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:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user