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
+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},