fix: Ref 게임 로직과 시나리오 풀 호환을 보정

월 경계, 전투 기술 상한, 연감과 베팅·설문·경매 정산 순서를 Ref 계약에 맞춘다.\n\n시나리오 일반 풀을 ENGINE mutation과 logical tick 기반으로 직렬화하고 914·915 catalog 및 조건부 100기 pool 실행 경계를 추가한다.\n\n경매 worker는 세대별 durable event만 만들고 ENGINE이 row lock 후 상태 전이와 정산을 단일 transaction으로 소유한다.
This commit is contained in:
2026-08-23 16:26:14 +00:00
parent bf6b7be7b0
commit 85591c68ad
114 changed files with 13327 additions and 901 deletions
+181 -47
View File
@@ -1,7 +1,6 @@
import {
createGamePostgresConnector,
createRedisConnector,
GamePrisma,
type GamePrismaClient,
resolvePostgresConfigFromEnv,
resolveRedisConfigFromEnv,
@@ -9,7 +8,7 @@ import {
import { resolveGameApiConfigFromEnv } from '../config.js';
import { createBestEffortResourceCloser } from '../services/bestEffortResourceCloser.js';
import { loadCurrentGameTime } from '../services/gameClock.js';
import { loadCurrentGameTime, type CurrentGameTime } from '../services/gameClock.js';
import { createPollingWorkerControl, waitForWorkerPoll } from '../services/pollingWorkerLifecycle.js';
import { buildAuctionTimerKeys } from './keys.js';
import { resolveAuctionTimerScore, seedAuctionTimers } from './scheduler.js';
@@ -29,27 +28,84 @@ interface RedisTimerClient {
const AUCTION_FINALIZE_RECOVERY_LIMIT = 1;
const buildAuctionFinalizeRequestId = (auctionId: number, closeAt: Date, retry = 0): string => {
const generation = closeAt.getTime();
interface AuctionFinalizeDeadline {
closeAt: Date;
closeTick: bigint | null;
}
interface AuctionFinalizeCommand {
type: 'auctionFinalize';
requestId: string;
auctionId: number;
expectedCloseAt: string;
expectedCloseTick?: number;
}
interface AuctionFinalizeEventRecord {
target: string;
eventType: string;
payload: unknown;
status: string;
result: unknown;
}
const readSafeCloseTick = (closeTick: bigint | null): number | undefined => {
if (closeTick === null) return undefined;
const value = Number(closeTick);
if (!Number.isSafeInteger(value)) {
throw new Error(`Auction close tick is unsafe: ${closeTick}`);
}
return value;
};
export const buildAuctionFinalizeRequestId = (
auctionId: number,
deadline: AuctionFinalizeDeadline,
retry = 0
): string => {
const generation =
deadline.closeTick === null ? deadline.closeAt.getTime().toString() : `tick:${deadline.closeTick.toString()}`;
const base = `auction:finalize:${auctionId}:${generation}`;
return retry > 0 ? `${base}:retry:${retry}` : base;
};
const buildLegacyAuctionFinalizeRequestId = (auctionId: number, closeAt: Date, retry = 0): string => {
const base = `auction:finalize:${auctionId}:${closeAt.getTime()}`;
return retry > 0 ? `${base}:retry:${retry}` : base;
};
const buildAuctionFinalizeCommand = (
auctionId: number,
deadline: AuctionFinalizeDeadline,
requestId: string
): AuctionFinalizeCommand => ({
type: 'auctionFinalize',
requestId,
auctionId,
expectedCloseAt: deadline.closeAt.toISOString(),
...(deadline.closeTick === null ? {} : { expectedCloseTick: readSafeCloseTick(deadline.closeTick) }),
});
const isMatchingAuctionFinalizeEvent = (
event: { target: string; eventType: string; payload: unknown },
command: { type: 'auctionFinalize'; requestId: string; auctionId: number }
command: AuctionFinalizeCommand
): boolean => {
const payload = event.payload;
const payloadRecord =
payload !== null && typeof payload === 'object' && !Array.isArray(payload)
? (payload as Record<string, unknown>)
: null;
const expectedGenerationMatches =
payloadRecord?.expectedCloseTick !== undefined
? payloadRecord.expectedCloseTick === command.expectedCloseTick
: payloadRecord?.expectedCloseAt === undefined || payloadRecord.expectedCloseAt === command.expectedCloseAt;
return (
event.target === 'ENGINE' &&
event.eventType === command.type &&
payloadRecord?.type === command.type &&
payloadRecord.requestId === command.requestId &&
payloadRecord.auctionId === command.auctionId
payloadRecord.auctionId === command.auctionId &&
expectedGenerationMatches
);
};
@@ -80,6 +136,66 @@ const getNextDueMs = async (redis: RedisTimerClient, timerKey: string): Promise<
return next[0]?.score ?? null;
};
export const reconcilePendingAuctionTimers = async (options: {
db: Pick<GamePrismaClient, 'auction' | 'inputEvent'>;
redis: Pick<RedisTimerClient, 'zAdd' | 'zRem'>;
timerKey: string;
auctionIds: readonly number[];
gameTime: CurrentGameTime;
}): Promise<{ pendingIds: number[]; rescheduled: number }> => {
const auctionIds = [...new Set(options.auctionIds)];
if (auctionIds.length === 0) {
return { pendingIds: [], rescheduled: 0 };
}
const rows = await options.db.auction.findMany({
where: { id: { in: auctionIds } },
select: { id: true, status: true, closeAt: true, closeTick: true },
});
const pendingIds: number[] = [];
const timers: Array<{ score: number; value: string }> = [];
for (const row of rows) {
if (row.status !== 'OPEN' && row.status !== 'FINALIZING') {
continue;
}
const deadline = { closeAt: row.closeAt, closeTick: row.closeTick };
const canonicalBase = buildAuctionFinalizeRequestId(row.id, deadline);
const legacyBase = buildLegacyAuctionFinalizeRequestId(row.id, row.closeAt);
const bases = [...new Set([canonicalBase, legacyBase])];
const events = await options.db.inputEvent.findMany({
where: {
OR: bases.flatMap((base) => [{ requestId: base }, { requestId: { startsWith: `${base}:retry:` } }]),
},
select: { requestId: true, target: true, eventType: true, payload: true, status: true },
orderBy: { sequence: 'desc' },
});
const hasPendingCurrentGeneration = events.some((event) => {
if (event.status !== 'PENDING' && event.status !== 'PROCESSING') return false;
return isMatchingAuctionFinalizeEvent(
event,
buildAuctionFinalizeCommand(row.id, deadline, event.requestId)
);
});
if (hasPendingCurrentGeneration) {
pendingIds.push(row.id);
continue;
}
timers.push({
score:
row.status === 'FINALIZING'
? (options.gameTime.tick ?? options.gameTime.now.getTime())
: resolveAuctionTimerScore(options.gameTime, row.closeAt, row.closeTick),
value: String(row.id),
});
}
if (pendingIds.length > 0) {
await options.redis.zRem(options.timerKey, pendingIds.map(String));
}
if (timers.length > 0) {
await options.redis.zAdd(options.timerKey, timers);
}
return { pendingIds, rescheduled: timers.length };
};
export const processDueAuctionId = async (options: {
db: GamePrismaClient;
redis: RedisTimerClient;
@@ -89,7 +205,7 @@ export const processDueAuctionId = async (options: {
nowMs: number;
nowTick?: number | null;
historyNowMs?: number;
}): Promise<'FINALIZING' | 'RESCHEDULED' | 'IGNORED'> => {
}): Promise<'PENDING' | 'RESCHEDULED' | 'IGNORED'> => {
const { db, redis, timerKey, historyKey, id, nowMs, nowTick = null, historyNowMs = nowMs } = options;
const auctionId = Number(id);
if (!Number.isSafeInteger(auctionId) || auctionId < 1) {
@@ -97,73 +213,75 @@ export const processDueAuctionId = async (options: {
}
const now = new Date(nowMs);
const outcome = await db.$transaction(async (transaction) => {
const updated = await transaction.$executeRaw(
GamePrisma.sql`
UPDATE auction
SET status = 'FINALIZING',
finalizing_at = ${now},
updated_at = ${now}
WHERE id = ${auctionId}
AND status = 'OPEN'
AND (
(close_tick IS NOT NULL AND close_tick <= ${nowTick === null ? null : BigInt(nowTick)})
OR (close_tick IS NULL AND close_at <= ${now})
)
`
);
const current = await transaction.auction.findUnique({
where: { id: auctionId },
select: { status: true, closeAt: true, closeTick: true },
});
if (!current) {
if (updated > 0) {
throw new Error(`Auction disappeared after FINALIZING transition: ${auctionId}`);
}
return { status: 'IGNORED' as const };
}
if (current.status === 'OPEN') {
return { status: 'RESCHEDULED' as const, closeAt: current.closeAt, closeTick: current.closeTick };
const isDue =
current.closeTick !== null && nowTick !== null
? current.closeTick <= BigInt(nowTick)
: current.closeTick === null && current.closeAt.getTime() <= now.getTime();
if (!isDue) {
return { status: 'RESCHEDULED' as const, closeAt: current.closeAt, closeTick: current.closeTick };
}
}
if (current.status !== 'FINALIZING') {
if (current.status !== 'OPEN' && current.status !== 'FINALIZING') {
return { status: 'IGNORED' as const };
}
const deadline = { closeAt: current.closeAt, closeTick: current.closeTick };
for (let retry = 0; retry <= AUCTION_FINALIZE_RECOVERY_LIMIT; retry += 1) {
const requestId = buildAuctionFinalizeRequestId(auctionId, current.closeAt, retry);
const command = { type: 'auctionFinalize' as const, requestId, auctionId };
const existing = await transaction.inputEvent.findUnique({
where: { requestId },
select: { target: true, eventType: true, payload: true, status: true, result: true },
});
const requestId = buildAuctionFinalizeRequestId(auctionId, deadline, retry);
const legacyRequestId = buildLegacyAuctionFinalizeRequestId(auctionId, current.closeAt, retry);
const candidateRequestIds = [...new Set([requestId, legacyRequestId])];
let existing: AuctionFinalizeEventRecord | null = null;
let existingRequestId = requestId;
for (const candidateRequestId of candidateRequestIds) {
existing = await transaction.inputEvent.findUnique({
where: { requestId: candidateRequestId },
select: { target: true, eventType: true, payload: true, status: true, result: true },
});
if (existing) {
existingRequestId = candidateRequestId;
break;
}
}
const command = buildAuctionFinalizeCommand(auctionId, deadline, existingRequestId);
if (!existing) {
const nextCommand = buildAuctionFinalizeCommand(auctionId, deadline, requestId);
await transaction.inputEvent.create({
data: {
requestId,
target: 'ENGINE',
eventType: command.type,
payload: command,
eventType: nextCommand.type,
payload: { ...nextCommand },
},
});
return { status: 'FINALIZING' as const };
return { status: 'PENDING' as const };
}
if (!isMatchingAuctionFinalizeEvent(existing, command)) {
throw new Error(`Conflicting durable auction finalization event: ${requestId}`);
throw new Error(`Conflicting durable auction finalization event: ${existingRequestId}`);
}
if (existing.status === 'PENDING' || existing.status === 'PROCESSING') {
return { status: 'FINALIZING' as const };
return { status: 'PENDING' as const };
}
if (existing.status === 'SUCCEEDED' && isSuccessfulAuctionFinalizeResult(existing.result, auctionId)) {
throw new Error(`Auction remained FINALIZING after successful durable event: ${requestId}`);
throw new Error(
`Auction remained ${current.status} after successful durable event: ${existingRequestId}`
);
}
}
throw new Error(`Auction finalization recovery exhausted: ${auctionId}`);
});
if (outcome.status === 'FINALIZING') {
if (outcome.status === 'PENDING') {
// history retention은 운영 경과시간 기준이며 게임의 논리 시각과 분리한다.
await redis.zAdd(historyKey, [{ score: historyNowMs, value: id }]);
return 'FINALIZING';
return 'PENDING';
}
if (outcome.status === 'RESCHEDULED') {
const gameTime = await loadCurrentGameTime(db, now);
@@ -198,6 +316,7 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom
]);
let nextResyncAt = Date.now();
const pendingFinalizationIds = new Set<number>();
try {
while (!control.signal.aborted) {
@@ -205,20 +324,32 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom
const gameTime = await loadCurrentGameTime(postgres.prisma, new Date(operationalNowMs));
const gameNowMs = gameTime.now.getTime();
const dueScore = gameTime.tick ?? gameNowMs;
const historyTrimBefore = operationalNowMs - config.auctionTimerRetentionSeconds * 1000;
if (historyTrimBefore > 0) {
await redis.client.zRemRangeByScore(keys.historyKey, 0, historyTrimBefore);
}
if (operationalNowMs >= nextResyncAt) {
await seedAuctionTimers(postgres.prisma, redis.client, keys);
nextResyncAt = operationalNowMs + config.auctionTimerResyncMs;
}
if (pendingFinalizationIds.size > 0) {
const reconciliation = await reconcilePendingAuctionTimers({
db: postgres.prisma,
redis: redis.client,
timerKey: keys.timerKey,
auctionIds: [...pendingFinalizationIds],
gameTime,
});
pendingFinalizationIds.clear();
for (const auctionId of reconciliation.pendingIds) {
pendingFinalizationIds.add(auctionId);
}
}
const historyTrimBefore = operationalNowMs - config.auctionTimerRetentionSeconds * 1000;
if (historyTrimBefore > 0) {
await redis.client.zRemRangeByScore(keys.historyKey, 0, historyTrimBefore);
}
const dueIds = await popDueAuctionIds(redis.client, keys.timerKey, dueScore, 100);
if (dueIds.length > 0) {
for (const id of dueIds) {
try {
await processDueAuctionId({
const outcome = await processDueAuctionId({
db: postgres.prisma,
redis: redis.client,
timerKey: keys.timerKey,
@@ -228,6 +359,9 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom
nowTick: gameTime.tick,
historyNowMs: operationalNowMs,
});
if (outcome === 'PENDING') {
pendingFinalizationIds.add(Number(id));
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown auction worker error';
const trace = error instanceof Error ? error.stack : undefined;
@@ -112,6 +112,7 @@ export const buildBattleSimEnvironment = async (
maxAtmosByCommand: resolveNumber(constValues, ['maxAtmosByCommand'], DEFAULT_WAR_CONFIG.maxAtmosByCommand),
maxTrainByWar: resolveNumber(constValues, ['maxTrainByWar'], DEFAULT_WAR_CONFIG.maxTrainByWar),
maxAtmosByWar: resolveNumber(constValues, ['maxAtmosByWar'], DEFAULT_WAR_CONFIG.maxAtmosByWar),
maxTechLevel: resolveNumber(constValues, ['maxTechLevel'], 12),
maxGeneralStat: resolveNumber(constValues, ['maxLevel'], LEGACY_DEFAULT_MAX_LEVEL),
statUpgradeLimit: resolveNumber(constValues, ['upgradeLimit'], 30),
castleCrewTypeId,
+18 -6
View File
@@ -21,14 +21,26 @@ const stableJson = (value: unknown): string => {
}
return JSON.stringify(value) ?? 'null';
};
const commandIdentityJson = (value: unknown): string => {
export const commandIdentityJson = (value: unknown): string => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return stableJson(value);
}
const commandType = String(Reflect.get(value, 'type'));
if (
value &&
typeof value === 'object' &&
!Array.isArray(value) &&
Reflect.get(value, 'type') === 'npcPossessGeneral'
[
'npcPossessGeneral',
'selectPoolReserve',
'selectPoolCreate',
'selectPoolReselect',
'voteReward',
'auctionBid',
].includes(commandType)
) {
const { acceptedGameAt: _acceptedGameAt, ...identity } = value as Record<string, unknown>;
const {
acceptedGameAt: _acceptedGameAt,
acceptedGameTick: _acceptedGameTick,
...identity
} = value as Record<string, unknown>;
return stableJson(identity);
}
return stableJson(value);
+21 -9
View File
@@ -43,6 +43,7 @@ interface AuctionRow {
detail: unknown;
status: string;
closeAt: Date;
closeTick: bigint | null;
}
export interface AuctionDetail {
@@ -55,6 +56,14 @@ export interface AuctionDetail {
remainCloseDateExtensionCnt?: number | null;
}
export const hasAuctionClosePassed = (
auction: { closeAt: Date; closeTick: bigint | null },
time: { now: Date; tick: number | null }
): boolean =>
auction.closeTick !== null && time.tick !== null
? auction.closeTick < BigInt(time.tick)
: auction.closeAt.getTime() < time.now.getTime();
interface AuctionBidRow {
id: number;
generalId: number;
@@ -109,7 +118,8 @@ const loadAuction = async (db: DatabaseClient, auctionId: number): Promise<Aucti
host_general_id as "hostGeneralId",
detail,
status,
close_at as "closeAt"
close_at as "closeAt",
close_tick as "closeTick"
FROM auction
WHERE id = ${auctionId}
`
@@ -181,6 +191,8 @@ const shouldUsePrevBid = (highestBid: AuctionBidRow | null, myPrevBid: AuctionBi
return myPrevBid;
};
const MIN_AUCTION_REMAINING_RESOURCE = 1_000;
export const auctionRouter = router({
getOverview: authedProcedure.query(async ({ ctx }) => {
const auth = requireAuth(ctx);
@@ -372,8 +384,7 @@ export const auctionRouter = router({
}
const gameTime = await loadCurrentGameTime(ctx.db);
const { now } = gameTime;
if (auction.closeAt <= now) {
if (hasAuctionClosePassed(auction, gameTime)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '경매가 종료되었습니다.' });
}
if (auction.hostGeneralId === general.id) {
@@ -407,7 +418,7 @@ export const auctionRouter = router({
if (morePoint <= 0) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '입찰가가 유효하지 않습니다.' });
}
if (general.gold < morePoint) {
if (general.gold < morePoint + MIN_AUCTION_REMAINING_RESOURCE) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '금이 부족합니다.' });
}
@@ -417,6 +428,7 @@ export const auctionRouter = router({
auctionId: auction.id,
generalId: general.id,
amount: input.amount,
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
tryExtendCloseDate: true,
});
if (!result || result.type !== 'auctionBid') {
@@ -448,8 +460,7 @@ export const auctionRouter = router({
}
const gameTime = await loadCurrentGameTime(ctx.db);
const { now } = gameTime;
if (auction.closeAt <= now) {
if (hasAuctionClosePassed(auction, gameTime)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '경매가 종료되었습니다.' });
}
if (auction.hostGeneralId === general.id) {
@@ -483,7 +494,7 @@ export const auctionRouter = router({
if (morePoint <= 0) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '입찰가가 유효하지 않습니다.' });
}
if (general.rice < morePoint) {
if (general.rice < morePoint + MIN_AUCTION_REMAINING_RESOURCE) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '쌀이 부족합니다.' });
}
@@ -493,6 +504,7 @@ export const auctionRouter = router({
auctionId: auction.id,
generalId: general.id,
amount: input.amount,
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
tryExtendCloseDate: true,
});
if (!result || result.type !== 'auctionBid') {
@@ -524,8 +536,7 @@ export const auctionRouter = router({
}
const gameTime = await loadCurrentGameTime(ctx.db);
const { now } = gameTime;
if (auction.closeAt <= now) {
if (hasAuctionClosePassed(auction, gameTime)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '경매가 종료되었습니다.' });
}
@@ -630,6 +641,7 @@ export const auctionRouter = router({
auctionId: auction.id,
generalId: general.id,
amount: input.amount,
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
tryExtendCloseDate: input.tryExtendCloseDate ?? false,
});
if (!result || result.type !== 'auctionBid') {
+15 -6
View File
@@ -11,6 +11,7 @@ import {
WarTraitLoader,
WAR_TRAIT_KEYS,
isWarTraitKey,
isCentennialStatResetAllowed,
} from '@sammo-ts/logic';
import type { InheritBuffType, ItemSlot, MessageDraft, MessageRecordDraft } from '@sammo-ts/logic';
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
@@ -307,6 +308,7 @@ export const inheritRouter = router({
const resetTurnLevel = asNumber(asRecord(general.meta).inheritResetTurnTime, -1) + 1;
const config = asRecord(worldState.config);
const canResetStat = isCentennialStatResetAllowed(config);
const constValues = asRecord(config.const);
const availableSpecialWar = Array.isArray(constValues.availableSpecialWar)
? constValues.availableSpecialWar.filter((key): key is string => typeof key === 'string')
@@ -347,6 +349,7 @@ export const inheritRouter = router({
availableTargetGenerals: others,
turnTimeZones: buildTurnTimeZoneList(Math.max(1, Math.round(worldState.tickSeconds / 60))),
isUnited,
canResetStat,
currentSpecialWar: general.special2Code ?? 'None',
currentStat: {
leadership: general.leadership,
@@ -700,12 +703,6 @@ export const inheritRouter = router({
});
}
const currentPoint = await readInheritancePoint(ctx.db, userId, 'previous');
const cost = bonusSum > 0 ? inheritConst.inheritBornStatPoint : 0;
if (currentPoint < cost) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '유산 포인트가 부족합니다.' });
}
const general = await ctx.db.general.findFirst({
where: { userId },
select: { id: true, npcState: true },
@@ -716,6 +713,18 @@ export const inheritRouter = router({
if (general.npcState >= 2) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'NPC는 능력치 초기화를 할 수 없습니다.' });
}
if (!isCentennialStatResetAllowed(config)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: '100기 올스타 장수는 능력치 초기화를 사용할 수 없습니다.',
});
}
const currentPoint = await readInheritancePoint(ctx.db, userId, 'previous');
const cost = bonusSum > 0 ? inheritConst.inheritBornStatPoint : 0;
if (currentPoint < cost) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '유산 포인트가 부족합니다.' });
}
const seasonValue = resolveSeasonValue(worldMeta);
if (seasonValue !== null) {
+40 -17
View File
@@ -16,11 +16,7 @@ import {
import { readInheritancePoint, resolveInheritConstants } from '../../services/inheritance.js';
import { loadAuthoritativeAccountIcon } from '../../services/accountIconSync.js';
import { loadCurrentGameTime } from '../../services/gameClock.js';
import {
getSelectionPoolStatus,
reserveSelectionPool,
resolveSelectionMaxGeneral,
} from '@sammo-ts/game-engine/turn/selectPoolService.js';
import { getSelectionPoolStatus, resolveSelectionMaxGeneral } from '@sammo-ts/game-engine/turn/selectPoolService.js';
import {
ConflictingTurnDaemonCommandError,
RejectedNpcPossessionCommandError,
@@ -54,6 +50,31 @@ const resolveSelectionCommandResult = (
return { ok: true, generalId: result.generalId };
};
const resolveSelectionReservationCommandResult = (
result: Awaited<ReturnType<GameApiContext['turnDaemon']['requestCommand']>> | null
) => {
if (!result) {
throw new TRPCError({
code: 'TIMEOUT',
message:
'장수 선택 목록 요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다. 같은 요청으로 다시 시도해 주세요.',
});
}
if (result.type !== 'selectPoolReserve') {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: '턴 데몬이 올바르지 않은 장수 선택 목록 결과를 반환했습니다.',
});
}
if (!result.ok) {
throw new TRPCError({ code: result.code, message: result.reason });
}
return result.reservation;
};
const resolveSelectionReservationRequestId = (contextRequestId: string | undefined, userId: string) =>
contextRequestId ? `select-pool:${userId}:${contextRequestId}:reserve` : undefined;
const resolveSelectionRequestId = (
contextRequestId: string | undefined,
userId: string,
@@ -366,26 +387,22 @@ export const joinRouter = router({
},
};
}),
getSelectionPool: authedProcedure.mutation(async ({ ctx }) => {
getSelectionPool: engineAuthedProcedure.mutation(async ({ ctx }) => {
const userId = ctx.auth?.user.id;
if (!userId) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
const worldState = await ctx.db.worldState.findFirst();
if (!worldState) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'World state is not initialized.',
});
}
const gameTime = await loadCurrentGameTime(ctx.db);
return reserveSelectionPool({
db: ctx.db,
worldState,
const commandRequestId = resolveSelectionReservationRequestId(ctx.requestId, userId);
const result = await ctx.turnDaemon.requestCommand({
type: 'selectPoolReserve',
...(commandRequestId ? { requestId: commandRequestId } : {}),
userId,
now: gameTime.now,
seedOwnerIdentity: ctx.auth?.user.legacyMemberNo ?? userId,
acceptedGameAt: gameTime.now.toISOString(),
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
});
return resolveSelectionReservationCommandResult(result);
}),
selectPoolGeneral: engineAuthedProcedure
.input(
@@ -413,6 +430,7 @@ export const joinRouter = router({
});
}
const commandRequestId = resolveSelectionRequestId(ctx.requestId, userId, input.clientRequestId, 'create');
const gameTime = await loadCurrentGameTime(ctx.db);
const result = await ctx.turnDaemon.requestCommand({
type: 'selectPoolCreate',
...(commandRequestId ? { requestId: commandRequestId } : {}),
@@ -421,6 +439,8 @@ export const joinRouter = router({
uniqueName: input.uniqueName,
personality: input.personality,
seedOwnerIdentity: auth.user.legacyMemberNo ?? userId,
acceptedGameAt: gameTime.now.toISOString(),
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
...(selectedIcon
? {
ownerPicture: selectedIcon.picture,
@@ -450,12 +470,15 @@ export const joinRouter = router({
input.clientRequestId,
'reselect'
);
const gameTime = await loadCurrentGameTime(ctx.db);
const result = await ctx.turnDaemon.requestCommand({
type: 'selectPoolReselect',
...(commandRequestId ? { requestId: commandRequestId } : {}),
userId,
ownerDisplayName: auth.user.displayName,
uniqueName: input.uniqueName,
acceptedGameAt: gameTime.now.toISOString(),
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
});
return resolveSelectionCommandResult(result, 'selectPoolReselect');
}),
+8 -3
View File
@@ -221,11 +221,14 @@ const resolveScenarioStat = (config: Record<string, unknown>): { max: number; np
};
};
const resolveCommandEnv = (config: Record<string, unknown>): { develCost: number; defaultCrewTypeId: number } => {
const resolveCommandEnv = (
config: Record<string, unknown>
): { develCost: number; defaultCrewTypeId: number; maxTechLevel: number } => {
const constValues = asRecord(config.const ?? config.consts);
return {
develCost: resolveNumberFromKeys(constValues, ['develCost', 'develcost', 'develrate'], 0),
defaultCrewTypeId: resolveNumberFromKeys(constValues, ['defaultCrewTypeId'], 0),
maxTechLevel: resolveNumberFromKeys(constValues, ['maxTechLevel'], 12),
};
};
@@ -345,13 +348,14 @@ const buildZeroPolicy = async (
nationTech: number;
develCost: number;
defaultCrewTypeId: number;
maxTechLevel: number;
unitSetName: string;
}
): Promise<NationPolicy> => {
const { statMax, statNpcMax, nationTech, develCost, defaultCrewTypeId, unitSetName } = options;
const { statMax, statNpcMax, nationTech, develCost, defaultCrewTypeId, maxTechLevel, unitSetName } = options;
const unitSet = await loadUnitSetDefinitionByName(unitSetName);
const crewType = findCrewTypeById(unitSet, defaultCrewTypeId || unitSet.defaultCrewTypeId || 0);
const techCost = getTechCost(nationTech);
const techCost = getTechCost(nationTech, maxTechLevel);
const next = clonePolicy(policy);
if (next.reqNPCDevelGold === 0) {
@@ -511,6 +515,7 @@ export const npcRouter = router({
nationTech,
develCost: env.develCost,
defaultCrewTypeId: env.defaultCrewTypeId,
maxTechLevel: env.maxTechLevel,
unitSetName,
});
+9 -6
View File
@@ -29,6 +29,13 @@ const resolveNumber = (source: Record<string, unknown>, keys: string[], fallback
return fallback;
};
const resolveCurrentDevelCost = (worldState: { config?: unknown; meta?: unknown } | null): number => {
const config = asRecord(worldState?.config ?? {});
const constValues = asRecord(config.const ?? config);
const configured = resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0);
return resolveNumber(asRecord(worldState?.meta), ['develcost', 'develCost', 'develrate'], configured);
};
const adminProcedure = authedProcedure.use(({ ctx, next }) => {
const roles = ctx.auth?.user.roles ?? [];
if (!hasAdminRole(roles, ctx.profile.name)) {
@@ -403,9 +410,7 @@ export const tournamentRouter = router({
throw new TRPCError({ code: 'BAD_REQUEST', message: '참가 인원이 가득 찼습니다.' });
}
const config = asRecord(worldState?.config ?? {});
const constValues = asRecord(config.const ?? config);
const develCost = resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0);
const develCost = resolveCurrentDevelCost(worldState);
const feeResult = await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources',
reason: 'tournamentJoin',
@@ -462,9 +467,7 @@ export const tournamentRouter = router({
const [participants, bets] = await Promise.all([store.getParticipants(), store.getBettingEntries()]);
const worldState = await ctx.db.worldState.findFirst();
const config = asRecord(worldState?.config ?? {});
const constValues = asRecord(config.const ?? config);
const develCost = resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0);
const develCost = resolveCurrentDevelCost(worldState);
const refundMap = new Map<number, number>();
for (const participant of participants) {
+11 -153
View File
@@ -1,20 +1,8 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { asRecord, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import { asRecord } from '@sammo-ts/common';
import { GamePrisma } from '@sammo-ts/infra';
import {
ITEM_KEYS,
addOccupiedUniqueItemKeys,
buildVoteUniqueSeed,
countOccupiedUniqueItems,
createItemModuleRegistry,
loadItemModules,
resolveUniqueConfig,
rollUniqueLottery,
type GeneralItemSlots,
type ItemModule,
} from '@sammo-ts/logic';
import { authedProcedure, router } from '../../trpc.js';
import { getMyGeneral } from '../shared/general.js';
@@ -40,15 +28,6 @@ const adminProcedure = authedProcedure.use(({ ctx, next }) => {
return next();
});
let itemRegistryPromise: Promise<Map<string, ItemModule>> | null = null;
const getItemRegistry = async (): Promise<Map<string, ItemModule>> => {
if (!itemRegistryPromise) {
itemRegistryPromise = loadItemModules([...ITEM_KEYS]).then((modules) => createItemModuleRegistry(modules));
}
return itemRegistryPromise;
};
const resolveNumber = (source: Record<string, unknown>, keys: string[], fallback: number): number => {
for (const key of keys) {
const value = source[key];
@@ -65,30 +44,9 @@ const resolveNumber = (source: Record<string, unknown>, keys: string[], fallback
return fallback;
};
const normalizeCode = (value: string | null | undefined): string | null => {
if (!value || value === 'None') {
return null;
}
return value;
};
const normalizeOptions = (options: string[]): string[] =>
options.map((option) => option.trim()).filter((option) => option.length > 0);
const readMetaNumber = (meta: Record<string, unknown>, key: string, fallback: number): number => {
const value = meta[key];
if (typeof value === 'number' && Number.isFinite(value)) {
return Math.floor(value);
}
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return Math.floor(parsed);
}
}
return fallback;
};
const parseOptions = (value: unknown): string[] => {
if (Array.isArray(value)) {
return value.filter((entry): entry is string => typeof entry === 'string');
@@ -138,11 +96,14 @@ type VotePollRow = {
closed_at: Date | null;
};
const hasPollEnded = (poll: Pick<VotePollRow, 'closed_at' | 'end_at' | 'end_tick'>, time: CurrentGameTime): boolean =>
export const hasPollEnded = (
poll: Pick<VotePollRow, 'closed_at' | 'end_at' | 'end_tick'>,
time: CurrentGameTime
): boolean =>
Boolean(poll.closed_at) ||
(poll.end_tick !== null && time.tick !== null
? poll.end_tick <= BigInt(time.tick)
: Boolean(poll.end_at && poll.end_at <= time.now));
? poll.end_tick < BigInt(time.tick)
: Boolean(poll.end_at && poll.end_at.getTime() < time.now.getTime()));
const toGameTickOrNull = (time: CurrentGameTime, date: Date | null): bigint | null => {
if (!date) return null;
@@ -388,116 +349,12 @@ export const voteRouter = router({
}
const general = await getMyGeneral(ctx);
const rows = await ctx.db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
INSERT INTO vote (vote_id, general_id, nation_id, selection)
VALUES (
${input.voteId},
${general.id},
${general.nationId},
CAST(${JSON.stringify(sortedSelection)} AS jsonb)
)
ON CONFLICT (vote_id, general_id) DO NOTHING
RETURNING id
`);
if (!rows[0]?.id) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 설문조사를 완료하였습니다.' });
}
const worldState = await ctx.db.worldState.findFirst();
if (!worldState) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state is not initialized.' });
}
const worldMeta = asRecord(worldState.meta);
const config = asRecord(worldState.config);
const constValues = asRecord(config.const);
const develCost = resolveNumber(
worldMeta,
['develcost', 'develCost'],
resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0)
);
const voteReward = develCost * 5;
const scenarioMeta = asRecord(worldMeta.scenarioMeta);
const startYear = readMetaNumber(scenarioMeta, 'startYear', worldState.currentYear);
const initYear = readMetaNumber(worldMeta, 'initYear', startYear);
const initMonth = readMetaNumber(worldMeta, 'initMonth', 1);
const scenarioId = readMetaNumber(worldMeta, 'scenarioId', 0);
const hiddenSeed = worldMeta.hiddenSeed ?? worldMeta.seed ?? worldState.id;
const itemRegistry = await getItemRegistry();
const uniqueConfig = resolveUniqueConfig(constValues);
const [generalRows, reservedUniqueRows] = await Promise.all([
ctx.db.general.findMany({
select: {
horseCode: true,
weaponCode: true,
bookCode: true,
itemCode: true,
},
}),
ctx.db.auction.findMany({
where: {
type: 'UNIQUE_ITEM',
status: { in: ['OPEN', 'FINALIZING'] },
targetCode: { not: null },
},
select: { targetCode: true },
}),
]);
const generalItems: GeneralItemSlots[] = generalRows.map((row) => ({
horse: normalizeCode(row.horseCode),
weapon: normalizeCode(row.weaponCode),
book: normalizeCode(row.bookCode),
item: normalizeCode(row.itemCode),
}));
const occupiedUniqueCounts = countOccupiedUniqueItems(generalItems, itemRegistry);
addOccupiedUniqueItemKeys(
occupiedUniqueCounts,
reservedUniqueRows.map((row) => row.targetCode),
itemRegistry
);
const userCount = await ctx.db.general.count({ where: { npcState: { lt: 2 } } });
const rngSeed = buildVoteUniqueSeed(
typeof hiddenSeed === 'string' || typeof hiddenSeed === 'number' ? hiddenSeed : String(hiddenSeed),
input.voteId,
general.id
);
const rng = new RandUtil(LiteHashDRBG.build(rngSeed));
const itemKey = rollUniqueLottery({
rng,
config: uniqueConfig,
itemRegistry,
generalItems: {
horse: normalizeCode(general.horseCode),
weapon: normalizeCode(general.weaponCode),
book: normalizeCode(general.bookCode),
item: normalizeCode(general.itemCode),
},
occupiedUniqueCounts,
scenarioId,
userCount,
currentYear: worldState.currentYear,
currentMonth: worldState.currentMonth,
startYear,
initYear,
initMonth,
acquireType: '설문조사',
});
const rewardResult = await ctx.turnDaemon.requestCommand({
type: 'voteReward',
voteId: input.voteId,
generalId: general.id,
goldReward: voteReward,
unique: {
expected: Boolean(itemKey),
itemKey: itemKey ?? null,
},
selection: sortedSelection,
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
});
if (!rewardResult || rewardResult.type !== 'voteReward') {
@@ -559,6 +416,7 @@ export const voteRouter = router({
)
.mutation(async ({ ctx, input }) => {
const general = await getMyGeneral(ctx);
const openerName = ctx.auth?.user.username ?? general.name;
const options = normalizeOptions(input.options);
if (options.length === 0) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '항목이 없습니다.' });
@@ -610,7 +468,7 @@ export const voteRouter = router({
${multipleOptions},
${input.revealMode},
${general.id},
${general.name},
${openerName},
${gameTime.now},
${gameTime.tick === null ? null : BigInt(gameTime.tick)},
${endAt},
+30 -14
View File
@@ -45,6 +45,9 @@ const recordHistoryAccess = async (ctx: GameApiContext): Promise<void> => {
const parseTextArray = (value: unknown): string[] =>
Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
const readStoredSnapshotNumber = (value: unknown): number | null =>
typeof value === 'number' && Number.isFinite(value) ? value : null;
const parseYearbookNations = (value: unknown): YearbookNation[] => {
if (!Array.isArray(value)) {
return [];
@@ -114,6 +117,7 @@ const buildNationSnapshot = async (ctx: GameApiContext) => {
gold: true,
rice: true,
tech: true,
meta: true,
},
orderBy: { id: 'asc' },
}),
@@ -197,33 +201,45 @@ const buildNationSnapshot = async (ctx: GameApiContext) => {
generalStatsByNation.set(general.nationId, entry);
}
return nationRows.map<YearbookNation>((nation) => {
const projected = nationRows.map<YearbookNation>((nation) => {
const generalStats = generalStatsByNation.get(nation.id) ?? {
goldRice: 0,
statPower: 0,
expDed: 0,
generalCount: 0,
};
const cityStats = cityStatsByNation.get(nation.id) ?? { popSum: 0, valueSum: 0, maxSum: 0 };
const resource = Math.round(((nation.gold ?? 0) + (nation.rice ?? 0) + generalStats.goldRice) / 100);
const tech = nation.tech ?? 0;
const cityPower =
nation.level > 0 && cityStats.maxSum > 0
? Math.round((cityStats.popSum * cityStats.valueSum) / cityStats.maxSum / 100)
: 0;
const expDed = Math.round(generalStats.expDed / 100);
const power = Math.round((resource + tech + cityPower + generalStats.statPower + expDed) / 10);
const nationMeta = asRecord(nation.meta);
const storedPower = readStoredSnapshotNumber(nationMeta.power);
let power = 1;
if (nation.id !== 0) {
if (storedPower !== null) {
power = storedPower;
} else {
const cityStats = cityStatsByNation.get(nation.id) ?? { popSum: 0, valueSum: 0, maxSum: 0 };
const resource = Math.round(((nation.gold ?? 0) + (nation.rice ?? 0) + generalStats.goldRice) / 100);
const tech = nation.tech ?? 0;
const cityPower =
nation.level > 0 && cityStats.maxSum > 0
? Math.round((cityStats.popSum * cityStats.valueSum) / cityStats.maxSum / 100)
: 0;
const expDed = Math.round(generalStats.expDed / 100);
power = Math.round((resource + tech + cityPower + generalStats.statPower + expDed) / 10);
}
}
const storedGeneralCount = readStoredSnapshotNumber(nationMeta.gennum);
const generalCount = nation.id === 0 ? 1 : (storedGeneralCount ?? generalStats.generalCount);
return {
id: nation.id,
name: nation.name,
color: nation.color,
level: nation.level,
name: nation.id === 0 ? '재야' : nation.name,
color: nation.id === 0 ? '#000000' : nation.color,
level: nation.id === 0 ? 0 : nation.level,
power,
generalCount: generalStats.generalCount,
generalCount,
cities: cityNamesByNation.get(nation.id) ?? [],
};
});
return projected.sort((left, right) => right.power - left.power);
};
const readGlobalActionLogs = async (ctx: GameApiContext, year: number, month: number) => {
+1
View File
@@ -565,6 +565,7 @@ export const settleTournamentOutcome = async (options: {
type: 'tournamentBettingPayout',
requestId: `tournament:${settledState.bettingId}:betting-payout`,
bettingId: settledState.bettingId,
tournamentType: settledState.type,
payouts: payoutInfo.payouts,
reason: 'winner_payout',
});
+13 -2
View File
@@ -617,11 +617,22 @@ export const buildBettingPayouts = (
winnerId: number,
entries: TournamentBetEntry[]
): { payouts: Array<{ generalId: number; amount: number }>; total: number; refundAll: boolean } => {
const total = entries.reduce((sum, entry) => sum + entry.amount, 0);
const aggregated = new Map<string, TournamentBetEntry>();
for (const entry of entries) {
const key = `${entry.generalId}:${entry.targetId}`;
const previous = aggregated.get(key);
if (previous) {
previous.amount += entry.amount;
} else {
aggregated.set(key, { ...entry });
}
}
const normalizedEntries = [...aggregated.values()];
const total = normalizedEntries.reduce((sum, entry) => sum + entry.amount, 0);
if (total <= 0) {
return { payouts: [], total: 0, refundAll: false };
}
const winners = entries.filter((entry) => entry.targetId === winnerId);
const winners = normalizedEntries.filter((entry) => entry.targetId === winnerId);
const winnersTotal = winners.reduce((sum, entry) => sum + entry.amount, 0);
if (winnersTotal <= 0) {
// Legacy Betting::_calcRewardExclusive() builds a refund candidate list
+20 -7
View File
@@ -396,6 +396,7 @@ const buildConstraintEnv = (worldState: WorldStateRow): Record<string, unknown>
relYear,
join_mode: joinMode,
openingPartYear: resolveNumber(constValues, ['openingPartYear'], 0),
maxTechLevel: resolveNumber(constValues, ['maxTechLevel'], 12),
};
};
@@ -515,19 +516,31 @@ export const buildRecruitmentCommandInfo = (options: {
const city = options.city ? mapCityRow(options.city) : undefined;
const nation = options.nation ? mapNationRow(options.nation) : null;
const cities = options.cities.map(mapCityRow);
const context = city ? { general, city, nation } : { general, nation };
const command = new RecruitmentCommandResolver(options.generalActionModules ?? [], {});
const tech = options.nation?.tech ?? 0;
const techAbility = getTechAbility(tech);
const commandEnv = buildCommandEnv(options.worldState);
const constraintEnv = buildConstraintEnv(options.worldState);
const startYear = typeof constraintEnv.startYear === 'number' ? constraintEnv.startYear : undefined;
const configuredStartYear = typeof constraintEnv.startYear === 'number' ? constraintEnv.startYear : undefined;
const startYear = configuredStartYear ?? options.worldState.currentYear;
const context = {
general,
...(city ? { city } : {}),
nation,
time: {
year: options.worldState.currentYear,
month: options.worldState.currentMonth,
startYear,
},
maxTechLevel: commandEnv.maxTechLevel,
};
const command = new RecruitmentCommandResolver(options.generalActionModules ?? [], commandEnv);
const tech = options.nation?.tech ?? 0;
const techAbility = getTechAbility(tech, commandEnv.maxTechLevel);
const availabilityContext = {
general,
nation,
map: options.map,
cities,
currentYear: options.worldState.currentYear,
...(startYear === undefined ? {} : { startYear }),
...(configuredStartYear === undefined ? {} : { startYear: configuredStartYear }),
};
const crewTypes = options.unitSet.crewTypes ?? [];
const armTypes = Object.entries(options.unitSet.armTypes ?? {})
@@ -565,7 +578,7 @@ export const buildRecruitmentCommandInfo = (options: {
const currentCrewTypeName = crewTypes.find((crewType) => crewType.id === general.crewTypeId)?.name ?? '-';
return {
techLevel: getTechLevel(tech),
techLevel: getTechLevel(tech, commandEnv.maxTechLevel),
leadership: command.resolveLeadership(context),
fullLeadership: command.resolveFullLeadership(context),
currentCrewTypeId: general.crewTypeId,
+62
View File
@@ -8,6 +8,7 @@ import { InMemoryFlushStore } from '../src/auth/flushStore.js';
import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js';
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
import { appRouter } from '../src/router.js';
import { hasAuctionClosePassed } from '../src/router/auction/index.js';
const buildGeneral = (overrides: Partial<GeneralRow> = {}): GeneralRow => ({
id: 7,
@@ -80,6 +81,7 @@ const buildContext = (options: {
isunited?: number;
requestId?: string;
transaction?: ReturnType<typeof vi.fn>;
clockTick?: number;
}) => {
const auth = options.auth === undefined ? buildAuth() : options.auth;
const general = options.general === undefined ? buildGeneral() : options.general;
@@ -106,6 +108,14 @@ const buildContext = (options: {
currentYear: 200,
currentMonth: 1,
tickSeconds: 3600,
...(options.clockTick === undefined
? {}
: {
clockBaseTime: new Date('2026-07-26T00:00:00.000Z'),
clockTick: BigInt(options.clockTick),
clockMode: 'manual',
clockWallAnchor: new Date('2026-07-26T00:00:00.000Z'),
}),
config: {
const: {
auctionName: ['청룡', '백호', '주작', '현무'],
@@ -169,6 +179,20 @@ const buildContext = (options: {
};
describe('auction router actor and permission boundaries', () => {
it('keeps the auction open through its authoritative close tick', () => {
const closeAt = new Date('2026-07-27T00:00:00.000Z');
const auction = { closeAt, closeTick: 72_000_000n };
expect(hasAuctionClosePassed(auction, { now: closeAt, tick: 72_000_000 })).toBe(false);
expect(
hasAuctionClosePassed(auction, {
now: new Date(closeAt.getTime() + 1),
tick: 72_000_001,
})
).toBe(true);
expect(hasAuctionClosePassed({ closeAt, closeTick: null }, { now: closeAt, tick: null })).toBe(false);
});
it('rejects unauthenticated auction reads', async () => {
const fixture = buildContext({ auth: null });
@@ -329,6 +353,7 @@ describe('auction router actor and permission boundaries', () => {
detail: { startBidAmount: 100, isReverse: false },
status: 'OPEN',
closeAt: new Date(Date.now() + 60 * 60_000),
closeTick: 100n,
},
];
}
@@ -346,6 +371,7 @@ describe('auction router actor and permission boundaries', () => {
}
return [];
},
clockTick: 100,
});
await appRouter.createCaller(fixture.context).auction.bidUnique({
@@ -358,7 +384,43 @@ describe('auction router actor and permission boundaries', () => {
auctionId: 31,
generalId: 7,
amount: 110,
acceptedGameTick: 100,
tryExtendCloseDate: false,
});
});
it('keeps the Ref 1000 gold reserve after a resource-auction bid', async () => {
const queryRaw = async (query: GamePrisma.Sql) => {
const text = sqlText(query);
if (text.includes('FROM auction') && text.includes('WHERE id =')) {
return [
{
id: 31,
type: 'BUY_RICE',
targetCode: '100',
hostGeneralId: 88,
detail: { title: '쌀 100 경매', amount: 100, startBidAmount: 500, isReverse: false },
status: 'OPEN',
closeAt: new Date(Date.now() + 60 * 60_000),
},
];
}
if (text.includes('FROM auction_bid')) {
return [];
}
return [];
};
const accepted = buildContext({ general: buildGeneral({ gold: 1_500 }), queryRaw });
await expect(
appRouter.createCaller(accepted.context).auction.bidBuyRice({ auctionId: 31, amount: 500 })
).resolves.toEqual({ ok: true });
expect(accepted.requestCommand).toHaveBeenCalledOnce();
const rejected = buildContext({ general: buildGeneral({ gold: 1_499 }), queryRaw });
await expect(
appRouter.createCaller(rejected.context).auction.bidBuyRice({ auctionId: 31, amount: 500 })
).rejects.toMatchObject({ code: 'BAD_REQUEST', message: '금이 부족합니다.' });
expect(rejected.requestCommand).not.toHaveBeenCalled();
});
});
@@ -21,7 +21,7 @@ import type { GamePrisma } from '@sammo-ts/infra';
import type { MapDefinition, ScenarioConfig, ScenarioMeta, TurnSchedule } from '@sammo-ts/logic';
import { buildAuctionTimerKeys } from '../src/auction/keys.js';
import { processDueAuctionId, runAuctionWorker } from '../src/auction/worker.js';
import { buildAuctionFinalizeRequestId, processDueAuctionId, runAuctionWorker } from '../src/auction/worker.js';
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
const liveDescribe = databaseUrl && process.env.REDIS_URL ? describe : describe.skip;
@@ -80,8 +80,11 @@ liveDescribe('auction worker durable recovery', () => {
return auction;
};
const requestIdFor = (auction: { id: number; closeAt: Date }): string =>
`auction:finalize:${auction.id}:${auction.closeAt.getTime()}`;
const requestIdFor = (auction: { id: number; closeAt: Date; closeTick?: bigint | null }): string =>
buildAuctionFinalizeRequestId(auction.id, {
closeAt: auction.closeAt,
closeTick: auction.closeTick ?? null,
});
const memoryRedis = () => ({
zRangeByScore: vi.fn(async () => []),
@@ -91,7 +94,7 @@ liveDescribe('auction worker durable recovery', () => {
zRemRangeByScore: vi.fn(async () => 0),
});
it('atomically moves OPEN to FINALIZING and creates one deterministic input event', async () => {
it('leaves OPEN and creates one deterministic input event', async () => {
const auction = await createAuction('OPEN');
const redis = memoryRedis();
@@ -104,7 +107,7 @@ liveDescribe('auction worker durable recovery', () => {
id: String(auction.id),
nowMs: Date.now(),
})
).resolves.toBe('FINALIZING');
).resolves.toBe('PENDING');
await expect(
processDueAuctionId({
db: connector.prisma,
@@ -114,13 +117,13 @@ liveDescribe('auction worker durable recovery', () => {
id: String(auction.id),
nowMs: Date.now(),
})
).resolves.toBe('FINALIZING');
).resolves.toBe('PENDING');
const [storedAuction, events] = await Promise.all([
connector.prisma.auction.findUniqueOrThrow({ where: { id: auction.id } }),
connector.prisma.inputEvent.findMany({ where: { requestId: requestIdFor(auction) } }),
]);
expect(storedAuction.status).toBe('FINALIZING');
expect(storedAuction.status).toBe('OPEN');
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({
target: 'ENGINE',
@@ -130,6 +133,7 @@ liveDescribe('auction worker durable recovery', () => {
type: 'auctionFinalize',
requestId: requestIdFor(auction),
auctionId: auction.id,
expectedCloseAt: auction.closeAt.toISOString(),
},
});
});
@@ -191,7 +195,7 @@ liveDescribe('auction worker durable recovery', () => {
id: String(auction.id),
nowMs: Date.now(),
})
).resolves.toBe('FINALIZING');
).resolves.toBe('PENDING');
await expect(
processDueAuctionId({
db: connector.prisma,
@@ -201,7 +205,7 @@ liveDescribe('auction worker durable recovery', () => {
id: String(auction.id),
nowMs: Date.now(),
})
).resolves.toBe('FINALIZING');
).resolves.toBe('PENDING');
await expect(
connector.prisma.inputEvent.findMany({
where: { requestId: { startsWith: requestId } },
@@ -258,7 +262,7 @@ liveDescribe('auction worker durable recovery', () => {
id: String(auction.id),
nowMs: Date.now(),
})
).resolves.toBe('FINALIZING');
).resolves.toBe('PENDING');
await expect(
connector.prisma.inputEvent.findUnique({ where: { requestId: requestIdFor(auction) } })
@@ -531,7 +535,11 @@ liveDescribe('auction worker durable recovery', () => {
where: { id: extensionAuction.id },
data: { closeAt: secondCloseAt, closeTick: BigInt(secondCloseTick) },
});
const secondExtensionRequestId = requestIdFor({ id: extensionAuction.id, closeAt: secondCloseAt });
const secondExtensionRequestId = requestIdFor({
id: extensionAuction.id,
closeAt: secondCloseAt,
closeTick: BigInt(secondCloseTick),
});
await processDueAuctionId({
db: connector.prisma,
redis: memoryRedis(),
+236 -17
View File
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest';
import type { GamePrismaClient } from '@sammo-ts/infra';
import { processDueAuctionId } from '../src/auction/worker.js';
import { processDueAuctionId, reconcilePendingAuctionTimers } from '../src/auction/worker.js';
import { resolveAuctionSeedScore } from '../src/auction/scheduler.js';
const buildRedis = () => ({
@@ -32,14 +32,16 @@ const buildDb = (options: {
const transaction = {
$executeRaw: vi.fn(async () => options.updated),
auction: {
findUnique: vi.fn(async () => options.auction ?? null),
findUnique: vi.fn(async () =>
options.auction ? { ...options.auction, closeTick: options.auction.closeTick ?? null } : null
),
},
inputEvent: {
findUnique: vi.fn(
async ({ where }: { where: { requestId: string } }) =>
options.existingEvents?.find((event) => event.requestId === where.requestId) ?? null
),
create: vi.fn(async () => ({ sequence: 1n })),
create: vi.fn(async (_args?: { data: { eventType: string } }) => ({ sequence: 1n })),
},
};
return {
@@ -84,6 +86,108 @@ describe('auction worker clock-shift race', () => {
).toBe(36_000_000);
});
it('requeues an auction immediately after the engine commits an OPEN extension', async () => {
const redis = buildRedis();
const closeAt = new Date('2099-01-01T00:00:00.000Z');
const pendingRequestId = 'auction:finalize:8:tick:72000000';
const db = {
auction: {
findMany: vi.fn(async () => [
{ id: 7, status: 'OPEN', closeAt, closeTick: 72_000_000n },
{ id: 8, status: 'FINALIZING', closeAt, closeTick: 72_000_000n },
{ id: 9, status: 'FINISHED', closeAt, closeTick: 72_000_000n },
]),
},
inputEvent: {
findMany: vi.fn(async () => [
{
requestId: pendingRequestId,
target: 'ENGINE',
eventType: 'auctionFinalize',
payload: {
type: 'auctionFinalize',
requestId: pendingRequestId,
auctionId: 8,
expectedCloseAt: closeAt.toISOString(),
expectedCloseTick: 72_000_000,
},
status: 'PENDING',
},
]),
},
} as unknown as Pick<GamePrismaClient, 'auction' | 'inputEvent'>;
const now = new Date('2026-07-30T12:00:00.000Z');
await expect(
reconcilePendingAuctionTimers({
db,
redis,
timerKey: 'timer',
auctionIds: [7, 8, 9],
gameTime: {
now,
wallNow: now,
tick: 36_000_000,
mode: 'manual',
running: false,
startsAt: null,
dateToTick: () => 72_000_000,
},
})
).resolves.toEqual({ pendingIds: [8], rescheduled: 1 });
expect(redis.zAdd).toHaveBeenCalledWith('timer', [{ score: 72_000_000, value: '7' }]);
expect(redis.zRem).toHaveBeenCalledWith('timer', ['8']);
});
it('ignores a prior pending generation and schedules the extended OPEN deadline', async () => {
const redis = buildRedis();
const closeAt = new Date('2099-01-01T00:30:00.000Z');
const previousRequestId = 'auction:finalize:7:tick:72000000';
const db = {
auction: {
findMany: vi.fn(async () => [{ id: 7, status: 'OPEN', closeAt, closeTick: 108_000_000n }]),
},
inputEvent: {
findMany: vi.fn(async () => [
{
requestId: previousRequestId,
target: 'ENGINE',
eventType: 'auctionFinalize',
payload: {
type: 'auctionFinalize',
requestId: previousRequestId,
auctionId: 7,
expectedCloseAt: new Date('2099-01-01T00:00:00.000Z').toISOString(),
expectedCloseTick: 72_000_000,
},
status: 'PENDING',
},
]),
},
} as unknown as Pick<GamePrismaClient, 'auction' | 'inputEvent'>;
const now = new Date('2026-07-30T12:00:00.000Z');
await expect(
reconcilePendingAuctionTimers({
db,
redis,
timerKey: 'timer',
auctionIds: [7],
gameTime: {
now,
wallNow: now,
tick: 72_000_000,
mode: 'manual',
running: false,
startsAt: null,
dateToTick: () => 108_000_000,
},
})
).resolves.toEqual({ pendingIds: [], rescheduled: 1 });
expect(redis.zAdd).toHaveBeenCalledWith('timer', [{ score: 108_000_000, value: '7' }]);
expect(redis.zRem).not.toHaveBeenCalled();
});
it('requeues an OPEN auction at its current DB deadline when an old due score loses the race', async () => {
const redis = buildRedis();
const closeAt = new Date('2026-07-30T12:15:00.000Z');
@@ -128,11 +232,11 @@ describe('auction worker clock-shift race', () => {
expect(redis.zAdd).toHaveBeenCalledWith('timer', [{ score: 72_000_000, value: '7' }]);
});
it('commits the FINALIZING transition and durable command in one transaction before recording history', async () => {
it('leaves OPEN untouched and creates one durable command before recording history', async () => {
const redis = buildRedis();
const closeAt = new Date('2026-07-30T11:00:00.000Z');
const requestId = `auction:finalize:7:${closeAt.getTime()}`;
const { db, transaction } = buildDb({ updated: 1, auction: { status: 'FINALIZING', closeAt } });
const { db, transaction } = buildDb({ updated: 0, auction: { status: 'OPEN', closeAt } });
const nowMs = new Date('2026-07-30T12:00:00.000Z').getTime();
await expect(
@@ -144,7 +248,7 @@ describe('auction worker clock-shift race', () => {
id: '7',
nowMs,
})
).resolves.toBe('FINALIZING');
).resolves.toBe('PENDING');
expect(redis.zAdd).toHaveBeenCalledWith('history', [{ score: nowMs, value: '7' }]);
expect(transaction.inputEvent.create).toHaveBeenCalledWith({
@@ -152,15 +256,119 @@ describe('auction worker clock-shift race', () => {
requestId,
target: 'ENGINE',
eventType: 'auctionFinalize',
payload: { type: 'auctionFinalize', requestId, auctionId: 7 },
payload: {
type: 'auctionFinalize',
requestId,
auctionId: 7,
expectedCloseAt: closeAt.toISOString(),
},
},
});
expect(transaction.$executeRaw).not.toHaveBeenCalled();
});
it('enqueues finalization at the exact close tick without changing auction status', async () => {
const redis = buildRedis();
const closeAt = new Date('2099-01-01T00:00:00.000Z');
const requestId = 'auction:finalize:7:tick:72000000';
const { db, transaction } = buildDb({
updated: 0,
auction: { status: 'OPEN', closeAt, closeTick: 72_000_000n },
});
await expect(
processDueAuctionId({
db,
redis,
timerKey: 'timer',
historyKey: 'history',
id: '7',
nowMs: new Date('2042-01-01T00:00:00.000Z').getTime(),
nowTick: 72_000_000,
})
).resolves.toBe('PENDING');
expect(transaction.inputEvent.create).toHaveBeenCalledWith({
data: {
requestId,
target: 'ENGINE',
eventType: 'auctionFinalize',
payload: {
type: 'auctionFinalize',
requestId,
auctionId: 7,
expectedCloseAt: closeAt.toISOString(),
expectedCloseTick: 72_000_000,
},
},
});
expect(transaction.$executeRaw).not.toHaveBeenCalled();
});
it('keeps an already-enqueued bid ahead of finalization and does not block it out of band', async () => {
const redis = buildRedis();
const closeAt = new Date('2026-07-30T11:00:00.000Z');
const queuedTypes = ['auctionBid'];
const { db, transaction } = buildDb({ updated: 0, auction: { status: 'OPEN', closeAt } });
await expect(
processDueAuctionId({
db,
redis,
timerKey: 'timer',
historyKey: 'history',
id: '7',
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
})
).resolves.toBe('PENDING');
const created = transaction.inputEvent.create.mock.calls[0]?.[0] as { data: { eventType: string } } | undefined;
if (created) queuedTypes.push(created.data.eventType);
expect(queuedTypes).toEqual(['auctionBid', 'auctionFinalize']);
expect(transaction.$executeRaw).not.toHaveBeenCalled();
});
it('reuses the same pending OPEN-generation event after a worker retry or restart', async () => {
const redis = buildRedis();
const closeAt = new Date('2026-07-30T11:00:00.000Z');
const requestId = `auction:finalize:7:${closeAt.getTime()}`;
const { db, transaction } = buildDb({
updated: 0,
auction: { status: 'OPEN', closeAt },
existingEvents: [
{
requestId,
target: 'ENGINE',
eventType: 'auctionFinalize',
payload: {
type: 'auctionFinalize',
requestId,
auctionId: 7,
expectedCloseAt: closeAt.toISOString(),
},
status: 'PENDING',
result: null,
},
],
});
await expect(
processDueAuctionId({
db,
redis,
timerKey: 'timer',
historyKey: 'history',
id: '7',
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
})
).resolves.toBe('PENDING');
expect(transaction.inputEvent.create).not.toHaveBeenCalled();
expect(transaction.$executeRaw).not.toHaveBeenCalled();
});
it('records operational history time separately from logical settlement time', async () => {
const redis = buildRedis();
const closeAt = new Date('2026-07-30T11:00:00.000Z');
const { db } = buildDb({ updated: 1, auction: { status: 'FINALIZING', closeAt } });
const { db } = buildDb({ updated: 0, auction: { status: 'FINALIZING', closeAt } });
const logicalNowMs = new Date('0190-01-01T00:00:00.000Z').getTime();
const operationalNowMs = new Date('2026-07-30T12:00:00.000Z').getTime();
@@ -204,7 +412,7 @@ describe('auction worker clock-shift race', () => {
id: '7',
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
})
).resolves.toBe('FINALIZING');
).resolves.toBe('PENDING');
expect(transaction.inputEvent.create).not.toHaveBeenCalled();
expect(redis.zAdd).toHaveBeenCalledWith('history', [
@@ -241,14 +449,19 @@ describe('auction worker clock-shift race', () => {
id: '7',
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
})
).resolves.toBe('FINALIZING');
).resolves.toBe('PENDING');
expect(transaction.inputEvent.create).toHaveBeenCalledWith({
data: {
requestId: retryRequestId,
target: 'ENGINE',
eventType: 'auctionFinalize',
payload: { type: 'auctionFinalize', requestId: retryRequestId, auctionId: 7 },
payload: {
type: 'auctionFinalize',
requestId: retryRequestId,
auctionId: 7,
expectedCloseAt: closeAt.toISOString(),
},
},
});
});
@@ -260,8 +473,8 @@ describe('auction worker clock-shift race', () => {
const previousRequestId = `auction:finalize:7:${previousCloseAt.getTime()}`;
const requestId = `auction:finalize:7:${closeAt.getTime()}`;
const { db, transaction } = buildDb({
updated: 1,
auction: { status: 'FINALIZING', closeAt },
updated: 0,
auction: { status: 'OPEN', closeAt },
existingEvents: [
{
requestId: previousRequestId,
@@ -283,22 +496,27 @@ describe('auction worker clock-shift race', () => {
id: '7',
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
})
).resolves.toBe('FINALIZING');
).resolves.toBe('PENDING');
expect(transaction.inputEvent.create).toHaveBeenCalledWith({
data: {
requestId,
target: 'ENGINE',
eventType: 'auctionFinalize',
payload: { type: 'auctionFinalize', requestId, auctionId: 7 },
payload: {
type: 'auctionFinalize',
requestId,
auctionId: 7,
expectedCloseAt: closeAt.toISOString(),
},
},
});
});
it('rolls the auction transition back when durable event creation fails', async () => {
it('does not touch auction status when durable event creation fails', async () => {
const redis = buildRedis();
const closeAt = new Date('2026-07-30T11:00:00.000Z');
const { db, transaction } = buildDb({ updated: 1, auction: { status: 'FINALIZING', closeAt } });
const { db, transaction } = buildDb({ updated: 0, auction: { status: 'OPEN', closeAt } });
transaction.inputEvent.create.mockRejectedValueOnce(new Error('event insert failed'));
await expect(
@@ -313,5 +531,6 @@ describe('auction worker clock-shift race', () => {
).rejects.toThrow('event insert failed');
expect(redis.zAdd).not.toHaveBeenCalled();
expect(transaction.$executeRaw).not.toHaveBeenCalled();
});
});
@@ -37,7 +37,6 @@ const classifications = {
'diplomacy.respondLetter',
'diplomacy.rollbackLetter',
'diplomacy.sendLetter',
'join.getSelectionPool',
'join.listPossessCandidates',
'messages.readLatest',
'turns.repeatNation',
@@ -63,6 +62,7 @@ const classifications = {
'general.vacation',
'inherit.openUniqueAuction',
'join.createGeneral',
'join.getSelectionPool',
'join.possessGeneral',
'join.reselectPoolGeneral',
'join.selectPoolGeneral',
@@ -2,6 +2,11 @@ import { describe, expect, it } from 'vitest';
import { IdempotentTurnDaemonTransport } from '../src/daemon/idempotentTransport.js';
import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js';
import {
commandIdentityJson,
ConflictingTurnDaemonCommandError,
DatabaseTurnDaemonTransport,
} from '../src/daemon/databaseTransport.js';
describe('IdempotentTurnDaemonTransport', () => {
it('derives stable ordered engine request IDs from the API input event', async () => {
@@ -19,4 +24,108 @@ describe('IdempotentTurnDaemonTransport', () => {
'api-event:engine:0:vacation',
]);
});
it('keeps the first durable acceptance tick authoritative across an idempotent retry', () => {
const auctionBid = {
type: 'auctionBid',
requestId: 'auction-bid',
auctionId: 31,
generalId: 7,
amount: 500,
acceptedGameTick: 100,
};
expect(commandIdentityJson({ ...auctionBid, acceptedGameTick: 101 })).toBe(commandIdentityJson(auctionBid));
expect(commandIdentityJson({ ...auctionBid, amount: 501 })).not.toBe(commandIdentityJson(auctionBid));
const voteReward = {
type: 'voteReward',
requestId: 'vote-reward',
voteId: 1,
generalId: 7,
selection: [0],
acceptedGameTick: 100,
};
expect(commandIdentityJson({ ...voteReward, acceptedGameTick: 101 })).toBe(commandIdentityJson(voteReward));
expect(commandIdentityJson({ ...voteReward, selection: [1] })).not.toBe(commandIdentityJson(voteReward));
const selectionCommands = [
{
type: 'selectPoolReserve',
requestId: 'select-pool-reserve',
userId: 'user-7',
seedOwnerIdentity: 7,
acceptedGameAt: '0200-01-01T00:00:00.000Z',
acceptedGameTick: 100,
},
{
type: 'selectPoolCreate',
requestId: 'select-pool-create',
userId: 'user-7',
ownerDisplayName: '사용자',
uniqueName: '풀장수',
personality: 'che_안전',
acceptedGameAt: '0200-01-01T00:00:00.000Z',
acceptedGameTick: 100,
},
{
type: 'selectPoolReselect',
requestId: 'select-pool-reselect',
userId: 'user-7',
ownerDisplayName: '사용자',
uniqueName: '풀장수',
acceptedGameAt: '0200-01-01T00:00:00.000Z',
acceptedGameTick: 100,
},
];
for (const command of selectionCommands) {
expect(
commandIdentityJson({
...command,
acceptedGameAt: '0200-01-01T00:01:00.000Z',
acceptedGameTick: 101,
})
).toBe(commandIdentityJson(command));
expect(commandIdentityJson({ ...command, userId: 'other-user' })).not.toBe(commandIdentityJson(command));
}
});
it('reuses a successful vote event when only the retry acceptance tick has changed', async () => {
const persistedPayload = {
type: 'voteReward' as const,
requestId: 'vote-reward',
voteId: 1,
generalId: 7,
selection: [0],
acceptedGameTick: 100,
};
const create = async () => {
throw Object.assign(new Error('duplicate'), { code: 'P2002' });
};
const transport = new DatabaseTurnDaemonTransport(
{
inputEvent: {
create,
findUniqueOrThrow: async () => ({ eventType: 'voteReward', payload: persistedPayload }),
},
} as any,
100
);
await expect(
transport.sendCommand({
...persistedPayload,
acceptedGameTick: 101,
})
).resolves.toBe('vote-reward');
for (const changedIdentity of [{ selection: [1] }, { voteId: 2 }, { generalId: 8 }]) {
await expect(
transport.sendCommand({
...persistedPayload,
...changedIdentity,
acceptedGameTick: 101,
})
).rejects.toBeInstanceOf(ConflictingTurnDaemonCommandError);
}
});
});
+29 -2
View File
@@ -110,6 +110,7 @@ const buildContext = (options: {
rankRows?: Array<{ type: string; value: number }>;
inheritanceLogs?: Array<{ id: number; year: number; month: number; text: string; createdAt: Date }>;
configConst?: Record<string, unknown>;
configMap?: Record<string, unknown>;
}) => {
const auth = options.auth === undefined ? buildAuth() : options.auth;
const general = options.general === undefined ? buildGeneral() : options.general;
@@ -128,12 +129,14 @@ const buildContext = (options: {
const inheritanceLogFindMany = vi.fn(async () => options.inheritanceLogs ?? []);
const webPushOutboxCreateMany = vi.fn(async () => ({ count: 1 }));
const activeWorldState =
options.configConst === undefined
options.configConst === undefined && options.configMap === undefined
? worldState
: {
...worldState,
config: {
const: options.configConst,
...worldState.config,
...(options.configConst === undefined ? {} : { const: options.configConst }),
...(options.configMap === undefined ? {} : { map: options.configMap }),
},
};
const messageRows: CapturedMessage[] = [];
@@ -269,6 +272,30 @@ describe('inherit router actor and permission boundaries', () => {
});
});
it('reports and enforces the Ref S100 stat-reset ban without dispatching or charging', async () => {
const fixture = buildContext({
configMap: { targetGeneralPool: 'SPoolUnderU100' },
inheritancePoint: 0,
});
const caller = appRouter.createCaller(fixture.context);
await expect(caller.inherit.getStatus()).resolves.toMatchObject({ canResetStat: false });
await expect(
caller.inherit.resetStat({
leadership: 70,
strength: 45,
intel: 85,
inheritBonusStat: [2, 1, 1],
})
).rejects.toMatchObject({
code: 'BAD_REQUEST',
message: '100기 올스타 장수는 능력치 초기화를 사용할 수 없습니다.',
});
expect(fixture.requestCommand).not.toHaveBeenCalled();
expect(fixture.pointUpsert).not.toHaveBeenCalled();
expect(fixture.logCreate).not.toHaveBeenCalled();
});
it('projects every Ref inheritance source with its own coefficient and stored/calculated boundary', async () => {
const fixture = buildContext({
general: buildGeneral({
+36
View File
@@ -693,6 +693,42 @@ describe('appRouter', () => {
).rejects.toMatchObject({ code: 'FORBIDDEN' });
});
it('queues selection-pool reservation with the authenticated actor and server logical time', async () => {
const transport = new InMemoryTurnDaemonTransport();
const requestId = 'select-pool-reserve-http';
const commandRequestId = `select-pool:user-1:${requestId}:reserve`;
const acceptedGameAt = '2026-07-30T12:00:00.000Z';
const reservation = {
poolName: 'SPoolUnderU30',
hasGeneral: false,
validUntil: '2026-07-30T12:20:00.000Z',
candidates: [],
};
transport.setCommandResult(commandRequestId, {
type: 'selectPoolReserve',
ok: true,
reservation,
});
const state = {
...buildWorldState(),
clockBaseTime: new Date(acceptedGameAt),
clockTick: 0n,
clockMode: 'manual',
clockWallAnchor: new Date(acceptedGameAt),
} as WorldStateRow;
const context = { ...buildContext({ state, transport }), requestId };
await expect(appRouter.createCaller(context).join.getSelectionPool()).resolves.toEqual(reservation);
expect(transport.commands.at(-1)?.command).toEqual({
type: 'selectPoolReserve',
requestId: commandRequestId,
userId: 'user-1',
seedOwnerIdentity: 'user-1',
acceptedGameAt,
acceptedGameTick: 0,
});
});
it('queues turn daemon run commands', async () => {
const transport = new InMemoryTurnDaemonTransport();
const caller = appRouter.createCaller(buildContext({ transport }));
@@ -221,6 +221,25 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
expect(concurrentReservation).toEqual(firstReservation);
expect(firstReservation.candidates).toHaveLength(14);
expect(await db.selectPoolEntry.count({ where: { ownerUserId: userId } })).toBe(14);
const reservedNames = new Set(firstReservation.candidates.map((candidate) => candidate.uniqueName));
expect(
runtime!.world
.listGeneralPoolCandidates(new Date(firstReservation.validUntil))
?.some((candidate) => reservedNames.has(candidate.uniqueName))
).toBe(false);
await expect(
db.inputEvent.findUniqueOrThrow({
where: { requestId: `select-pool:${userId}:select-pool-reserve-a:reserve` },
})
).resolves.toMatchObject({
eventType: 'selectPoolReserve',
status: 'SUCCEEDED',
actorUserId: userId,
payload: {
acceptedGameAt: expect.any(String),
acceptedGameTick: expect.any(Number),
},
});
const attempts = await Promise.allSettled([
appRouter.createCaller(buildContext('select-pool-create-a')).join.selectPoolGeneral({
@@ -290,6 +309,10 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
expect(initialRankRows.every(({ nationId, value }) => nationId === 0 && value === 0)).toBe(true);
expect(await db.selectPoolEntry.count({ where: { generalId: initial.id } })).toBe(1);
expect(await db.selectPoolEntry.count({ where: { ownerUserId: userId } })).toBe(0);
expect(runtime!.world.listGeneralPoolEntries()?.filter((entry) => entry.ownerUserId === userId)).toEqual([]);
expect(
runtime!.world.listGeneralPoolEntries()?.find((entry) => entry.generalId === initial.id)?.candidate.name
).toBe(initial.name);
expect(
await db.logEntry.count({
where: { meta: { path: ['ownerUserId'], equals: userId } },
@@ -336,6 +359,16 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
.createCaller(buildContext('select-pool-reselect'))
.join.reselectPoolGeneral({ uniqueName: target.uniqueName })
).resolves.toEqual({ ok: true, generalId: initial.id });
await expect(
db.inputEvent.findUniqueOrThrow({ where: { requestId: 'select-pool-reselect:join.reselectPoolGeneral' } })
).resolves.toMatchObject({
eventType: 'selectPoolReselect',
actorUserId: userId,
payload: {
acceptedGameAt: expect.any(String),
acceptedGameTick: expect.any(Number),
},
});
const updated = await db.general.findUniqueOrThrow({ where: { id: initial.id } });
expect(updated).toMatchObject({
@@ -368,6 +401,10 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
ownerUserId: null,
reservedUntil: null,
});
expect(runtime!.world.listGeneralPoolEntries()?.filter((entry) => entry.ownerUserId === userId)).toEqual([]);
expect(
runtime!.world.listGeneralPoolEntries()?.find((entry) => entry.generalId === initial.id)?.uniqueName
).toBe(target.uniqueName);
expect(
await db.logEntry.count({
where: { meta: { path: ['ownerUserId'], equals: userId } },
@@ -518,6 +555,10 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
status: 'SUCCEEDED',
attempts: 1,
actorUserId: otherUserId,
payload: {
acceptedGameAt: expect.any(String),
acceptedGameTick: expect.any(Number),
},
});
}, 30_000);
+10 -10
View File
@@ -19,11 +19,11 @@ const loadWeightedRows = async (): Promise<Array<[{ id: number }, number]>> => {
const drawVector = async (hiddenSeed: string): Promise<{ selected: number[]; draws: number[] }> => {
const weighted = await loadWeightedRows();
const now = new Date('2026-07-30T03:34:56.000Z');
const nowTick = 72_000_000;
const draws: number[] = [];
const selected = await claimWeightedSelectionCandidates({
weighted,
rng: new RandUtil(new LiteHashDRBG(buildSelectPoolSeed(hiddenSeed, 42, now))),
rng: new RandUtil(new LiteHashDRBG(buildSelectPoolSeed(hiddenSeed, 42, nowTick))),
count: 14,
claim: async () => true,
onDraw: (candidate) => draws.push(candidate.id),
@@ -33,21 +33,21 @@ const drawVector = async (hiddenSeed: string): Promise<{ selected: number[]; dra
describe('select pool Ref RNG parity', () => {
it('uses the legacy seed serialization and fixed UnderS30 draw vector', async () => {
const now = new Date('2026-07-30T03:34:56.000Z');
expect(buildSelectPoolSeed('vector-hidden', 42, now)).toBe(
'str(13,vector-hidden)|str(10,selectPool)|int(42)|str(19,2026-07-30 12:34:56)'
const nowTick = 72_000_000;
expect(buildSelectPoolSeed('vector-hidden', 42, nowTick)).toBe(
'str(13,vector-hidden)|str(10,selectPool)|int(42)|int(72000000)'
);
await expect(drawVector('vector-hidden')).resolves.toEqual({
selected: [72, 1283, 110, 1659, 608, 1408, 1543, 1573, 1096, 1081, 278, 1256, 872, 1369],
draws: [72, 1283, 110, 1659, 608, 1408, 1543, 1573, 1096, 1081, 278, 1256, 872, 1369],
selected: [1547, 199, 1266, 756, 1741, 1435, 303, 753, 214, 576, 387, 388, 394, 252],
draws: [1547, 199, 1266, 756, 1741, 1435, 303, 753, 214, 576, 387, 388, 394, 252],
});
});
it('consumes duplicate draws without removing the candidate from the weighted pool', async () => {
await expect(drawVector('vector-hidden-28')).resolves.toEqual({
selected: [314, 865, 1485, 1382, 110, 550, 27, 368, 399, 1298, 152, 39, 189, 760],
draws: [314, 865, 1485, 1382, 110, 550, 27, 368, 399, 1298, 27, 152, 39, 189, 760],
await expect(drawVector('vector-hidden-2')).resolves.toEqual({
selected: [1632, 543, 640, 1351, 691, 966, 1110, 1358, 224, 936, 262, 109, 852, 456],
draws: [1632, 543, 640, 1351, 691, 966, 1110, 1358, 224, 936, 262, 109, 966, 852, 456],
});
});
});
+41 -1
View File
@@ -129,6 +129,7 @@ const buildContext = (options: {
userId: string;
roles?: string[];
develCost?: number;
currentDevelCost?: number;
rankRows?: Array<{ generalId: number; type: string; value: number }>;
}): GameApiContext => {
const db = {
@@ -142,7 +143,10 @@ const buildContext = (options: {
findMany: async () => options.rankRows ?? [],
},
worldState: {
findFirst: async () => ({ config: { const: { develCost: options.develCost ?? 200 } } }),
findFirst: async () => ({
config: { const: { develCost: options.develCost ?? 200 } },
...(options.currentDevelCost === undefined ? {} : { meta: { develcost: options.currentDevelCost } }),
}),
},
} as unknown as DatabaseClient;
return {
@@ -267,6 +271,42 @@ describe('tournament router permissions and mutations', () => {
expect(snapshot.participants[0]!.groupId).toBeLessThan(8);
});
it('charges the current game_env develcost instead of the scenario snapshot', async () => {
const redis = new MemoryRedis();
const transport = new TournamentTransport();
const general = buildGeneral(1, 'user-1');
transport.gold.set(general.id, general.gold);
await setTournamentFixture(redis, {
stage: 1,
phase: 0,
type: 0,
auto: true,
openYear: 193,
openMonth: 1,
termSeconds: 60,
nextAt: '2026-07-26T01:00:00.000Z',
});
await redis.set('sammo:che:default:tournament:participants', '[]');
const caller = appRouter.createCaller(
buildContext({
redis,
transport,
generals: [general],
userId: 'user-1',
develCost: 200,
currentDevelCost: 64,
})
);
await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 });
expect(transport.gold.get(general.id)).toBe(1_936);
expect(transport.commands).toContainEqual({
type: 'adjustGeneralResources',
reason: 'tournamentJoin',
adjustments: [{ generalId: general.id, goldDelta: -64, minGoldAfter: 0 }],
});
});
it('serializes concurrent bets and enforces the legacy per-user 1000 limit', async () => {
const redis = new MemoryRedis();
const transport = new TournamentTransport();
@@ -320,6 +320,24 @@ describe('tournament worker (in-memory)', () => {
).toEqual({ payouts: [], total: 300, refundAll: false });
});
it('coalesces duplicate legacy rows before one winner payout and rounding', () => {
expect(
buildBettingPayouts(10, [
{ generalId: 1, targetId: 10, amount: 101 },
{ generalId: 1, targetId: 10, amount: 99 },
{ generalId: 2, targetId: 10, amount: 100 },
{ generalId: 3, targetId: 11, amount: 100 },
])
).toEqual({
payouts: [
{ generalId: 1, amount: 267 },
{ generalId: 2, amount: 133 },
],
total: 400,
refundAll: false,
});
});
it('locks 64 applicants into eight groups of eight', async () => {
const redis = new MemoryRedis();
const store = new TournamentStore(redis, buildTournamentKeys('test-groups'));
+60 -19
View File
@@ -9,6 +9,7 @@ import { InMemoryFlushStore } from '../src/auth/flushStore.js';
import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js';
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
import { appRouter } from '../src/router.js';
import { hasPollEnded } from '../src/router/vote/index.js';
const poll = {
id: 1,
@@ -95,10 +96,11 @@ const buildContext = (options: {
configConst?: Record<string, unknown>;
metaDevelCost?: number;
auctionTargets?: string[];
clockTick?: number;
}) => {
const auth = options.auth === undefined ? buildAuth() : options.auth;
const general = options.general === undefined ? buildGeneral() : options.general;
const requestCommand = vi.fn(async () => ({
const requestCommand = vi.fn(async (_command?: unknown) => ({
type: 'voteReward' as const,
ok: true as const,
voteId: 1,
@@ -139,6 +141,14 @@ const buildContext = (options: {
currentYear: 200,
currentMonth: 1,
tickSeconds: 3600,
...(options.clockTick === undefined
? {}
: {
clockBaseTime: new Date('0200-01-01T00:00:00.000Z'),
clockTick: BigInt(options.clockTick),
clockMode: 'manual',
clockWallAnchor: new Date('2026-07-26T00:00:00.000Z'),
}),
config: { const: { develCost: 18, allItems: {}, ...(options.configConst ?? {}) } },
meta: {
...(options.metaDevelCost === undefined ? {} : { develcost: options.metaDevelCost }),
@@ -201,6 +211,29 @@ const buildContext = (options: {
};
describe('vote router actor and permission boundaries', () => {
it('keeps a poll open at its exact Ref end tick and closes it after that tick', () => {
const now = new Date('2026-07-26T00:00:00Z');
const time = {
now,
wallNow: now,
tick: 100,
mode: 'manual' as const,
running: false,
startsAt: null,
dateToTick: () => 100,
};
expect(hasPollEnded({ closed_at: null, end_at: now, end_tick: 100n }, time)).toBe(false);
expect(hasPollEnded({ closed_at: null, end_at: now, end_tick: 99n }, time)).toBe(true);
expect(hasPollEnded({ closed_at: null, end_at: now, end_tick: null }, { ...time, tick: null })).toBe(false);
expect(
hasPollEnded(
{ closed_at: null, end_at: now, end_tick: null },
{ ...time, now: new Date(now.getTime() + 1), tick: null }
)
).toBe(true);
});
it('rejects unauthenticated survey access', async () => {
const fixture = buildContext({ auth: null });
@@ -211,18 +244,20 @@ describe('vote router actor and permission boundaries', () => {
it('uses only the general owned by the authenticated user for voting and reward dispatch', async () => {
const owned = buildGeneral({ id: 7, userId: 'user-1', name: '유비' });
const fixture = buildContext({ general: owned });
const fixture = buildContext({ general: owned, clockTick: 100 });
await expect(
appRouter.createCaller(fixture.context).vote.submitVote({ voteId: 1, selection: [0] })
).resolves.toEqual({ ok: true, wonLottery: false });
expect(fixture.requestCommand).toHaveBeenCalledWith(
expect.objectContaining({
type: 'voteReward',
voteId: 1,
generalId: 7,
goldReward: 90,
})
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'voteReward',
voteId: 1,
generalId: 7,
selection: [0],
acceptedGameTick: 100,
});
expect(fixture.queryRaw.mock.calls.some(([query]) => sqlText(query).includes('INSERT INTO vote ('))).toBe(
false
);
expect(fixture.changeJournal.snapshot()).toEqual([{ domain: 'front.general', entityId: 7 }]);
expect(fixture.redisIncr).not.toHaveBeenCalled();
@@ -230,7 +265,10 @@ describe('vote router actor and permission boundaries', () => {
});
it('publishes a global front-status projection after creating a survey', async () => {
const fixture = buildContext({ auth: buildAuth(['admin.survey.open']) });
const auth = buildAuth(['admin.survey.open']);
auth.user.username = 'admin-account';
auth.user.displayName = '관리자 표시명';
const fixture = buildContext({ auth, general: buildGeneral({ name: '관리자 장수' }) });
await expect(
appRouter.createCaller(fixture.context).vote.createPoll({
@@ -244,19 +282,22 @@ describe('vote router actor and permission boundaries', () => {
expect(fixture.requestCommand).not.toHaveBeenCalled();
expect(fixture.redisIncr).not.toHaveBeenCalled();
expect(fixture.redisPublish).not.toHaveBeenCalled();
const insert = fixture.queryRaw.mock.calls
.map(([query]) => query)
.find((query) => sqlText(query).includes('INSERT INTO vote_poll'));
expect(insert?.values).toContain('admin-account');
expect(insert?.values).not.toContain('관리자 장수');
});
it('uses the current world develcost for the legacy five-times survey reward', async () => {
it('reports the current world develcost as the legacy five-times survey reward', async () => {
const fixture = buildContext({ metaDevelCost: 30, configConst: { develCost: 0 } });
await expect(appRouter.createCaller(fixture.context).vote.getVoteList()).resolves.toMatchObject({
voteReward: 150,
});
await appRouter.createCaller(fixture.context).vote.submitVote({ voteId: 1, selection: [0] });
expect(fixture.requestCommand).toHaveBeenCalledWith(expect.objectContaining({ goldReward: 150 }));
});
it('includes active unique auctions in the API-side reward expectation', async () => {
it('leaves live reward and unique occupancy calculation to ENGINE', async () => {
const fixture = buildContext({
configConst: {
allItems: { weapon: { che_무기_12_칠성검: 1 } },
@@ -270,11 +311,11 @@ describe('vote router actor and permission boundaries', () => {
await appRouter.createCaller(fixture.context).vote.submitVote({ voteId: 1, selection: [0] });
expect(fixture.requestCommand).toHaveBeenCalledWith(
expect.objectContaining({
unique: { expected: false, itemKey: null },
})
);
expect(fixture.db.general.findMany).not.toHaveBeenCalled();
expect(fixture.db.general.count).not.toHaveBeenCalled();
expect(fixture.db.auction.findMany).not.toHaveBeenCalled();
expect(fixture.requestCommand.mock.calls[0]?.[0]).not.toHaveProperty('goldReward');
expect(fixture.requestCommand.mock.calls[0]?.[0]).not.toHaveProperty('unique');
});
it('rejects voting and comments when the authenticated user owns no general', async () => {
@@ -97,6 +97,9 @@ const buildContext = (
hasGeneral?: boolean;
worldMeta?: unknown;
liveLogs?: { history: string[]; action: string[] };
liveNations?: Array<Record<string, unknown>>;
liveCities?: Array<Record<string, unknown>>;
liveGenerals?: Array<Record<string, unknown>>;
} = {}
): GameApiContext => {
const db = {
@@ -104,13 +107,13 @@ const buildContext = (
general: {
findFirst: async ({ where }: { where: { userId: string } }) =>
options.hasGeneral === false ? null : { id: where.userId === 'owner-a' ? 1 : 2, userId: where.userId },
findMany: async () => [],
findMany: async () => options.liveGenerals ?? [],
},
city: {
findMany: async () => [],
findMany: async () => options.liveCities ?? [],
},
nation: {
findMany: async () => [],
findMany: async () => options.liveNations ?? [],
},
logEntry: {
findMany: async ({ where }: { where: { category: unknown } }) => {
@@ -326,4 +329,82 @@ describe('historical yearbook access from dynasty', () => {
},
});
});
it('uses stored Ref nation projections, canonical neutral values, and descending power in the live month', async () => {
const zeroCityStats = {
population: 0,
agriculture: 0,
commerce: 0,
security: 0,
defence: 0,
wall: 0,
populationMax: 1,
agricultureMax: 1,
commerceMax: 1,
securityMax: 1,
defenceMax: 1,
wallMax: 1,
};
const caller = appRouter.createCaller(
buildContext(authFor('owner-a'), {
liveNations: [
{
id: 0,
name: '오염된 재야',
color: '#ffffff',
level: 9,
gold: 0,
rice: 0,
tech: 0,
meta: { power: 90, gennum: 90 },
},
{
id: 1,
name: '촉',
color: '#ff0000',
level: 7,
gold: 0,
rice: 0,
tech: 0,
meta: { power: 777, gennum: 9 },
},
{
id: 2,
name: '위',
color: '#0000ff',
level: 7,
gold: 0,
rice: 0,
tech: 0,
meta: { power: 0, gennum: 2 },
},
],
liveCities: [
{ id: 0, name: '낙양', nationId: 0, ...zeroCityStats },
{ id: 1, name: '성도', nationId: 1, ...zeroCityStats },
{ id: 2, name: '허창', nationId: 2, ...zeroCityStats },
],
})
);
const result = await caller.yearbook.getHistory({ year: 220, month: 1 });
expect(result).toMatchObject({
notModified: false,
data: {
nations: [
expect.objectContaining({ id: 1, power: 777, generalCount: 9 }),
expect.objectContaining({
id: 0,
name: '재야',
color: '#000000',
level: 0,
power: 1,
generalCount: 1,
}),
expect.objectContaining({ id: 2, power: 0, generalCount: 2 }),
],
},
});
});
});