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,10 +1,11 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { createGamePostgresConnector, GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
import { isItemKey, ItemLoader } from '@sammo-ts/logic';
|
||||
import { isItemKey, ItemLoader, type MessageDraft } from '@sammo-ts/logic';
|
||||
|
||||
import type { TurnDaemonCommand, TurnDaemonCommandResult } from '../lifecycle/types.js';
|
||||
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js';
|
||||
import type { TurnGeneral } from '../turn/types.js';
|
||||
|
||||
export interface AuctionBidder {
|
||||
bid(
|
||||
@@ -20,6 +21,7 @@ type AuctionStatus = 'OPEN' | 'FINALIZING' | 'FINISHED' | 'CANCELED';
|
||||
|
||||
const COEFF_EXTENSION_MINUTES_PER_BID = 1 / 6;
|
||||
const MIN_EXTENSION_MINUTES_PER_BID = 1;
|
||||
export const MIN_AUCTION_REMAINING_RESOURCE = 1_000;
|
||||
|
||||
interface AuctionRow {
|
||||
id: number;
|
||||
@@ -29,6 +31,7 @@ interface AuctionRow {
|
||||
detail: unknown;
|
||||
status: AuctionStatus;
|
||||
closeAt: Date;
|
||||
closeTick: bigint | null;
|
||||
latestEventId: string;
|
||||
}
|
||||
|
||||
@@ -40,12 +43,78 @@ interface AuctionBidRow {
|
||||
}
|
||||
|
||||
interface AuctionDetail {
|
||||
title?: string;
|
||||
isReverse?: boolean;
|
||||
startBidAmount?: number;
|
||||
finishBidAmount?: number | null;
|
||||
availableLatestBidCloseDate?: string | null;
|
||||
}
|
||||
|
||||
export const hasEnoughResourceForAuctionBid = (current: number, additionalBid: number): boolean =>
|
||||
Number.isFinite(current) &&
|
||||
Number.isFinite(additionalBid) &&
|
||||
additionalBid > 0 &&
|
||||
current >= additionalBid + MIN_AUCTION_REMAINING_RESOURCE;
|
||||
|
||||
export const hasAuctionClosePassed = (
|
||||
auction: { closeAt: Date; closeTick: bigint | null },
|
||||
now: Date,
|
||||
nowTick: number | null
|
||||
): boolean =>
|
||||
auction.closeTick !== null && nowTick !== null
|
||||
? auction.closeTick < BigInt(nowTick)
|
||||
: auction.closeAt.getTime() < now.getTime();
|
||||
|
||||
export const resolveAuctionBidTiming = (
|
||||
world: Pick<InMemoryTurnWorld, 'dateToGameTick' | 'gameTickToDate'>,
|
||||
processingNow: Date,
|
||||
acceptedGameTick?: number
|
||||
): { bidAt: Date; bidTick: number } =>
|
||||
acceptedGameTick === undefined
|
||||
? { bidAt: processingNow, bidTick: world.dateToGameTick(processingNow) }
|
||||
: { bidAt: world.gameTickToDate(acceptedGameTick), bidTick: acceptedGameTick };
|
||||
|
||||
export const hasAuctionBidClosePassed = (
|
||||
auction: { closeAt: Date; closeTick: bigint | null },
|
||||
world: Pick<InMemoryTurnWorld, 'dateToGameTick' | 'gameTickToDate'>,
|
||||
processingNow: Date,
|
||||
acceptedGameTick?: number
|
||||
): boolean => {
|
||||
const { bidAt, bidTick } = resolveAuctionBidTiming(world, processingNow, acceptedGameTick);
|
||||
return hasAuctionClosePassed(auction, bidAt, bidTick);
|
||||
};
|
||||
|
||||
export const buildAuctionOutbidRefundMessage = (options: {
|
||||
auctionId: number;
|
||||
title?: string;
|
||||
bidder: TurnGeneral;
|
||||
nation?: { name: string; color: string } | null;
|
||||
time: Date;
|
||||
}): MessageDraft => ({
|
||||
msgType: 'private',
|
||||
src: {
|
||||
generalId: 0,
|
||||
generalName: '',
|
||||
nationId: 0,
|
||||
nationName: 'System',
|
||||
color: '#000000',
|
||||
icon: '',
|
||||
},
|
||||
dest: {
|
||||
generalId: options.bidder.id,
|
||||
generalName: options.bidder.name,
|
||||
nationId: options.bidder.nationId,
|
||||
nationName: options.nation?.name ?? '재야',
|
||||
color: options.nation?.color ?? '#000000',
|
||||
icon: options.bidder.picture ?? '',
|
||||
},
|
||||
text: `${options.auctionId}번 ${options.title ?? '경매'}에 상회입찰자가 나타났습니다.`,
|
||||
time: new Date(options.time.getTime()),
|
||||
validUntil: new Date('9999-12-31T00:00:00.000Z'),
|
||||
option: {},
|
||||
sendDestOnly: true,
|
||||
});
|
||||
|
||||
const parseDetail = (detail: unknown): AuctionDetail => {
|
||||
if (!detail || typeof detail !== 'object') {
|
||||
return {};
|
||||
@@ -106,6 +175,7 @@ const loadAuction = async (prisma: QueryClient, auctionId: number): Promise<Auct
|
||||
detail,
|
||||
status,
|
||||
close_at as "closeAt",
|
||||
close_tick as "closeTick",
|
||||
latest_event_id as "latestEventId"
|
||||
FROM auction
|
||||
WHERE id = ${auctionId}
|
||||
@@ -198,8 +268,9 @@ export const createAuctionBidder = async (options: {
|
||||
reason: '경매가 종료되었습니다.',
|
||||
};
|
||||
}
|
||||
const now = world.getGameNow(new Date());
|
||||
if (auction.closeAt.getTime() <= now.getTime()) {
|
||||
const processingNow = world.getGameNow(new Date());
|
||||
const { bidAt, bidTick } = resolveAuctionBidTiming(world, processingNow, command.acceptedGameTick);
|
||||
if (hasAuctionClosePassed(auction, bidAt, bidTick)) {
|
||||
return {
|
||||
type: 'auctionBid',
|
||||
ok: false,
|
||||
@@ -371,7 +442,7 @@ export const createAuctionBidder = async (options: {
|
||||
};
|
||||
}
|
||||
|
||||
if (auction.type === 'BUY_RICE' && general.gold < morePoint) {
|
||||
if (auction.type === 'BUY_RICE' && !hasEnoughResourceForAuctionBid(general.gold, morePoint)) {
|
||||
return {
|
||||
type: 'auctionBid',
|
||||
ok: false,
|
||||
@@ -379,7 +450,7 @@ export const createAuctionBidder = async (options: {
|
||||
reason: '금이 부족합니다.',
|
||||
};
|
||||
}
|
||||
if (auction.type === 'SELL_RICE' && general.rice < morePoint) {
|
||||
if (auction.type === 'SELL_RICE' && !hasEnoughResourceForAuctionBid(general.rice, morePoint)) {
|
||||
return {
|
||||
type: 'auctionBid',
|
||||
ok: false,
|
||||
@@ -410,7 +481,7 @@ export const createAuctionBidder = async (options: {
|
||||
? new Date(detail.availableLatestBidCloseDate)
|
||||
: null;
|
||||
let nextCloseAt = extendCloseDate({
|
||||
now,
|
||||
now: bidAt,
|
||||
closeAt: auction.closeAt,
|
||||
turnMinutes,
|
||||
availableLatestBidCloseDate,
|
||||
@@ -420,11 +491,11 @@ export const createAuctionBidder = async (options: {
|
||||
detail.finishBidAmount != null &&
|
||||
command.amount === detail.finishBidAmount
|
||||
) {
|
||||
nextCloseAt = new Date(now.getTime() + turnMinutes * 60_000);
|
||||
nextCloseAt = new Date(bidAt.getTime() + turnMinutes * 60_000);
|
||||
}
|
||||
|
||||
const eventId = randomUUID();
|
||||
const eventAt = now;
|
||||
const eventAt = bidAt;
|
||||
const rankTrackedAmount = auction.type === 'UNIQUE_ITEM' ? readRankTrackedAmount(myPrevBid) + morePoint : 0;
|
||||
const previousRankTrackedAmount = readRankTrackedAmount(highestBid);
|
||||
|
||||
@@ -613,6 +684,21 @@ export const createAuctionBidder = async (options: {
|
||||
}
|
||||
}
|
||||
|
||||
if (highestBid && highestBid.generalId !== command.generalId && !myPrevBid) {
|
||||
const previousBidder = world.getGeneralById(highestBid.generalId);
|
||||
if (previousBidder) {
|
||||
world.queueMessage(
|
||||
buildAuctionOutbidRefundMessage({
|
||||
auctionId: auction.id,
|
||||
title: detail.title,
|
||||
bidder: previousBidder,
|
||||
nation: world.getNationById(previousBidder.nationId),
|
||||
time: eventAt,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'auctionBid',
|
||||
ok: true,
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { createGamePostgresConnector, GamePrisma } from '@sammo-ts/infra';
|
||||
import { ActionLogger, ItemLoader, LogFormat, UserLogger, isItemKey } from '@sammo-ts/logic';
|
||||
import { ActionLogger, ItemLoader, LogFormat, isItemKey, type MessageDraft } from '@sammo-ts/logic';
|
||||
import { resolveLegacyCompatibleUniqueConfig } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
|
||||
import { cloneItemInventory, ensureItemInventory, equipNewItem } from '@sammo-ts/logic/items/index.js';
|
||||
import { asRecord, JosaUtil } from '@sammo-ts/common';
|
||||
|
||||
import type { TurnDaemonCommandResult } from '../lifecycle/types.js';
|
||||
import type { TurnDaemonCommand, TurnDaemonCommandResult } from '../lifecycle/types.js';
|
||||
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js';
|
||||
import type { TurnGeneral } from '../turn/types.js';
|
||||
import type { LogEntryDraft } from '@sammo-ts/logic';
|
||||
|
||||
export interface AuctionFinalizer {
|
||||
finalize(auctionId: number, db?: GamePrisma.TransactionClient): Promise<TurnDaemonCommandResult>;
|
||||
finalize(
|
||||
command: Extract<TurnDaemonCommand, { type: 'auctionFinalize' }>,
|
||||
db?: GamePrisma.TransactionClient
|
||||
): Promise<TurnDaemonCommandResult>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -31,6 +35,7 @@ interface AuctionRow {
|
||||
detail: unknown;
|
||||
status: AuctionStatus;
|
||||
closeAt: Date;
|
||||
closeTick: bigint | null;
|
||||
}
|
||||
|
||||
interface AuctionBidRow {
|
||||
@@ -59,6 +64,93 @@ const parseDetail = (detail: unknown): AuctionDetailResource => {
|
||||
return detail as AuctionDetailResource;
|
||||
};
|
||||
|
||||
const toFiniteNumber = (value: unknown): number => {
|
||||
const parsed = typeof value === 'string' ? Number(value) : value;
|
||||
return typeof parsed === 'number' && Number.isFinite(parsed) ? parsed : 0;
|
||||
};
|
||||
|
||||
const readRankTrackedAmount = (bid: AuctionBidRow): number => {
|
||||
const meta = asRecord(bid.meta);
|
||||
return Math.max(0, toFiniteNumber(meta.inheritSpentTrackedAmount));
|
||||
};
|
||||
|
||||
export const resolveAuctionResourceAmount = (detailAmount: unknown, targetCode: string | null): number | null => {
|
||||
const detailValue = toFiniteNumber(detailAmount);
|
||||
if (detailValue > 0) return detailValue;
|
||||
const targetValue = toFiniteNumber(targetCode);
|
||||
return targetValue > 0 ? targetValue : null;
|
||||
};
|
||||
|
||||
export const isUniqueAuctionSupplyExhausted = (configuredAmount: number, occupiedAmount: number): boolean =>
|
||||
configuredAmount <= occupiedAmount;
|
||||
|
||||
export const resolveUniqueSupplyRetryCloseAt = (now: Date, turnMinutes: number): Date =>
|
||||
new Date(now.getTime() + Math.max(1, turnMinutes) * 60_000);
|
||||
|
||||
export const isAuctionFinalizeGenerationCurrent = (
|
||||
auction: Pick<AuctionRow, 'closeAt' | 'closeTick'>,
|
||||
command: Pick<Extract<TurnDaemonCommand, { type: 'auctionFinalize' }>, 'expectedCloseAt' | 'expectedCloseTick'>
|
||||
): boolean => {
|
||||
if (command.expectedCloseTick !== undefined) {
|
||||
return auction.closeTick !== null && auction.closeTick === BigInt(command.expectedCloseTick);
|
||||
}
|
||||
if (command.expectedCloseAt !== undefined) {
|
||||
return auction.closeAt.getTime() === new Date(command.expectedCloseAt).getTime();
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
export const hasAuctionFinalizeDeadlineArrived = (
|
||||
auction: Pick<AuctionRow, 'closeAt' | 'closeTick'>,
|
||||
now: Date,
|
||||
nowTick: number
|
||||
): boolean =>
|
||||
auction.closeTick === null ? auction.closeAt.getTime() <= now.getTime() : auction.closeTick <= BigInt(nowTick);
|
||||
|
||||
export const buildAuctionBidderSystemMessage = (options: {
|
||||
bidder: TurnGeneral;
|
||||
nation?: { name: string; color: string } | null;
|
||||
time: Date;
|
||||
text: string;
|
||||
}): MessageDraft => ({
|
||||
msgType: 'private',
|
||||
src: {
|
||||
generalId: 0,
|
||||
generalName: '',
|
||||
nationId: 0,
|
||||
nationName: 'System',
|
||||
color: '#000000',
|
||||
icon: '',
|
||||
},
|
||||
dest: {
|
||||
generalId: options.bidder.id,
|
||||
generalName: options.bidder.name,
|
||||
nationId: options.bidder.nationId,
|
||||
nationName: options.nation?.name ?? '재야',
|
||||
color: options.nation?.color ?? '#000000',
|
||||
icon: options.bidder.picture ?? '',
|
||||
},
|
||||
text: options.text,
|
||||
time: new Date(options.time.getTime()),
|
||||
validUntil: new Date('9999-12-31T00:00:00.000Z'),
|
||||
option: {},
|
||||
sendDestOnly: true,
|
||||
});
|
||||
|
||||
export const buildAuctionCancellationMessage = (options: {
|
||||
auctionId: number;
|
||||
title?: string;
|
||||
bidder: TurnGeneral;
|
||||
nation?: { name: string; color: string } | null;
|
||||
time: Date;
|
||||
}): MessageDraft =>
|
||||
buildAuctionBidderSystemMessage({
|
||||
bidder: options.bidder,
|
||||
nation: options.nation,
|
||||
time: options.time,
|
||||
text: `${options.auctionId}번 ${options.title ?? '경매'}가 취소되었습니다.`,
|
||||
});
|
||||
|
||||
const toTurnMinutes = (tickSeconds: number): number => Math.max(1, Math.round(tickSeconds / 60));
|
||||
|
||||
type AuctionDb = GamePrisma.TransactionClient;
|
||||
@@ -76,12 +168,48 @@ const pushLogs = (world: InMemoryTurnWorld, logs: LogEntryDraft[]): void => {
|
||||
}
|
||||
};
|
||||
|
||||
export const buildUniqueAuctionInheritanceLogData = (options: {
|
||||
userId: string;
|
||||
year: number;
|
||||
month: number;
|
||||
itemName: string;
|
||||
amount: number;
|
||||
}) => ({
|
||||
userId: options.userId,
|
||||
year: options.year,
|
||||
month: options.month,
|
||||
logType: 'inheritPoint',
|
||||
text: `유니크 ${options.itemName} 경매로 ${options.amount} 포인트 사용`,
|
||||
});
|
||||
|
||||
export const buildUniqueAuctionAwardLogs = (options: {
|
||||
bidder: TurnGeneral;
|
||||
nationName: string;
|
||||
itemName: string;
|
||||
itemRawName: string;
|
||||
}): LogEntryDraft[] => {
|
||||
const logger = new ActionLogger({ generalId: options.bidder.id, nationId: options.bidder.nationId });
|
||||
const josaYi = JosaUtil.pick(options.bidder.name, '이');
|
||||
const josaUl = JosaUtil.pick(options.itemRawName, '을');
|
||||
logger.pushGeneralActionLog(`<C>${options.itemName}</>${josaUl} 습득했습니다!`);
|
||||
logger.pushGeneralHistoryLog(`<C>${options.itemName}</>${josaUl} 습득`);
|
||||
logger.pushGlobalActionLog(
|
||||
`<Y>${options.bidder.name}</>${josaYi} <C>${options.itemName}</>${josaUl} 습득했습니다!`
|
||||
);
|
||||
logger.pushGlobalHistoryLog(
|
||||
`<C><b>【보물수배】</b></><D><b>${options.nationName}</b></>의 <Y>${options.bidder.name}</>${josaYi} <C>${options.itemName}</>${josaUl} 습득했습니다!`
|
||||
);
|
||||
return logger.flush();
|
||||
};
|
||||
|
||||
const refundInheritancePoint = async (options: {
|
||||
prisma: AuctionDb;
|
||||
userId: string;
|
||||
generalId: number;
|
||||
amount: number;
|
||||
rankTrackedAmount: number;
|
||||
}): Promise<void> => {
|
||||
const { prisma, userId, amount } = options;
|
||||
const { prisma, userId, generalId, amount, rankTrackedAmount } = options;
|
||||
if (!userId || amount <= 0) {
|
||||
return;
|
||||
}
|
||||
@@ -95,6 +223,16 @@ const refundInheritancePoint = async (options: {
|
||||
updated_at = EXCLUDED.updated_at
|
||||
`
|
||||
);
|
||||
if (rankTrackedAmount > 0) {
|
||||
await prisma.$executeRaw(
|
||||
GamePrisma.sql`
|
||||
UPDATE rank_data
|
||||
SET value = GREATEST(0, value - ${rankTrackedAmount})
|
||||
WHERE general_id = ${generalId}
|
||||
AND type = 'inherit_spent_dyn'
|
||||
`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const createAuctionFinalizer = async (options: {
|
||||
@@ -103,7 +241,6 @@ export const createAuctionFinalizer = async (options: {
|
||||
}): Promise<AuctionFinalizer> => {
|
||||
const connector = createGamePostgresConnector({ url: options.databaseUrl });
|
||||
await connector.connect();
|
||||
const prisma = connector.prisma;
|
||||
const world = options.world;
|
||||
const itemLoader = new ItemLoader();
|
||||
|
||||
@@ -115,8 +252,17 @@ export const createAuctionFinalizer = async (options: {
|
||||
};
|
||||
|
||||
return {
|
||||
finalize: async (auctionId: number, commandDb): Promise<TurnDaemonCommandResult> => {
|
||||
const db = commandDb ?? prisma;
|
||||
finalize: async (command, commandDb): Promise<TurnDaemonCommandResult> => {
|
||||
const auctionId = command.auctionId;
|
||||
if (!commandDb) {
|
||||
return {
|
||||
type: 'auctionFinalize',
|
||||
ok: false,
|
||||
auctionId,
|
||||
reason: '경매 확정은 ENGINE mutation transaction에서만 실행할 수 있습니다.',
|
||||
};
|
||||
}
|
||||
const db = commandDb;
|
||||
const rows = await db.$queryRaw<AuctionRow[]>(
|
||||
GamePrisma.sql`
|
||||
SELECT id,
|
||||
@@ -126,9 +272,11 @@ export const createAuctionFinalizer = async (options: {
|
||||
host_name as "hostName",
|
||||
detail,
|
||||
status,
|
||||
close_at as "closeAt"
|
||||
close_at as "closeAt",
|
||||
close_tick as "closeTick"
|
||||
FROM auction
|
||||
WHERE id = ${auctionId}
|
||||
FOR UPDATE
|
||||
`
|
||||
);
|
||||
const auction = rows[0];
|
||||
@@ -145,7 +293,39 @@ export const createAuctionFinalizer = async (options: {
|
||||
return { type: 'auctionFinalize', ok: true, auctionId };
|
||||
}
|
||||
|
||||
if (auction.status !== 'FINALIZING') {
|
||||
const now = world.getGameNow(new Date());
|
||||
if (auction.status === 'OPEN') {
|
||||
if (!isAuctionFinalizeGenerationCurrent(auction, command)) {
|
||||
return {
|
||||
type: 'auctionFinalize',
|
||||
ok: false,
|
||||
auctionId,
|
||||
reason: '경매 마감 세대가 변경되었습니다.',
|
||||
};
|
||||
}
|
||||
const nowTick = world.dateToGameTick(now);
|
||||
if (!hasAuctionFinalizeDeadlineArrived(auction, now, nowTick)) {
|
||||
return {
|
||||
type: 'auctionFinalize',
|
||||
ok: false,
|
||||
auctionId,
|
||||
reason: '경매 마감 시각이 아직 지나지 않았습니다.',
|
||||
};
|
||||
}
|
||||
const transitioned = await db.$executeRaw(
|
||||
GamePrisma.sql`
|
||||
UPDATE auction
|
||||
SET status = 'FINALIZING',
|
||||
finalizing_at = ${now},
|
||||
updated_at = ${now}
|
||||
WHERE id = ${auctionId}
|
||||
AND status = 'OPEN'
|
||||
`
|
||||
);
|
||||
if (transitioned !== 1) {
|
||||
throw new Error(`경매 확정 상태 전이에 실패했습니다: ${auctionId}`);
|
||||
}
|
||||
} else if (auction.status !== 'FINALIZING') {
|
||||
return {
|
||||
type: 'auctionFinalize',
|
||||
ok: false,
|
||||
@@ -176,7 +356,6 @@ export const createAuctionFinalizer = async (options: {
|
||||
);
|
||||
const highestBid = bidRows[0] ?? null;
|
||||
|
||||
const now = world.getGameNow(new Date());
|
||||
const logs: LogEntryDraft[] = [];
|
||||
const globalLogger = new ActionLogger();
|
||||
|
||||
@@ -192,11 +371,76 @@ export const createAuctionFinalizer = async (options: {
|
||||
);
|
||||
};
|
||||
|
||||
const cancelWithEscrowRefund = async (options: {
|
||||
bid: AuctionBidRow;
|
||||
resourceAmount?: number | null;
|
||||
refundResourceHost?: boolean;
|
||||
}): Promise<boolean> => {
|
||||
const refundBidder = world.getGeneralById(options.bid.generalId);
|
||||
if (!refundBidder) return false;
|
||||
|
||||
const shouldRefundHost =
|
||||
auction.type !== 'UNIQUE_ITEM' && options.refundResourceHost !== false && auction.hostGeneralId > 0;
|
||||
const resourceAmount = options.resourceAmount ?? null;
|
||||
const resourceHost = shouldRefundHost ? world.getGeneralById(auction.hostGeneralId) : null;
|
||||
if (shouldRefundHost && (!resourceHost || resourceAmount === null || resourceAmount <= 0)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (auction.type === 'UNIQUE_ITEM') {
|
||||
const bidderUserId = await getGeneralUserId(db, refundBidder.id);
|
||||
if (!bidderUserId) return false;
|
||||
const rankTrackedAmount = readRankTrackedAmount(options.bid);
|
||||
await refundInheritancePoint({
|
||||
prisma: db,
|
||||
userId: bidderUserId,
|
||||
generalId: refundBidder.id,
|
||||
amount: options.bid.amount,
|
||||
rankTrackedAmount,
|
||||
});
|
||||
world.updateGeneral(refundBidder.id, {
|
||||
inheritancePoints: {
|
||||
...refundBidder.inheritancePoints,
|
||||
previous: toFiniteNumber(refundBidder.inheritancePoints?.previous) + options.bid.amount,
|
||||
},
|
||||
meta: {
|
||||
...refundBidder.meta,
|
||||
inherit_spent_dyn: Math.max(
|
||||
0,
|
||||
toFiniteNumber(refundBidder.meta.inherit_spent_dyn) - rankTrackedAmount
|
||||
),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const bidderResource = auction.type === 'BUY_RICE' ? 'gold' : 'rice';
|
||||
world.updateGeneral(refundBidder.id, {
|
||||
[bidderResource]: refundBidder[bidderResource] + options.bid.amount,
|
||||
});
|
||||
if (resourceHost && resourceAmount !== null) {
|
||||
const hostResource = auction.type === 'BUY_RICE' ? 'rice' : 'gold';
|
||||
world.updateGeneral(resourceHost.id, {
|
||||
[hostResource]: resourceHost[hostResource] + resourceAmount,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
world.queueMessage(
|
||||
buildAuctionCancellationMessage({
|
||||
auctionId: auction.id,
|
||||
title: detail.title,
|
||||
bidder: refundBidder,
|
||||
nation: world.getNationById(refundBidder.nationId),
|
||||
time: now,
|
||||
})
|
||||
);
|
||||
await finalizeStatus('CANCELED');
|
||||
return true;
|
||||
};
|
||||
|
||||
if (!highestBid) {
|
||||
if (auction.type === 'BUY_RICE' || auction.type === 'SELL_RICE') {
|
||||
const amount = detail.amount;
|
||||
if (!amount || amount <= 0) {
|
||||
await finalizeStatus('CANCELED');
|
||||
const amount = resolveAuctionResourceAmount(detail.amount, auction.targetCode);
|
||||
if (amount === null) {
|
||||
return {
|
||||
type: 'auctionFinalize',
|
||||
ok: false,
|
||||
@@ -207,7 +451,6 @@ export const createAuctionFinalizer = async (options: {
|
||||
if (auction.hostGeneralId > 0) {
|
||||
const host = world.getGeneralById(auction.hostGeneralId);
|
||||
if (!host) {
|
||||
await finalizeStatus('CANCELED');
|
||||
return {
|
||||
type: 'auctionFinalize',
|
||||
ok: false,
|
||||
@@ -226,7 +469,22 @@ export const createAuctionFinalizer = async (options: {
|
||||
);
|
||||
logs.push(...hostLogger.flush());
|
||||
globalLogger.pushGlobalActionLog(`경매 ${auctionId}번이 유찰되었습니다.`, LogFormat.PLAIN);
|
||||
world.queueMessage(
|
||||
buildAuctionBidderSystemMessage({
|
||||
bidder: host,
|
||||
nation: world.getNationById(host.nationId),
|
||||
time: now,
|
||||
text: `${auctionId}번 ${resourceKey === 'rice' ? '쌀' : '금'} 경매에 입찰이 없어 취소되었습니다.`,
|
||||
})
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
type: 'auctionFinalize',
|
||||
ok: false,
|
||||
auctionId,
|
||||
reason: '유니크 경매 입찰 정보가 없어 자동 환불할 수 없습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
logs.push(...globalLogger.flush());
|
||||
@@ -255,6 +513,7 @@ export const createAuctionFinalizer = async (options: {
|
||||
GamePrisma.sql`
|
||||
UPDATE auction
|
||||
SET status = 'OPEN',
|
||||
finalizing_at = NULL,
|
||||
detail = ${JSON.stringify(nextDetail)}::jsonb,
|
||||
close_at = ${nextCloseAt},
|
||||
close_tick = ${BigInt(world.dateToGameTick(nextCloseAt))},
|
||||
@@ -273,7 +532,6 @@ export const createAuctionFinalizer = async (options: {
|
||||
|
||||
const bidder = world.getGeneralById(highestBid.generalId);
|
||||
if (!bidder) {
|
||||
await finalizeStatus('CANCELED');
|
||||
return {
|
||||
type: 'auctionFinalize',
|
||||
ok: false,
|
||||
@@ -283,9 +541,8 @@ export const createAuctionFinalizer = async (options: {
|
||||
}
|
||||
|
||||
if (auction.type === 'BUY_RICE' || auction.type === 'SELL_RICE') {
|
||||
const amount = detail.amount;
|
||||
if (!amount || amount <= 0) {
|
||||
await finalizeStatus('CANCELED');
|
||||
const amount = resolveAuctionResourceAmount(detail.amount, auction.targetCode);
|
||||
if (amount === null) {
|
||||
return {
|
||||
type: 'auctionFinalize',
|
||||
ok: false,
|
||||
@@ -298,7 +555,11 @@ export const createAuctionFinalizer = async (options: {
|
||||
if (auction.hostGeneralId > 0) {
|
||||
const host = world.getGeneralById(auction.hostGeneralId);
|
||||
if (!host) {
|
||||
await finalizeStatus('CANCELED');
|
||||
await cancelWithEscrowRefund({
|
||||
bid: highestBid,
|
||||
resourceAmount: amount,
|
||||
refundResourceHost: false,
|
||||
});
|
||||
return {
|
||||
type: 'auctionFinalize',
|
||||
ok: false,
|
||||
@@ -332,7 +593,7 @@ export const createAuctionFinalizer = async (options: {
|
||||
} else if (auction.type === 'UNIQUE_ITEM') {
|
||||
const itemKey = auction.targetCode;
|
||||
if (!itemKey) {
|
||||
await finalizeStatus('CANCELED');
|
||||
await cancelWithEscrowRefund({ bid: highestBid });
|
||||
return {
|
||||
type: 'auctionFinalize',
|
||||
ok: false,
|
||||
@@ -341,7 +602,7 @@ export const createAuctionFinalizer = async (options: {
|
||||
};
|
||||
}
|
||||
if (!isItemKey(itemKey)) {
|
||||
await finalizeStatus('CANCELED');
|
||||
await cancelWithEscrowRefund({ bid: highestBid });
|
||||
return {
|
||||
type: 'auctionFinalize',
|
||||
ok: false,
|
||||
@@ -351,12 +612,7 @@ export const createAuctionFinalizer = async (options: {
|
||||
}
|
||||
const itemModule = await itemLoader.load(itemKey).catch(() => null);
|
||||
if (!itemModule) {
|
||||
await finalizeStatus('CANCELED');
|
||||
await refundInheritancePoint({
|
||||
prisma: db,
|
||||
userId: (await getGeneralUserId(db, bidder.id)) ?? '',
|
||||
amount: highestBid.amount,
|
||||
});
|
||||
await cancelWithEscrowRefund({ bid: highestBid });
|
||||
return {
|
||||
type: 'auctionFinalize',
|
||||
ok: false,
|
||||
@@ -370,6 +626,42 @@ export const createAuctionFinalizer = async (options: {
|
||||
asRecord(world.getScenarioConfig().const),
|
||||
itemLoader
|
||||
);
|
||||
const configuredAmount = config.allItems[itemModule.slot]?.[itemKey] ?? 0;
|
||||
const occupiedAmount = world
|
||||
.listGenerals()
|
||||
.filter((candidate) => candidate.role.items[itemModule.slot] === itemKey).length;
|
||||
if (isUniqueAuctionSupplyExhausted(configuredAmount, occupiedAmount)) {
|
||||
// Ref keeps the already-escrowed highest bid and retries the expired
|
||||
// auction on later turn passes when every configured copy is held.
|
||||
// This differs from same-slot ownership, which extends the deadline.
|
||||
const turnMinutes = await resolveTurnMinutes(db);
|
||||
const nextCloseAt = resolveUniqueSupplyRetryCloseAt(now, turnMinutes);
|
||||
await db.$executeRaw(
|
||||
GamePrisma.sql`
|
||||
UPDATE auction
|
||||
SET status = 'OPEN',
|
||||
finalizing_at = NULL,
|
||||
close_at = ${nextCloseAt},
|
||||
close_tick = ${BigInt(world.dateToGameTick(nextCloseAt))},
|
||||
updated_at = ${now}
|
||||
WHERE id = ${auctionId}
|
||||
`
|
||||
);
|
||||
world.queueMessage(
|
||||
buildAuctionBidderSystemMessage({
|
||||
bidder,
|
||||
nation: world.getNationById(bidder.nationId),
|
||||
time: now,
|
||||
text: '그 유니크는 모두 점유되었습니다.',
|
||||
})
|
||||
);
|
||||
return {
|
||||
type: 'auctionFinalize',
|
||||
ok: false,
|
||||
auctionId,
|
||||
reason: '그 유니크는 모두 점유되었습니다.',
|
||||
};
|
||||
}
|
||||
const scenarioMeta = asRecord(state.meta.scenarioMeta);
|
||||
const startYear =
|
||||
typeof scenarioMeta.startYear === 'number' && Number.isFinite(scenarioMeta.startYear)
|
||||
@@ -417,6 +709,7 @@ export const createAuctionFinalizer = async (options: {
|
||||
GamePrisma.sql`
|
||||
UPDATE auction
|
||||
SET status = 'OPEN',
|
||||
finalizing_at = NULL,
|
||||
host_general_id = ${bidder.id === auction.hostGeneralId ? auction.hostGeneralId : 0},
|
||||
host_name = ${bidder.id === auction.hostGeneralId ? auction.hostName : '(상인)'},
|
||||
detail = ${JSON.stringify(nextDetail)}::jsonb,
|
||||
@@ -432,6 +725,14 @@ export const createAuctionFinalizer = async (options: {
|
||||
);
|
||||
logs.push(...globalLogger.flush());
|
||||
pushLogs(world, logs);
|
||||
world.queueMessage(
|
||||
buildAuctionBidderSystemMessage({
|
||||
bidder,
|
||||
nation: world.getNationById(bidder.nationId),
|
||||
time: now,
|
||||
text: '유니크 아이템 소유 제한 상태입니다. 종료 시간이 연장됩니다.',
|
||||
})
|
||||
);
|
||||
return {
|
||||
type: 'auctionFinalize',
|
||||
ok: false,
|
||||
@@ -463,6 +764,7 @@ export const createAuctionFinalizer = async (options: {
|
||||
GamePrisma.sql`
|
||||
UPDATE auction
|
||||
SET status = 'OPEN',
|
||||
finalizing_at = NULL,
|
||||
host_general_id = ${bidder.id === auction.hostGeneralId ? auction.hostGeneralId : 0},
|
||||
host_name = ${bidder.id === auction.hostGeneralId ? auction.hostName : '(상인)'},
|
||||
detail = ${JSON.stringify(nextDetail)}::jsonb,
|
||||
@@ -478,6 +780,17 @@ export const createAuctionFinalizer = async (options: {
|
||||
);
|
||||
logs.push(...globalLogger.flush());
|
||||
pushLogs(world, logs);
|
||||
world.queueMessage(
|
||||
buildAuctionBidderSystemMessage({
|
||||
bidder,
|
||||
nation: world.getNationById(bidder.nationId),
|
||||
time: now,
|
||||
text:
|
||||
currentItem === itemKey
|
||||
? '이미 그 유니크를 가지고 있습니다.'
|
||||
: '이미 다른 유니크를 가지고 있습니다.',
|
||||
})
|
||||
);
|
||||
return {
|
||||
type: 'auctionFinalize',
|
||||
ok: false,
|
||||
@@ -499,28 +812,28 @@ export const createAuctionFinalizer = async (options: {
|
||||
itemInventory: nextBidder.itemInventory,
|
||||
});
|
||||
|
||||
const bidderLogger = new ActionLogger({ generalId: bidder.id, nationId: bidder.nationId });
|
||||
const josaUl = JosaUtil.pick(itemModule.rawName, '을');
|
||||
bidderLogger.pushGeneralActionLog(
|
||||
`유니크 경매 낙찰로 ${itemModule.name}${josaUl} 획득했습니다.`,
|
||||
LogFormat.PLAIN
|
||||
logs.push(
|
||||
...buildUniqueAuctionAwardLogs({
|
||||
bidder,
|
||||
nationName: world.getNationById(bidder.nationId)?.name ?? '재야',
|
||||
itemName: itemModule.name,
|
||||
itemRawName: itemModule.rawName,
|
||||
})
|
||||
);
|
||||
logs.push(...bidderLogger.flush());
|
||||
|
||||
const bidderUserId = await getGeneralUserId(db, bidder.id);
|
||||
if (bidderUserId) {
|
||||
const userIdNum = Number(bidderUserId);
|
||||
if (Number.isFinite(userIdNum)) {
|
||||
const userLogger = new UserLogger(userIdNum);
|
||||
userLogger.push(
|
||||
`유니크 ${itemModule.name} 경매로 ${highestBid.amount} 포인트 사용`,
|
||||
'inheritPoint'
|
||||
);
|
||||
logs.push(...userLogger.flush());
|
||||
}
|
||||
const state = world.getState();
|
||||
await db.inheritanceLog.create({
|
||||
data: buildUniqueAuctionInheritanceLogData({
|
||||
userId: bidderUserId,
|
||||
year: state.currentYear,
|
||||
month: state.currentMonth,
|
||||
itemName: itemModule.name,
|
||||
amount: highestBid.amount,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
globalLogger.pushGlobalActionLog(`유니크 경매 ${auctionId}번이 성사되었습니다.`, LogFormat.PLAIN);
|
||||
}
|
||||
|
||||
logs.push(...globalLogger.flush());
|
||||
|
||||
@@ -37,6 +37,12 @@ const fail = (reason: string): TurnDaemonCommandResult => ({
|
||||
reason,
|
||||
});
|
||||
|
||||
export const buildInitialUniqueAuctionBidMeta = (obfuscatedName: string, amount: number) => ({
|
||||
obfuscatedName,
|
||||
tryExtendCloseDate: false,
|
||||
inheritSpentTrackedAmount: amount,
|
||||
});
|
||||
|
||||
const openResourceAuction = async (
|
||||
command: AuctionOpenCommand,
|
||||
world: InMemoryTurnWorld,
|
||||
@@ -257,7 +263,7 @@ const openUniqueAuction = async (
|
||||
amount: command.amount,
|
||||
eventId,
|
||||
eventAt: now,
|
||||
meta: { obfuscatedName: alias, tryExtendCloseDate: false },
|
||||
meta: buildInitialUniqueAuctionBidMeta(alias, command.amount),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -266,6 +272,26 @@ const openUniqueAuction = async (
|
||||
where: { userId_key: { userId, key: 'previous' } },
|
||||
data: { value: currentPoint - command.amount },
|
||||
});
|
||||
await db.$executeRaw(
|
||||
GamePrisma.sql`
|
||||
INSERT INTO rank_data (nation_id, general_id, type, value)
|
||||
VALUES (${general.nationId}, ${general.id}, 'inherit_spent_dyn', ${command.amount})
|
||||
ON CONFLICT (general_id, type)
|
||||
DO UPDATE SET
|
||||
nation_id = EXCLUDED.nation_id,
|
||||
value = rank_data.value + EXCLUDED.value
|
||||
`
|
||||
);
|
||||
world.updateGeneral(general.id, {
|
||||
inheritancePoints: {
|
||||
...general.inheritancePoints,
|
||||
previous: currentPoint - command.amount,
|
||||
},
|
||||
meta: {
|
||||
...general.meta,
|
||||
inherit_spent_dyn: readNumber(asRecord(general.meta), 'inherit_spent_dyn', 0) + command.amount,
|
||||
},
|
||||
});
|
||||
|
||||
const logger = new ActionLogger();
|
||||
const rawNameJosa = JosaUtil.pick(item.rawName, '라');
|
||||
|
||||
@@ -388,7 +388,14 @@ export class TurnDaemonLifecycle {
|
||||
this.status.running = true;
|
||||
this.status.pendingReason = pending.reason;
|
||||
|
||||
const targetTime = pending.targetTime ?? new Date(startMs);
|
||||
const requestedTargetTime = pending.targetTime ?? new Date(startMs);
|
||||
const lastTurnTime = this.status.lastTurnTime ? new Date(this.status.lastTurnTime) : requestedTargetTime;
|
||||
// A single Ref turn run never observes generals beyond the next monthly
|
||||
// boundary before that boundary's monthly actions have completed. Keep
|
||||
// explicit manual/poke targets on the same chronological boundary as the
|
||||
// scheduled paths above.
|
||||
const nextMonthTime = this.getNextTickTime(lastTurnTime);
|
||||
const targetTime = new Date(Math.min(requestedTargetTime.getTime(), nextMonthTime.getTime()));
|
||||
const budget = pending.budget ?? this.options.defaultBudget;
|
||||
const checkpoint = this.status.checkpoint;
|
||||
let result: TurnRunResult;
|
||||
|
||||
@@ -7,8 +7,8 @@ import { isEventDomesticTraitKey } from '@sammo-ts/logic';
|
||||
import { resolveWorkspaceRoot } from '../paths.js';
|
||||
|
||||
const DEFAULT_GENERAL_POOL_ROOT = path.resolve(resolveWorkspaceRoot(), 'resources', 'general-pool');
|
||||
const SUPPORTED_POOL = 'SPoolUnderU30';
|
||||
const EXPECTED_COLUMNS = [
|
||||
const SUPPORTED_POOLS = new Set(['SPoolUnderU30', 'SPoolUnderU100']);
|
||||
const BASE_COLUMNS = [
|
||||
'generalName',
|
||||
'leadership',
|
||||
'strength',
|
||||
@@ -18,6 +18,13 @@ const EXPECTED_COLUMNS = [
|
||||
'imgsvr',
|
||||
'picture',
|
||||
] as const;
|
||||
const CENTENNIAL_COLUMNS = [
|
||||
...BASE_COLUMNS,
|
||||
'sourcePhase',
|
||||
'sourceServerId',
|
||||
'sourceGeneralNo',
|
||||
'selectionReasons',
|
||||
] as const;
|
||||
|
||||
export interface GeneralPoolSeedEntry {
|
||||
uniqueName: string;
|
||||
@@ -31,38 +38,58 @@ export interface GeneralPoolLoaderOptions {
|
||||
const readPoolResource = async (filePath: string): Promise<unknown> =>
|
||||
JSON.parse(await fs.readFile(filePath, 'utf8')) as unknown;
|
||||
|
||||
const normalizePoolRow = (row: unknown, index: number): GeneralPoolSeedEntry => {
|
||||
if (!Array.isArray(row) || row.length !== EXPECTED_COLUMNS.length) {
|
||||
throw new Error(`General pool row ${index} does not match the expected ${EXPECTED_COLUMNS.length} columns.`);
|
||||
const normalizePoolRow = (
|
||||
poolName: string,
|
||||
columns: readonly string[],
|
||||
row: unknown,
|
||||
index: number
|
||||
): GeneralPoolSeedEntry => {
|
||||
if (!Array.isArray(row) || row.length !== columns.length) {
|
||||
throw new Error(`General pool row ${index} does not match the expected ${columns.length} columns.`);
|
||||
}
|
||||
const info = Object.fromEntries(EXPECTED_COLUMNS.map((column, columnIndex) => [column, row[columnIndex]]));
|
||||
const uniqueName = info.generalName;
|
||||
if (typeof uniqueName !== 'string' || uniqueName.length === 0) {
|
||||
const info = Object.fromEntries(columns.map((column, columnIndex) => [column, row[columnIndex]]));
|
||||
const generalName = info.generalName;
|
||||
if (typeof generalName !== 'string' || generalName.length === 0) {
|
||||
throw new Error(`General pool row ${index} has no generalName.`);
|
||||
}
|
||||
const uniqueName = poolName === 'SPoolUnderU100' ? `A100${String(index + 1).padStart(4, '0')}` : generalName;
|
||||
if (uniqueName.length > 20) {
|
||||
throw new Error(`General pool row ${index} has a generalName longer than the select_pool key.`);
|
||||
}
|
||||
const isCentennial = poolName === 'SPoolUnderU100';
|
||||
const specialDomesticIsValid =
|
||||
(isCentennial && info.specialDomestic === null) ||
|
||||
(typeof info.specialDomestic === 'string' && isEventDomesticTraitKey(info.specialDomestic));
|
||||
if (
|
||||
!Number.isInteger(info.leadership) ||
|
||||
!Number.isInteger(info.strength) ||
|
||||
!Number.isInteger(info.intel) ||
|
||||
typeof info.specialDomestic !== 'string' ||
|
||||
!isEventDomesticTraitKey(info.specialDomestic) ||
|
||||
!specialDomesticIsValid ||
|
||||
!Array.isArray(info.dex) ||
|
||||
info.dex.length !== 5 ||
|
||||
info.dex.some((value) => typeof value !== 'number' || !Number.isInteger(value) || value < 0) ||
|
||||
info.dex.reduce((sum, value) => sum + Number(value), 0) <= 0 ||
|
||||
(!isCentennial && info.dex.reduce((sum, value) => sum + Number(value), 0) <= 0) ||
|
||||
(info.imgsvr !== 0 && info.imgsvr !== 1) ||
|
||||
typeof info.picture !== 'string'
|
||||
) {
|
||||
throw new Error(`General pool row ${index} contains invalid candidate data.`);
|
||||
}
|
||||
if (
|
||||
isCentennial &&
|
||||
(!Number.isInteger(info.sourcePhase) ||
|
||||
typeof info.sourceServerId !== 'string' ||
|
||||
!Number.isInteger(info.sourceGeneralNo) ||
|
||||
!Array.isArray(info.selectionReasons) ||
|
||||
info.selectionReasons.some((reason) => typeof reason !== 'string'))
|
||||
) {
|
||||
throw new Error(`General pool row ${index} contains invalid source metadata.`);
|
||||
}
|
||||
return {
|
||||
uniqueName,
|
||||
info: {
|
||||
...info,
|
||||
uniqueName,
|
||||
...(isCentennial ? { event100Growth: true } : {}),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -71,7 +98,7 @@ export const loadGeneralPoolEntries = async (
|
||||
poolName: string,
|
||||
options?: GeneralPoolLoaderOptions
|
||||
): Promise<GeneralPoolSeedEntry[]> => {
|
||||
if (poolName !== SUPPORTED_POOL) {
|
||||
if (!SUPPORTED_POOLS.has(poolName)) {
|
||||
throw new Error(`Unsupported general pool: ${poolName}.`);
|
||||
}
|
||||
const root = path.resolve(options?.generalPoolRoot ?? DEFAULT_GENERAL_POOL_ROOT);
|
||||
@@ -79,13 +106,14 @@ export const loadGeneralPoolEntries = async (
|
||||
if (!isRecord(raw) || !Array.isArray(raw.columns) || !Array.isArray(raw.data)) {
|
||||
throw new Error(`General pool ${poolName} is not a valid resource.`);
|
||||
}
|
||||
const expectedColumns = poolName === 'SPoolUnderU100' ? CENTENNIAL_COLUMNS : BASE_COLUMNS;
|
||||
if (
|
||||
raw.columns.length !== EXPECTED_COLUMNS.length ||
|
||||
raw.columns.some((column, index) => column !== EXPECTED_COLUMNS[index])
|
||||
raw.columns.length !== expectedColumns.length ||
|
||||
raw.columns.some((column, index) => column !== expectedColumns[index])
|
||||
) {
|
||||
throw new Error(`General pool ${poolName} has an unexpected column contract.`);
|
||||
}
|
||||
const entries = raw.data.map(normalizePoolRow);
|
||||
const entries = raw.data.map((row, index) => normalizePoolRow(poolName, expectedColumns, row, index));
|
||||
if (new Set(entries.map((entry) => entry.uniqueName)).size !== entries.length) {
|
||||
throw new Error(`General pool ${poolName} contains duplicate unique names.`);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export const resolveConstraintEnv = (
|
||||
develCost: env.develCost,
|
||||
openingPartYear: env.openingPartYear,
|
||||
minAvailableRecruitPop: env.minAvailableRecruitPop,
|
||||
maxTechLevel: env.maxTechLevel,
|
||||
...(Number.isFinite(killturn) ? { killturn } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -115,7 +115,8 @@ export const resolveLegacyAiStatsWithModules = (
|
||||
modules: TurnCommandEnv['generalActionModules'],
|
||||
worldRef: AiWorldView | null,
|
||||
world: TurnWorldState,
|
||||
startYear: number
|
||||
startYear: number,
|
||||
maxTechLevel = 12
|
||||
) => {
|
||||
const maxLevel = Math.max(1, maxStatLevel);
|
||||
const clampStat = (value: number): number => Math.max(0, Math.min(value, maxLevel));
|
||||
@@ -138,6 +139,7 @@ export const resolveLegacyAiStatsWithModules = (
|
||||
month: world.currentMonth,
|
||||
startYear,
|
||||
},
|
||||
maxTechLevel,
|
||||
};
|
||||
const rawStat = (statName: 'leadership' | 'strength' | 'intelligence'): number => general.stats[statName];
|
||||
const calculate = (
|
||||
@@ -699,7 +701,8 @@ export class GeneralAI {
|
||||
this.commandEnv.generalActionModules,
|
||||
this.worldRef,
|
||||
this.world,
|
||||
this.startYear
|
||||
this.startYear,
|
||||
this.commandEnv.maxTechLevel
|
||||
);
|
||||
}
|
||||
return resolveLegacyAiStats(general, this.nation, this.commandEnv.maxStatLevel ?? LEGACY_DEFAULT_MAX_LEVEL);
|
||||
@@ -726,6 +729,7 @@ export class GeneralAI {
|
||||
month: this.world.currentMonth,
|
||||
startYear: this.startYear,
|
||||
},
|
||||
maxTechLevel: this.commandEnv.maxTechLevel,
|
||||
},
|
||||
'징집인구',
|
||||
'score',
|
||||
|
||||
@@ -70,13 +70,14 @@ export const do금쌀구매 = (ai: GeneralAI) => {
|
||||
}
|
||||
: {}),
|
||||
time: { year: ai.world.currentYear, month: ai.world.currentMonth, startYear: ai.startYear },
|
||||
maxTechLevel: ai.commandEnv.maxTechLevel,
|
||||
};
|
||||
const recruitment = new RecruitmentCommandResolver(ai.commandEnv.generalActionModules ?? [], ai.commandEnv);
|
||||
const goldCost = crewType
|
||||
? recruitment.getCost(recruitContext, crewType.id, crewAmount, crewType).gold *
|
||||
(ai.generalPolicy.can('모병') ? 2 : 1)
|
||||
: 0;
|
||||
const riceCost = crewType ? (crewType.rice * getTechCost(tech) * crewAmount) / 100 : 0;
|
||||
const riceCost = crewType ? (crewType.rice * getTechCost(tech, ai.commandEnv.maxTechLevel) * crewAmount) / 100 : 0;
|
||||
trace('recruit-cost', {
|
||||
crewTypeId: crewType?.id ?? null,
|
||||
crewCost: crewType?.cost ?? null,
|
||||
|
||||
@@ -87,6 +87,7 @@ export const do징병 = (ai: GeneralAI) => {
|
||||
month: ai.world.currentMonth,
|
||||
startYear: ai.startYear,
|
||||
},
|
||||
maxTechLevel: ai.commandEnv.maxTechLevel,
|
||||
};
|
||||
const recruitment = new RecruitmentCommandResolver(ai.commandEnv.generalActionModules ?? [], ai.commandEnv);
|
||||
// The cached AI stat follows the scenario/global classification cap, while
|
||||
@@ -163,11 +164,17 @@ export const do징병 = (ai: GeneralAI) => {
|
||||
return null;
|
||||
}
|
||||
let picked = ai.rng.choiceUsingWeightPair(
|
||||
candidates.map((crew) => [crew, getCrewTypePickScore(crew, tech, warConfig.armPerPhase)])
|
||||
candidates.map((crew) => [
|
||||
crew,
|
||||
getCrewTypePickScore(crew, tech, warConfig.armPerPhase, ai.commandEnv.maxTechLevel),
|
||||
])
|
||||
);
|
||||
trace('crew-type', {
|
||||
armType,
|
||||
candidates: candidates.map((crew) => [crew.id, getCrewTypePickScore(crew, tech, warConfig.armPerPhase)]),
|
||||
candidates: candidates.map((crew) => [
|
||||
crew.id,
|
||||
getCrewTypePickScore(crew, tech, warConfig.armPerPhase, ai.commandEnv.maxTechLevel),
|
||||
]),
|
||||
picked: picked.id,
|
||||
});
|
||||
if (ai.generalPolicy.can('고급병종')) {
|
||||
@@ -203,7 +210,7 @@ export const do징병 = (ai: GeneralAI) => {
|
||||
const killCrew = readMetaNumber(generalMeta, 'rank_killcrew', readMetaNumber(generalMeta, 'killcrew', 0));
|
||||
const deathCrew = readMetaNumber(generalMeta, 'rank_deathcrew', readMetaNumber(generalMeta, 'deathcrew', 0));
|
||||
const expectedCrewLoss = Math.floor((crewAmount * killCrew * 1.2) / Math.max(deathCrew, 1));
|
||||
let riceCost = (picked.rice * getTechCost(tech) * expectedCrewLoss) / 100;
|
||||
let riceCost = (picked.rice * getTechCost(tech, ai.commandEnv.maxTechLevel) * expectedCrewLoss) / 100;
|
||||
|
||||
const remainingGold = ai.general.gold - fullLeadership * 3;
|
||||
const remainingRice = ai.general.rice - fullLeadership * 4;
|
||||
|
||||
@@ -56,7 +56,8 @@ const getCrewGoldCost = (ai: GeneralAI, general: TurnGeneral, baseMultiplier: nu
|
||||
// Keeping that operation order is observable at exact resource boundaries
|
||||
// (for example 3036 versus 3036.0000000000005).
|
||||
return (
|
||||
(((crewType?.cost ?? 0) * getTechCost(tech) * getFullLeadership(ai, general)) / 100) *
|
||||
(((crewType?.cost ?? 0) * getTechCost(tech, ai.commandEnv.maxTechLevel) * getFullLeadership(ai, general)) /
|
||||
100) *
|
||||
100 *
|
||||
baseMultiplier *
|
||||
finalMultiplier
|
||||
|
||||
@@ -77,10 +77,7 @@ const USER_RULER_ACTION_FEATURE: Readonly<Record<string, UserRulerAutomationFeat
|
||||
};
|
||||
|
||||
/** NPC는 기존 계약을 유지하고, 사용자 군주는 명시적으로 고른 업무만 위임한다. */
|
||||
export const canUseRulerAutomation = (
|
||||
general: TurnGeneral,
|
||||
feature: UserRulerAutomationFeature
|
||||
): boolean => {
|
||||
export const canUseRulerAutomation = (general: TurnGeneral, feature: UserRulerAutomationFeature): boolean => {
|
||||
if (general.npcState >= 2) {
|
||||
return true;
|
||||
}
|
||||
@@ -316,8 +313,8 @@ export class AutorunNationPolicy {
|
||||
const stat = scenarioConfig.stat;
|
||||
if (this.reqNpcWarGold === 0 || this.reqNpcWarRice === 0) {
|
||||
const crewType = findCrewTypeById(unitSet, env.defaultCrewTypeId);
|
||||
const baseGold = crewType ? crewType.cost * getTechCost(tech) * stat.npcMax : 0;
|
||||
const baseRice = crewType ? crewType.rice * getTechCost(tech) * stat.npcMax : 0;
|
||||
const baseGold = crewType ? crewType.cost * getTechCost(tech, env.maxTechLevel) * stat.npcMax : 0;
|
||||
const baseRice = crewType ? crewType.rice * getTechCost(tech, env.maxTechLevel) * stat.npcMax : 0;
|
||||
if (this.reqNpcWarGold === 0) {
|
||||
this.reqNpcWarGold = roundTo(baseGold * 4, -2);
|
||||
}
|
||||
@@ -328,8 +325,8 @@ export class AutorunNationPolicy {
|
||||
|
||||
if (this.reqHumanWarUrgentGold === 0 || this.reqHumanWarUrgentRice === 0) {
|
||||
const crewType = findCrewTypeById(unitSet, env.defaultCrewTypeId);
|
||||
const baseGold = crewType ? crewType.cost * getTechCost(tech) * stat.max : 0;
|
||||
const baseRice = crewType ? crewType.rice * getTechCost(tech) * stat.max : 0;
|
||||
const baseGold = crewType ? crewType.cost * getTechCost(tech, env.maxTechLevel) * stat.max : 0;
|
||||
const baseRice = crewType ? crewType.rice * getTechCost(tech, env.maxTechLevel) * stat.max : 0;
|
||||
if (this.reqHumanWarUrgentGold === 0) {
|
||||
this.reqHumanWarUrgentGold = roundTo(baseGold * 6, -2);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ const parseWith = <T>(schema: z.ZodType<T>, value: unknown): T | null => {
|
||||
};
|
||||
|
||||
const zFiniteNumber = z.number().finite();
|
||||
const zSafeInteger = zFiniteNumber.int().min(Number.MIN_SAFE_INTEGER).max(Number.MAX_SAFE_INTEGER);
|
||||
const zRecord = z.record(z.string(), z.unknown());
|
||||
|
||||
const zRunReason = z.enum(['schedule', 'manual', 'poke']);
|
||||
@@ -35,6 +36,8 @@ const zTurnRunBudget = z.object({
|
||||
const zAuctionFinalize = z.object({
|
||||
type: z.literal('auctionFinalize'),
|
||||
auctionId: zFiniteNumber,
|
||||
expectedCloseAt: z.string().refine(isCanonicalIsoTimestamp).optional(),
|
||||
expectedCloseTick: zSafeInteger.optional(),
|
||||
});
|
||||
|
||||
const zAuctionOpen = z.object({
|
||||
@@ -53,6 +56,7 @@ const zAuctionBid = z.object({
|
||||
auctionId: zFiniteNumber,
|
||||
generalId: zFiniteNumber,
|
||||
amount: zFiniteNumber,
|
||||
acceptedGameTick: zSafeInteger.optional(),
|
||||
tryExtendCloseDate: z.boolean().optional(),
|
||||
});
|
||||
|
||||
@@ -176,6 +180,7 @@ const zTournamentRefund = z.object({
|
||||
const zTournamentBettingPayout = z.object({
|
||||
type: z.literal('tournamentBettingPayout'),
|
||||
bettingId: zFiniteNumber.optional(),
|
||||
tournamentType: zFiniteNumber.optional(),
|
||||
reason: z.string().optional(),
|
||||
payouts: z.array(z.object({ generalId: zFiniteNumber, amount: zFiniteNumber })).min(1),
|
||||
});
|
||||
@@ -194,13 +199,8 @@ const zVoteReward = z.object({
|
||||
type: z.literal('voteReward'),
|
||||
voteId: zFiniteNumber,
|
||||
generalId: zFiniteNumber,
|
||||
goldReward: zFiniteNumber,
|
||||
unique: z
|
||||
.object({
|
||||
expected: z.boolean(),
|
||||
itemKey: z.string().nullable().optional(),
|
||||
})
|
||||
.optional(),
|
||||
selection: z.array(zFiniteNumber.int()).min(1),
|
||||
acceptedGameTick: zSafeInteger.optional(),
|
||||
});
|
||||
|
||||
const zSetNationMeta = z.object({
|
||||
@@ -329,6 +329,19 @@ const zSelectPoolCreate = z
|
||||
ownerPicture: z.string().optional(),
|
||||
ownerImageServer: z.number().int().nonnegative().optional(),
|
||||
ownerIconRevision: z.string().refine(isCanonicalIsoTimestamp).optional(),
|
||||
acceptedGameAt: z.string().refine(isCanonicalIsoTimestamp).optional(),
|
||||
acceptedGameTick: zFiniteNumber.int().min(Number.MIN_SAFE_INTEGER).max(Number.MAX_SAFE_INTEGER).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const zSelectPoolReserve = z
|
||||
.object({
|
||||
type: z.literal('selectPoolReserve'),
|
||||
requestId: z.string().optional(),
|
||||
userId: z.string().min(1),
|
||||
seedOwnerIdentity: z.union([z.string().min(1), zFiniteNumber]),
|
||||
acceptedGameAt: z.string().refine(isCanonicalIsoTimestamp),
|
||||
acceptedGameTick: zFiniteNumber.int().min(Number.MIN_SAFE_INTEGER).max(Number.MAX_SAFE_INTEGER).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -339,6 +352,8 @@ const zSelectPoolReselect = z
|
||||
userId: z.string().min(1),
|
||||
ownerDisplayName: z.string().min(1),
|
||||
uniqueName: z.string().min(1).max(20),
|
||||
acceptedGameAt: z.string().refine(isCanonicalIsoTimestamp).optional(),
|
||||
acceptedGameTick: zFiniteNumber.int().min(Number.MIN_SAFE_INTEGER).max(Number.MAX_SAFE_INTEGER).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -660,6 +675,14 @@ const normalizeSelectPoolCreate: CommandNormalizer<'selectPoolCreate'> = (envelo
|
||||
return { ...command, requestId: envelope.requestId };
|
||||
};
|
||||
|
||||
const normalizeSelectPoolReserve: CommandNormalizer<'selectPoolReserve'> = (envelope) => {
|
||||
const command = parseWith(zSelectPoolReserve, envelope.command);
|
||||
if (!command) {
|
||||
return null;
|
||||
}
|
||||
return { ...command, requestId: envelope.requestId };
|
||||
};
|
||||
|
||||
const normalizeSelectPoolReselect: CommandNormalizer<'selectPoolReselect'> = (envelope) => {
|
||||
const command = parseWith(zSelectPoolReselect, envelope.command);
|
||||
if (!command) {
|
||||
@@ -744,6 +767,7 @@ const normalizers: CommandNormalizerMap = {
|
||||
adjustGeneralIcon: normalizeAdjustGeneralIcon,
|
||||
joinCreateGeneral: normalizeJoinCreateGeneral,
|
||||
npcPossessGeneral: normalizeNpcPossessGeneral,
|
||||
selectPoolReserve: normalizeSelectPoolReserve,
|
||||
selectPoolCreate: normalizeSelectPoolCreate,
|
||||
selectPoolReselect: normalizeSelectPoolReselect,
|
||||
getStatus: normalizeGetStatus,
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
LogFormat,
|
||||
LogScope,
|
||||
sendMessage,
|
||||
readScenarioGeneralPoolClaim,
|
||||
type City,
|
||||
type LogEntryDraft,
|
||||
type MessageRecordDraft,
|
||||
@@ -1192,6 +1193,21 @@ export const createDatabaseTurnHooks = async (
|
||||
WHERE status = 'OPEN'
|
||||
`
|
||||
);
|
||||
await prisma.$executeRaw(
|
||||
GamePrisma.sql`
|
||||
UPDATE select_pool
|
||||
SET reserved_until_tick = CASE
|
||||
WHEN reserved_until_tick IS NULL THEN NULL
|
||||
ELSE reserved_until_tick + ${deltaTicks}
|
||||
END,
|
||||
reserved_until = CASE
|
||||
WHEN reserved_until IS NULL THEN NULL
|
||||
ELSE reserved_until + (${deltaSeconds} * INTERVAL '1 second')
|
||||
END
|
||||
WHERE general_id IS NULL
|
||||
AND (reserved_until_tick IS NOT NULL OR reserved_until IS NOT NULL)
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -1360,6 +1376,66 @@ export const createDatabaseTurnHooks = async (
|
||||
createdDiplomacy.map((entry) => `${entry.fromNationId}:${entry.toNationId}`)
|
||||
);
|
||||
|
||||
// Ref first claims a select_pool row and then finalizes it with the
|
||||
// newly inserted general ID. Core computes the turn in memory, so
|
||||
// perform the equivalent CAS in the same fenced flush transaction.
|
||||
// Releasing deaths first also preserves same-batch pool reuse.
|
||||
if (deletedGenerals.length > 0) {
|
||||
await prisma.selectPoolEntry.updateMany({
|
||||
where: { generalId: { in: deletedGenerals } },
|
||||
data: {
|
||||
generalId: null,
|
||||
ownerUserId: null,
|
||||
reservedUntil: null,
|
||||
reservedUntilTick: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
const createdGeneralPoolClaims = createdGenerals.flatMap((general) => {
|
||||
const claim = readScenarioGeneralPoolClaim(general.meta);
|
||||
return claim ? [{ general, claim }] : [];
|
||||
});
|
||||
const claimedPoolIds = new Set<number>();
|
||||
for (const { general, claim } of createdGeneralPoolClaims) {
|
||||
if (claimedPoolIds.has(claim.poolEntryId)) {
|
||||
throw new Error(`한 flush에서 select_pool 후보 ${claim.poolEntryId}를 중복 점유할 수 없습니다.`);
|
||||
}
|
||||
claimedPoolIds.add(claim.poolEntryId);
|
||||
const claimedAt = new Date(claim.claimedAt);
|
||||
const claimedAtTick = world.dateToGameTick(claimedAt);
|
||||
if (!Number.isSafeInteger(claimedAtTick)) {
|
||||
throw new Error(
|
||||
`select_pool 후보 점유 tick이 안전한 정수 범위를 벗어났습니다: ${claim.uniqueName}`
|
||||
);
|
||||
}
|
||||
const occupied = await prisma.selectPoolEntry.updateMany({
|
||||
where: {
|
||||
id: claim.poolEntryId,
|
||||
uniqueName: claim.uniqueName,
|
||||
OR: [
|
||||
{ generalId: general.id },
|
||||
{
|
||||
generalId: null,
|
||||
OR: [
|
||||
{ ownerUserId: null, reservedUntil: null, reservedUntilTick: null },
|
||||
{ reservedUntilTick: { lt: BigInt(claimedAtTick) } },
|
||||
{ reservedUntilTick: null, reservedUntil: { lt: claimedAt } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
data: {
|
||||
generalId: general.id,
|
||||
ownerUserId: null,
|
||||
reservedUntil: null,
|
||||
reservedUntilTick: null,
|
||||
},
|
||||
});
|
||||
if (occupied.count !== 1) {
|
||||
throw new Error(`select_pool 후보를 점유하지 못했습니다: ${claim.uniqueName}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (createdGenerals.length > 0) {
|
||||
await prisma.general.createMany({
|
||||
data: createdGenerals.map(buildGeneralCreate),
|
||||
@@ -1426,6 +1502,7 @@ export const createDatabaseTurnHooks = async (
|
||||
generalId: null,
|
||||
ownerUserId: null,
|
||||
reservedUntil: null,
|
||||
reservedUntilTick: null,
|
||||
},
|
||||
});
|
||||
if (prisma.generalTurnRevision) {
|
||||
|
||||
@@ -4,11 +4,12 @@ import type {
|
||||
MessageDraft,
|
||||
Nation,
|
||||
ScenarioConfig,
|
||||
ScenarioGeneralPoolCandidate,
|
||||
Troop,
|
||||
TurnSchedule,
|
||||
UnitSetDefinition,
|
||||
} from '@sammo-ts/logic';
|
||||
import { getNextTurnAt } from '@sammo-ts/logic';
|
||||
import { getNextTurnAt, readScenarioGeneralPoolClaim } from '@sammo-ts/logic';
|
||||
import { GAME_TICKS_PER_TURN, GameClock, type GameClockMode } from '@sammo-ts/common';
|
||||
|
||||
import type { TurnCheckpoint } from '../lifecycle/types.js';
|
||||
@@ -21,6 +22,7 @@ import type {
|
||||
TurnDiplomacy,
|
||||
TurnEvent,
|
||||
TurnGeneral,
|
||||
TurnGeneralPoolEntry,
|
||||
TurnWorldSnapshot,
|
||||
TurnWorldState,
|
||||
} from './types.js';
|
||||
@@ -154,6 +156,7 @@ export interface InMemoryTurnWorldStateSnapshot {
|
||||
schedule: TurnSchedule;
|
||||
state: TurnWorldState;
|
||||
worldConfig: Record<string, unknown>;
|
||||
generalPoolEntries: TurnWorldSnapshot['generalPoolEntries'];
|
||||
checkpoint?: TurnCheckpoint;
|
||||
generals: Array<[number, TurnGeneral]>;
|
||||
cities: Array<[number, City]>;
|
||||
@@ -491,6 +494,7 @@ export class InMemoryTurnWorld {
|
||||
private readonly pendingUnificationFinalizations: PendingUnificationFinalization[] = [];
|
||||
private pendingRealtimeBacklogShiftTicks = 0;
|
||||
private readonly scenarioConfig: ScenarioConfig;
|
||||
private generalPoolEntries: TurnWorldSnapshot['generalPoolEntries'];
|
||||
private readonly worldConfig: Record<string, unknown>;
|
||||
private readonly unitSet?: UnitSetDefinition;
|
||||
private checkpoint?: TurnCheckpoint;
|
||||
@@ -528,6 +532,13 @@ export class InMemoryTurnWorld {
|
||||
meta: { ...state.meta, lastTurnTime: lastTurnTime.toISOString() },
|
||||
};
|
||||
this.scenarioConfig = snapshot.scenarioConfig;
|
||||
this.generalPoolEntries = snapshot.generalPoolEntries
|
||||
? snapshot.generalPoolEntries.map((entry) => ({
|
||||
...entry,
|
||||
reservedUntil: entry.reservedUntil ? new Date(entry.reservedUntil.getTime()) : null,
|
||||
candidate: structuredClone(entry.candidate),
|
||||
}))
|
||||
: undefined;
|
||||
// Runtime callbacks created before the world keep the original object
|
||||
// reference. Mutate this object in place so a live settings action is
|
||||
// observed by monthly handlers without restarting the daemon.
|
||||
@@ -712,6 +723,14 @@ export class InMemoryTurnWorld {
|
||||
turnTime: clock.tickToDate(turnTick),
|
||||
});
|
||||
}
|
||||
for (const entry of this.generalPoolEntries ?? []) {
|
||||
if (entry.reservedUntilTick !== null) {
|
||||
entry.reservedUntilTick = clock.addTicks(entry.reservedUntilTick, shiftedTicks);
|
||||
entry.reservedUntil = clock.tickToDate(entry.reservedUntilTick);
|
||||
} else if (entry.reservedUntil) {
|
||||
entry.reservedUntil = new Date(entry.reservedUntil.getTime() + shiftedMilliseconds);
|
||||
}
|
||||
}
|
||||
if (this.checkpoint) {
|
||||
const checkpointTick = clock.addTicks(
|
||||
this.checkpoint.turnTick ?? clock.dateToTick(new Date(this.checkpoint.turnTime)),
|
||||
@@ -738,6 +757,7 @@ export class InMemoryTurnWorld {
|
||||
schedule: this.schedule,
|
||||
state: this.state,
|
||||
worldConfig: this.worldConfig,
|
||||
generalPoolEntries: this.generalPoolEntries,
|
||||
checkpoint: this.checkpoint,
|
||||
generals: Array.from(this.generals.entries()),
|
||||
cities: Array.from(this.cities.entries()),
|
||||
@@ -782,6 +802,7 @@ export class InMemoryTurnWorld {
|
||||
delete this.worldConfig[key];
|
||||
}
|
||||
Object.assign(this.worldConfig, restored.worldConfig);
|
||||
this.generalPoolEntries = restored.generalPoolEntries;
|
||||
this.checkpoint = restored.checkpoint;
|
||||
this.replaceMap(this.generals, restored.generals);
|
||||
this.replaceMap(this.cities, restored.cities);
|
||||
@@ -1036,6 +1057,80 @@ export class InMemoryTurnWorld {
|
||||
}));
|
||||
}
|
||||
|
||||
listGeneralPoolEntries(): TurnGeneralPoolEntry[] | undefined {
|
||||
return this.generalPoolEntries?.map((entry) => ({
|
||||
...entry,
|
||||
reservedUntil: entry.reservedUntil ? new Date(entry.reservedUntil.getTime()) : null,
|
||||
candidate: structuredClone(entry.candidate),
|
||||
}));
|
||||
}
|
||||
|
||||
replaceGeneralPoolEntries(entries: readonly TurnGeneralPoolEntry[]): void {
|
||||
this.generalPoolEntries = entries.map((entry) => ({
|
||||
...entry,
|
||||
reservedUntil: entry.reservedUntil ? new Date(entry.reservedUntil.getTime()) : null,
|
||||
candidate: structuredClone(entry.candidate),
|
||||
}));
|
||||
}
|
||||
|
||||
listGeneralPoolCandidates(
|
||||
claimedAt: Date,
|
||||
claimedAtTick = this.dateToGameTick(claimedAt)
|
||||
): ScenarioGeneralPoolCandidate[] | undefined {
|
||||
if (!this.generalPoolEntries) {
|
||||
return undefined;
|
||||
}
|
||||
if (!Number.isSafeInteger(claimedAtTick)) {
|
||||
throw new Error(`General-pool claim tick must be a safe integer: ${claimedAtTick}`);
|
||||
}
|
||||
const claimsByEntryId = new Map<number, number>();
|
||||
const currentNames = new Set<string>();
|
||||
const prefixes: Partial<Record<number, string>> = {
|
||||
1: 'ⓝ',
|
||||
2: 'ⓝ',
|
||||
3: 'ⓜ',
|
||||
4: 'ⓖ',
|
||||
5: '㉥',
|
||||
6: 'ⓤ',
|
||||
9: 'ⓞ',
|
||||
};
|
||||
for (const general of this.generals.values()) {
|
||||
const claim = readScenarioGeneralPoolClaim(general.meta);
|
||||
if (claim) {
|
||||
claimsByEntryId.set(claim.poolEntryId, general.id);
|
||||
}
|
||||
const prefix = prefixes[general.npcState];
|
||||
currentNames.add(
|
||||
prefix && general.name.startsWith(prefix) ? general.name.slice(prefix.length) : general.name
|
||||
);
|
||||
}
|
||||
|
||||
return this.generalPoolEntries
|
||||
.filter((entry) => {
|
||||
const linkedGeneralCanBeReused =
|
||||
entry.generalId === null || this.deletedGeneralIds.has(entry.generalId);
|
||||
if (
|
||||
!linkedGeneralCanBeReused ||
|
||||
claimsByEntryId.has(entry.id) ||
|
||||
currentNames.has(entry.uniqueName) ||
|
||||
currentNames.has(entry.candidate.name)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const isUnreserved =
|
||||
entry.ownerUserId === null && entry.reservedUntil === null && entry.reservedUntilTick === null;
|
||||
// Ref compares integer GameClock ticks and keeps a reservation
|
||||
// valid at exact equality. Legacy rows without a tick fall
|
||||
// back to the projected Date column.
|
||||
const isExpired =
|
||||
entry.reservedUntilTick !== null
|
||||
? entry.reservedUntilTick < claimedAtTick
|
||||
: entry.reservedUntil !== null && entry.reservedUntil.getTime() < claimedAt.getTime();
|
||||
return isUnreserved || isExpired;
|
||||
})
|
||||
.map((entry) => structuredClone(entry.candidate));
|
||||
}
|
||||
|
||||
listCities(): City[] {
|
||||
return Array.from(this.cities.values()).map((city) => ({ ...city }));
|
||||
}
|
||||
@@ -1346,6 +1441,11 @@ export class InMemoryTurnWorld {
|
||||
for (const auction of this.pendingNeutralAuctions) {
|
||||
auction.closeAt = shiftDate(auction.closeAt);
|
||||
}
|
||||
for (const entry of this.generalPoolEntries ?? []) {
|
||||
if (entry.reservedUntil) {
|
||||
entry.reservedUntil = shiftDate(entry.reservedUntil);
|
||||
}
|
||||
}
|
||||
if (this.checkpoint) {
|
||||
const checkpointTime = shiftDate(new Date(this.checkpoint.turnTime));
|
||||
this.checkpoint = {
|
||||
@@ -1441,6 +1541,8 @@ export class InMemoryTurnWorld {
|
||||
|
||||
executeGeneralTurn(general: TurnGeneral): GeneralTurnExecution {
|
||||
const currentGeneral = this.generals.get(general.id) ?? general;
|
||||
const executionYear = this.state.currentYear;
|
||||
const executionMonth = this.state.currentMonth;
|
||||
const city = this.cities.get(currentGeneral.cityId);
|
||||
const nation = currentGeneral.nationId > 0 ? (this.nations.get(currentGeneral.nationId) ?? null) : null;
|
||||
|
||||
@@ -1490,10 +1592,17 @@ export class InMemoryTurnWorld {
|
||||
}
|
||||
if (result.logs && result.logs.length > 0) {
|
||||
// Ref command logs use the executing general's pre-advance turntime.
|
||||
// Preserve that per-entry occurrence time instead of replacing every
|
||||
// log in the transaction with the shared completion cursor at flush.
|
||||
// Preserve that per-entry occurrence time and calendar date instead
|
||||
// of replacing them with the shared post-boundary flush context.
|
||||
for (const log of result.logs) {
|
||||
this.pushLog(log, currentGeneral.turnTime);
|
||||
this.pushLog(
|
||||
{
|
||||
...log,
|
||||
year: log.year ?? executionYear,
|
||||
month: log.month ?? executionMonth,
|
||||
},
|
||||
currentGeneral.turnTime
|
||||
);
|
||||
}
|
||||
}
|
||||
if (result.messages && result.messages.length > 0) {
|
||||
|
||||
@@ -135,7 +135,10 @@ const resolveInheritConstants = (worldState: WorldStateRow): InheritConstants =>
|
||||
const isSelectionPoolWorld = (worldState: WorldStateRow): boolean => {
|
||||
const config = asRecord(worldState.config);
|
||||
const map = asRecord(config.map);
|
||||
return asNumber(config.npcMode, 0) === 2 && map.targetGeneralPool === 'SPoolUnderU30';
|
||||
return (
|
||||
asNumber(config.npcMode, 0) === 2 &&
|
||||
(map.targetGeneralPool === 'SPoolUnderU30' || map.targetGeneralPool === 'SPoolUnderU100')
|
||||
);
|
||||
};
|
||||
|
||||
const resolveMaxGeneral = (worldState: WorldStateRow): number => {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { asNumber, asRecord } from '@sammo-ts/common';
|
||||
import {
|
||||
CENTENNIAL_ALL_STAR_DEFAULT_DEX_LIMIT,
|
||||
CENTENNIAL_ALL_STAR_NPC_PROGRESS_MULTIPLIER,
|
||||
CENTENNIAL_ALL_STAR_POOL,
|
||||
LogCategory,
|
||||
LogFormat,
|
||||
LogScope,
|
||||
applyCentennialAllStarTarget,
|
||||
readCentennialAllStarAux,
|
||||
type CentennialAllStarRules,
|
||||
} from '@sammo-ts/logic';
|
||||
|
||||
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
||||
import type { MonthlyEventActionHandler } from './monthlyEventHandler.js';
|
||||
|
||||
const resolveRules = (world: InMemoryTurnWorld): CentennialAllStarRules => {
|
||||
const scenario = world.getScenarioConfig();
|
||||
const configConst = asRecord(scenario.const);
|
||||
return {
|
||||
defaultStatMin: scenario.stat.min,
|
||||
defaultStatMax: scenario.stat.max,
|
||||
defaultStatTotal: scenario.stat.total,
|
||||
maxStatLevel: asNumber(configConst.maxLevel, 255),
|
||||
defaultSpecialDomestic:
|
||||
typeof configConst.defaultSpecialDomestic === 'string' ? configConst.defaultSpecialDomestic : 'None',
|
||||
dexLimit: asNumber(configConst.dexLimit, CENTENNIAL_ALL_STAR_DEFAULT_DEX_LIMIT),
|
||||
};
|
||||
};
|
||||
|
||||
const resolveNpcDexTargetRatio = (world: InMemoryTurnWorld): number => {
|
||||
const value = asNumber(asRecord(world.getScenarioConfig().map).centennialNpcDexTargetRatio, 0.4);
|
||||
if (value < 0 || value > 1) {
|
||||
throw new Error('centennialNpcDexTargetRatio must be between 0 and 1');
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export const createAdvanceCentennialAllStarHandler = (options: {
|
||||
getWorld: () => InMemoryTurnWorld | null;
|
||||
}): MonthlyEventActionHandler => {
|
||||
return (_args, environment) => {
|
||||
const world = options.getWorld();
|
||||
if (!world || asRecord(world.getScenarioConfig().map).targetGeneralPool !== CENTENNIAL_ALL_STAR_POOL) {
|
||||
return;
|
||||
}
|
||||
const rules = resolveRules(world);
|
||||
const npcDexTargetRatio = resolveNpcDexTargetRatio(world);
|
||||
for (const general of world.listGenerals()) {
|
||||
const aux = readCentennialAllStarAux(general.meta as Record<string, unknown>);
|
||||
if (!aux) {
|
||||
continue;
|
||||
}
|
||||
const isGeneratedNpc = general.npcState === 3 || general.npcState === 4;
|
||||
const result = applyCentennialAllStarTarget(
|
||||
general,
|
||||
aux.target,
|
||||
{
|
||||
startYear: environment.startyear,
|
||||
year: environment.year,
|
||||
month: environment.month,
|
||||
},
|
||||
rules,
|
||||
isGeneratedNpc ? CENTENNIAL_ALL_STAR_NPC_PROGRESS_MULTIPLIER : 1,
|
||||
isGeneratedNpc ? npcDexTargetRatio : 1
|
||||
);
|
||||
// progressMonth 같은 aux 필드도 월마다 영속화해야 하므로 실제 수치
|
||||
// 변화가 없는 경우에도 Ref General::applyDB()와 같이 dirty 처리한다.
|
||||
world.updateGeneral(general.id, {
|
||||
stats: result.stats,
|
||||
role: result.role,
|
||||
meta: result.meta,
|
||||
});
|
||||
if (result.milestone <= result.previousMilestone) {
|
||||
continue;
|
||||
}
|
||||
const percent = result.milestone * 20;
|
||||
world.pushLog({
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.ACTION,
|
||||
generalId: general.id,
|
||||
text: `<L>올스타 동조율</>이 <C>${percent}%</>에 도달했습니다!`,
|
||||
format: LogFormat.PLAIN,
|
||||
year: environment.year,
|
||||
month: environment.month,
|
||||
});
|
||||
world.pushLog({
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.HISTORY,
|
||||
generalId: general.id,
|
||||
text: `<L>올스타 동조율 ${percent}% 달성</>`,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
year: environment.year,
|
||||
month: environment.month,
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -1,5 +1,19 @@
|
||||
import { GAME_TICKS_PER_TURN, JosaUtil, LiteHashDRBG, RandUtil, asRecord } from '@sammo-ts/common';
|
||||
import { LogCategory, LogFormat, LogScope, type TurnCommandEnv } from '@sammo-ts/logic';
|
||||
import {
|
||||
buildScenarioGeneralPoolClaimMeta,
|
||||
CENTENNIAL_ALL_STAR_NPC_PROGRESS_MULTIPLIER,
|
||||
LogCategory,
|
||||
LogFormat,
|
||||
LogScope,
|
||||
applyCentennialAllStarTarget,
|
||||
initializeCentennialGeneratedNpc,
|
||||
pickUniqueScenarioGeneralPoolCandidates,
|
||||
readCentennialAllStarPoolTarget,
|
||||
resolveCentennialAllStarRules,
|
||||
resolveCentennialNpcDexTargetRatio,
|
||||
type ScenarioGeneralPoolCandidate,
|
||||
type TurnCommandEnv,
|
||||
} from '@sammo-ts/logic';
|
||||
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
|
||||
|
||||
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
||||
@@ -113,19 +127,21 @@ const buildNpc = (options: {
|
||||
environment: MonthlyEventEnvironment;
|
||||
env: TurnCommandEnv;
|
||||
baseName: string;
|
||||
candidate?: ScenarioGeneralPoolCandidate;
|
||||
}): TurnGeneral => {
|
||||
const { world, reservedTurns, rng, environment, env } = options;
|
||||
const centennialTarget = readCentennialAllStarPoolTarget(options.candidate);
|
||||
const age = rng.nextRangeInt(20, 25);
|
||||
const bornYear = environment.year - age;
|
||||
const deadYear = environment.year + rng.nextRangeInt(10, 50);
|
||||
const stats = buildNpcStats(rng, env);
|
||||
const affinity = rng.nextRangeInt(1, 150);
|
||||
const stats = centennialTarget ? buildNpcStats(rng, env) : (options.candidate?.stats ?? buildNpcStats(rng, env));
|
||||
const affinity = options.candidate?.affinity ?? rng.nextRangeInt(1, 150);
|
||||
const relativeYear = Math.max(environment.year - environment.startyear, 0);
|
||||
const configValues = asRecord(world.getScenarioConfig().const);
|
||||
const retirementYear = readLegacyNumber(configValues.retirementYear, 80);
|
||||
const specAge = buildSpecialityAge(retirementYear, age, relativeYear, 12);
|
||||
const specAge2 = buildSpecialityAge(retirementYear, age, relativeYear, 6);
|
||||
const personality = rng.choice(env.availablePersonalities ?? ['che_안전']);
|
||||
const personality = options.candidate?.personality ?? rng.choice(env.availablePersonalities ?? ['che_안전']);
|
||||
const cities = world.listCities();
|
||||
if (cities.length === 0) {
|
||||
throw new Error('CreateManyNPC requires at least one city.');
|
||||
@@ -145,7 +161,7 @@ const buildNpc = (options: {
|
||||
const turnTime = world.gameTickToDate(turnTick);
|
||||
const killturn = (deadYear - environment.year) * 12 + rng.nextRangeInt(0, 11) + environment.month - 1;
|
||||
const id = world.getNextGeneralId();
|
||||
const general: TurnGeneral = {
|
||||
let general: TurnGeneral = {
|
||||
id,
|
||||
userId: null,
|
||||
name: `${NPC_NAME_PREFIX}${options.baseName}`,
|
||||
@@ -158,8 +174,12 @@ const buildNpc = (options: {
|
||||
officerLevel: 0,
|
||||
role: {
|
||||
personality,
|
||||
specialDomestic: env.defaultSpecialDomestic,
|
||||
specialWar: env.defaultSpecialWar,
|
||||
specialDomestic: centennialTarget
|
||||
? env.defaultSpecialDomestic
|
||||
: (options.candidate?.specialDomestic ?? env.defaultSpecialDomestic),
|
||||
specialWar: centennialTarget
|
||||
? env.defaultSpecialWar
|
||||
: (options.candidate?.specialWar ?? env.defaultSpecialWar),
|
||||
items: { horse: null, weapon: null, book: null, item: null },
|
||||
},
|
||||
injury: 0,
|
||||
@@ -174,7 +194,8 @@ const buildNpc = (options: {
|
||||
bornYear,
|
||||
deadYear,
|
||||
affinity,
|
||||
picture: 'default.jpg',
|
||||
picture: typeof options.candidate?.picture === 'string' ? options.candidate.picture : 'default.jpg',
|
||||
imageServer: options.candidate?.imageServer ?? 0,
|
||||
triggerState: {
|
||||
flags: {},
|
||||
counters: {},
|
||||
@@ -193,13 +214,32 @@ const buildNpc = (options: {
|
||||
dedlevel: 1,
|
||||
specage: specAge,
|
||||
specage2: specAge2,
|
||||
dex1: 0,
|
||||
dex2: 0,
|
||||
dex3: 0,
|
||||
dex4: 0,
|
||||
dex5: 0,
|
||||
dex1: centennialTarget ? 0 : (options.candidate?.dex?.[0] ?? 0),
|
||||
dex2: centennialTarget ? 0 : (options.candidate?.dex?.[1] ?? 0),
|
||||
dex3: centennialTarget ? 0 : (options.candidate?.dex?.[2] ?? 0),
|
||||
dex4: centennialTarget ? 0 : (options.candidate?.dex?.[3] ?? 0),
|
||||
dex5: centennialTarget ? 0 : (options.candidate?.dex?.[4] ?? 0),
|
||||
...(options.candidate ? buildScenarioGeneralPoolClaimMeta(options.candidate, environment.turnTime) : {}),
|
||||
},
|
||||
};
|
||||
if (centennialTarget) {
|
||||
const scenario = world.getScenarioConfig();
|
||||
const rules = resolveCentennialAllStarRules(scenario, env.defaultSpecialDomestic);
|
||||
const initialized = initializeCentennialGeneratedNpc(general, centennialTarget, rules);
|
||||
const growth = applyCentennialAllStarTarget(
|
||||
{ ...general, ...initialized },
|
||||
centennialTarget,
|
||||
{
|
||||
startYear: environment.startyear,
|
||||
year: environment.year,
|
||||
month: environment.month,
|
||||
},
|
||||
rules,
|
||||
CENTENNIAL_ALL_STAR_NPC_PROGRESS_MULTIPLIER,
|
||||
resolveCentennialNpcDexTargetRatio(scenario)
|
||||
);
|
||||
general = { ...general, stats: growth.stats, role: growth.role, meta: growth.meta };
|
||||
}
|
||||
if (!world.addGeneral(general)) {
|
||||
throw new Error(`CreateManyNPC generated a duplicate general id: ${id}`);
|
||||
}
|
||||
@@ -241,8 +281,15 @@ export const createCreateManyNpcHandler = (options: {
|
||||
simpleSerialize(resolveHiddenSeed(world), 'CreateManyNPC', environment.year, environment.month)
|
||||
)
|
||||
);
|
||||
const baseNames = pickNpcNames(rng, requestedCount, world.listGenerals(), options.env);
|
||||
const created = baseNames.map((baseName) =>
|
||||
const generalPool = world.listGeneralPoolCandidates(environment.turnTime);
|
||||
const candidates: Array<{ baseName: string; candidate?: ScenarioGeneralPoolCandidate }> =
|
||||
generalPool === undefined
|
||||
? pickNpcNames(rng, requestedCount, world.listGenerals(), options.env).map((baseName) => ({ baseName }))
|
||||
: pickUniqueScenarioGeneralPoolCandidates(rng, generalPool, requestedCount).map((candidate) => ({
|
||||
baseName: candidate.name,
|
||||
candidate,
|
||||
}));
|
||||
const created = candidates.map(({ baseName, candidate }) =>
|
||||
buildNpc({
|
||||
world,
|
||||
reservedTurns: options.reservedTurns,
|
||||
@@ -250,6 +297,7 @@ export const createCreateManyNpcHandler = (options: {
|
||||
environment,
|
||||
env: options.env,
|
||||
baseName,
|
||||
...(candidate ? { candidate } : {}),
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ export const MONTHLY_EVENT_ACTION_CATALOG = [
|
||||
'ProcessSemiAnnual',
|
||||
'ProcessWarIncome',
|
||||
'CreateAdminNPC',
|
||||
'AdvanceCentennialAllStar',
|
||||
'CreateManyNPC',
|
||||
'RegNPC',
|
||||
'RegNeutralNPC',
|
||||
@@ -233,8 +234,7 @@ export const createMonthlyEventHandler = (options: {
|
||||
// the previous monthly boundary even after turnDate() has advanced
|
||||
// year/month. Generated general turn times depend on this distinction.
|
||||
const legacyTurnTime =
|
||||
context.legacyTurnTime ??
|
||||
new Date(context.turnTime.getTime() - world.getState().tickSeconds * 1_000);
|
||||
context.legacyTurnTime ?? new Date(context.turnTime.getTime() - world.getState().tickSeconds * 1_000);
|
||||
|
||||
for (const event of world.listEvents(targetCode)) {
|
||||
const environment: MonthlyEventEnvironment = {
|
||||
|
||||
@@ -40,7 +40,11 @@ export const createOpenNationBettingHandler = (options: {
|
||||
.filter((nation) => nation.id > 0)
|
||||
.sort((left, right) => right.power - left.power)
|
||||
.map((nation) => {
|
||||
const generalCount = generals.filter((general) => general.nationId === nation.id).length;
|
||||
const storedGeneralCount = nation.meta.gennum;
|
||||
const generalCount =
|
||||
typeof storedGeneralCount === 'number' && Number.isFinite(storedGeneralCount)
|
||||
? storedGeneralCount
|
||||
: generals.filter((general) => general.nationId === nation.id && general.npcState !== 5).length;
|
||||
const cityCount = cities.filter((city) => city.nationId === nation.id).length;
|
||||
return {
|
||||
title: nation.name,
|
||||
@@ -150,7 +154,7 @@ export const createFinishNationBettingHandler = (options: {
|
||||
id: bettingId,
|
||||
winnerNationIds: world
|
||||
.listNations()
|
||||
.filter((nation) => nation.level > 0)
|
||||
.filter((nation) => nation.id > 0 && nation.level > 0)
|
||||
.map((nation) => nation.id),
|
||||
year: environment.year,
|
||||
month: environment.month,
|
||||
|
||||
@@ -4,10 +4,17 @@ import {
|
||||
LogFormat,
|
||||
LogScope,
|
||||
AVAILABLE_NATION_TRAIT_KEYS,
|
||||
CENTENNIAL_ALL_STAR_AUX_KEY,
|
||||
getCityDistance,
|
||||
buildScenarioGeneralPoolClaimMeta,
|
||||
initialCentennialAllStarAux,
|
||||
pickUniqueScenarioGeneralPoolCandidates,
|
||||
readCentennialAllStarPoolTarget,
|
||||
resolveCentennialAllStarRules,
|
||||
type City,
|
||||
type MapDefinition,
|
||||
type Nation,
|
||||
type ScenarioGeneralPoolCandidate,
|
||||
type TurnCommandEnv,
|
||||
} from '@sammo-ts/logic';
|
||||
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
|
||||
@@ -126,8 +133,10 @@ const createNpcGeneral = (options: {
|
||||
bornYear: number;
|
||||
deadYear: number;
|
||||
killturn?: number;
|
||||
candidate?: ScenarioGeneralPoolCandidate;
|
||||
}): TurnGeneral => {
|
||||
const { world, reservedTurns, rng, env, environment } = options;
|
||||
const centennialTarget = readCentennialAllStarPoolTarget(options.candidate);
|
||||
const stats = buildNpcStats(rng, env, STAT_TYPE_WEIGHTS);
|
||||
const affinity = rng.nextRangeInt(1, 150);
|
||||
const personality = rng.choice(env.availablePersonalities ?? ['che_안전']);
|
||||
@@ -152,12 +161,9 @@ const createNpcGeneral = (options: {
|
||||
const turnTime = world.gameTickToDate(turnTick);
|
||||
const killturn =
|
||||
options.killturn ??
|
||||
(options.deadYear - environment.year) * 12 +
|
||||
rng.nextRangeInt(0, 11) +
|
||||
environment.month -
|
||||
1;
|
||||
(options.deadYear - environment.year) * 12 + rng.nextRangeInt(0, 11) + environment.month - 1;
|
||||
const id = world.getNextGeneralId();
|
||||
const general: TurnGeneral = {
|
||||
let general: TurnGeneral = {
|
||||
id,
|
||||
userId: null,
|
||||
name: `${NPC_PREFIX}${options.baseName}`,
|
||||
@@ -170,8 +176,12 @@ const createNpcGeneral = (options: {
|
||||
officerLevel: options.officerLevel,
|
||||
role: {
|
||||
personality,
|
||||
specialDomestic: env.defaultSpecialDomestic,
|
||||
specialWar: env.defaultSpecialWar,
|
||||
specialDomestic: centennialTarget
|
||||
? env.defaultSpecialDomestic
|
||||
: (options.candidate?.specialDomestic ?? env.defaultSpecialDomestic),
|
||||
specialWar: centennialTarget
|
||||
? env.defaultSpecialWar
|
||||
: (options.candidate?.specialWar ?? env.defaultSpecialWar),
|
||||
items: { horse: null, weapon: null, book: null, item: null },
|
||||
},
|
||||
injury: 0,
|
||||
@@ -186,7 +196,8 @@ const createNpcGeneral = (options: {
|
||||
bornYear: options.bornYear,
|
||||
deadYear: options.deadYear,
|
||||
affinity,
|
||||
picture: 'default.jpg',
|
||||
picture: typeof options.candidate?.picture === 'string' ? options.candidate.picture : 'default.jpg',
|
||||
imageServer: options.candidate?.imageServer ?? 0,
|
||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
lastTurn: { command: '휴식' },
|
||||
turnTime,
|
||||
@@ -200,13 +211,23 @@ const createNpcGeneral = (options: {
|
||||
dedlevel: 1,
|
||||
specage: buildSpecialityAge(retirementYear, age, relativeYear, 12),
|
||||
specage2: buildSpecialityAge(retirementYear, age, relativeYear, 6),
|
||||
dex1: 0,
|
||||
dex2: 0,
|
||||
dex3: 0,
|
||||
dex4: 0,
|
||||
dex5: 0,
|
||||
dex1: centennialTarget ? 0 : (options.candidate?.dex?.[0] ?? 0),
|
||||
dex2: centennialTarget ? 0 : (options.candidate?.dex?.[1] ?? 0),
|
||||
dex3: centennialTarget ? 0 : (options.candidate?.dex?.[2] ?? 0),
|
||||
dex4: centennialTarget ? 0 : (options.candidate?.dex?.[3] ?? 0),
|
||||
dex5: centennialTarget ? 0 : (options.candidate?.dex?.[4] ?? 0),
|
||||
...(options.candidate ? buildScenarioGeneralPoolClaimMeta(options.candidate, environment.turnTime) : {}),
|
||||
},
|
||||
};
|
||||
if (centennialTarget) {
|
||||
const meta: TurnGeneral['meta'] = { ...general.meta };
|
||||
const mutableMeta: Record<string, unknown> = meta;
|
||||
mutableMeta[CENTENNIAL_ALL_STAR_AUX_KEY] = initialCentennialAllStarAux(
|
||||
centennialTarget,
|
||||
resolveCentennialAllStarRules(world.getScenarioConfig(), env.defaultSpecialDomestic)
|
||||
);
|
||||
general = { ...general, meta };
|
||||
}
|
||||
if (!world.addGeneral(general)) {
|
||||
throw new Error(`RaiseNPCNation generated duplicate general id ${id}.`);
|
||||
}
|
||||
@@ -268,8 +289,7 @@ export const createRaiseNpcNationHandler = (options: {
|
||||
);
|
||||
|
||||
const currentLast = world.getState().meta.lastNationId;
|
||||
const currentLastNumber =
|
||||
typeof currentLast === 'number' && Number.isFinite(currentLast) ? currentLast : 0;
|
||||
const currentLastNumber = typeof currentLast === 'number' && Number.isFinite(currentLast) ? currentLast : 0;
|
||||
const liveNationMax = world.listNations().reduce((maxId, nation) => Math.max(maxId, nation.id), 0);
|
||||
let resolvedLastNationId = Math.max(currentLastNumber, liveNationMax);
|
||||
const serverId = resolveServerId(world);
|
||||
@@ -342,17 +362,22 @@ export const createRaiseNpcNationHandler = (options: {
|
||||
deadYear: environment.year + 60,
|
||||
killturn: 240,
|
||||
});
|
||||
const subordinateNames = pickNpcNames(
|
||||
rng,
|
||||
Math.max(averageGeneralCount - 1, 0),
|
||||
world.listGenerals(),
|
||||
options.env
|
||||
);
|
||||
for (const baseName of subordinateNames) {
|
||||
const deadYear =
|
||||
environment.year +
|
||||
10 +
|
||||
Math.trunc(60 * (1 - Math.log2(rng.nextRange(1, 1024)) / 10));
|
||||
const subordinateCount = Math.max(averageGeneralCount - 1, 0);
|
||||
const generalPool = world.listGeneralPoolCandidates(environment.turnTime);
|
||||
const subordinateCandidates: Array<{
|
||||
baseName: string;
|
||||
candidate?: ScenarioGeneralPoolCandidate;
|
||||
}> =
|
||||
generalPool === undefined
|
||||
? pickNpcNames(rng, subordinateCount, world.listGenerals(), options.env).map((baseName) => ({
|
||||
baseName,
|
||||
}))
|
||||
: pickUniqueScenarioGeneralPoolCandidates(rng, generalPool, subordinateCount).map((candidate) => ({
|
||||
baseName: candidate.name,
|
||||
candidate,
|
||||
}));
|
||||
for (const { baseName, candidate } of subordinateCandidates) {
|
||||
const deadYear = environment.year + 10 + Math.trunc(60 * (1 - Math.log2(rng.nextRange(1, 1024)) / 10));
|
||||
createNpcGeneral({
|
||||
world,
|
||||
reservedTurns: options.reservedTurns,
|
||||
@@ -365,11 +390,12 @@ export const createRaiseNpcNationHandler = (options: {
|
||||
officerLevel: 1,
|
||||
bornYear: environment.year - 20,
|
||||
deadYear,
|
||||
...(candidate ? { candidate } : {}),
|
||||
});
|
||||
}
|
||||
world.updateNation(nationId, {
|
||||
chiefGeneralId: ruler.id,
|
||||
meta: { ...nation.meta, gennum: 1 + subordinateNames.length },
|
||||
meta: { ...nation.meta, gennum: 1 + subordinateCandidates.length },
|
||||
});
|
||||
options.reservedTurns.ensureNationTurns(nationId, 12);
|
||||
options.reservedTurns.ensureNationTurns(nationId, 11);
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
createItemModuleRegistry,
|
||||
loadItemModules,
|
||||
resolveUniqueConfig,
|
||||
readScenarioGeneralPoolClaim,
|
||||
rollUniqueLottery,
|
||||
getNextTurnAt,
|
||||
getBillByLevel,
|
||||
@@ -509,6 +510,7 @@ type WorldView = {
|
||||
listNations(): Nation[];
|
||||
listTroops(): Troop[];
|
||||
listDiplomacy(): TurnDiplomacy[];
|
||||
listGeneralPoolCandidates(claimedAt: Date): ReturnType<InMemoryTurnWorld['listGeneralPoolCandidates']>;
|
||||
};
|
||||
|
||||
const mergeStats = (base: TurnGeneral['stats'], patch: Partial<TurnGeneral['stats']>): TurnGeneral['stats'] => ({
|
||||
@@ -614,6 +616,20 @@ const createWorldOverlay = (world: InMemoryTurnWorld) => {
|
||||
...entry,
|
||||
meta: { ...entry.meta },
|
||||
})),
|
||||
listGeneralPoolCandidates: (claimedAt) => {
|
||||
const candidates = world.listGeneralPoolCandidates(claimedAt);
|
||||
if (!candidates) {
|
||||
return candidates;
|
||||
}
|
||||
const overlayClaimedIds = new Set<number>();
|
||||
for (const general of generalOverrides.values()) {
|
||||
const claim = readScenarioGeneralPoolClaim(general.meta);
|
||||
if (claim) {
|
||||
overlayClaimedIds.add(claim.poolEntryId);
|
||||
}
|
||||
}
|
||||
return candidates.filter((candidate) => !overlayClaimedIds.has(candidate.poolEntryId));
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -1080,6 +1096,7 @@ export const createReservedTurnHandler = async (options: {
|
||||
: {}),
|
||||
rng: actionRng,
|
||||
time: actionTime,
|
||||
maxTechLevel: env.maxTechLevel,
|
||||
uniqueLottery,
|
||||
};
|
||||
let specificContext = buildActionContext(
|
||||
@@ -1113,6 +1130,7 @@ export const createReservedTurnHandler = async (options: {
|
||||
nation: currentNation,
|
||||
rng: actionRng,
|
||||
time: actionTime,
|
||||
maxTechLevel: env.maxTechLevel,
|
||||
};
|
||||
specificContext = baseContext;
|
||||
}
|
||||
@@ -2418,6 +2436,7 @@ export const createImmediateGeneralActionExecutor = async (options: {
|
||||
month: state.currentMonth,
|
||||
startYear,
|
||||
},
|
||||
maxTechLevel: env.maxTechLevel,
|
||||
uniqueLottery,
|
||||
};
|
||||
const actionContext =
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { asNumber, asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||
import { asNumber, asRecord, GAME_TICKS_PER_TURN, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||
import { acquireGameSchemaAdvisoryXactLock, GamePrisma } from '@sammo-ts/infra';
|
||||
import {
|
||||
EventDomesticTraitLoader,
|
||||
@@ -10,14 +10,25 @@ import {
|
||||
LogCategory,
|
||||
LogScope,
|
||||
PERSONALITY_TRAIT_KEYS,
|
||||
CENTENNIAL_ALL_STAR_AUX_KEY,
|
||||
CENTENNIAL_ALL_STAR_DEFAULT_DEX_LIMIT,
|
||||
CENTENNIAL_ALL_STAR_POOL,
|
||||
applyCentennialAllStarTarget,
|
||||
calculateCentennialUserCurrentTargetStats,
|
||||
calculateCentennialUserInitialStats,
|
||||
buildScenarioGeneralPoolClaimMeta,
|
||||
initialCentennialAllStarAux,
|
||||
parseScenarioGeneralPoolCandidate,
|
||||
prepareCentennialLegacyUserReselection,
|
||||
simpleSerialize,
|
||||
WarTraitLoader,
|
||||
} from '@sammo-ts/logic';
|
||||
import type { CentennialAllStarEnvironment, CentennialAllStarRules, CentennialAllStarTarget } from '@sammo-ts/logic';
|
||||
|
||||
import type { DatabaseClient, GamePrisma as GamePrismaTypes } from '@sammo-ts/infra';
|
||||
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
||||
import { buildPrestartDeleteAfter } from './prestartDeletion.js';
|
||||
import type { TurnGeneral } from './types.js';
|
||||
import type { TurnGeneral, TurnGeneralPoolEntry } from './types.js';
|
||||
|
||||
type WorldStateRow = GamePrismaTypes.WorldStateGetPayload<Record<string, never>>;
|
||||
|
||||
@@ -33,7 +44,8 @@ export class SelectPoolError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
const SUPPORTED_POOL = 'SPoolUnderU30';
|
||||
const LEGACY_SELECTION_POOL = 'SPoolUnderU30';
|
||||
const SUPPORTED_POOLS = new Set([LEGACY_SELECTION_POOL, CENTENNIAL_ALL_STAR_POOL]);
|
||||
const RESERVATION_COUNT = 14;
|
||||
const RESERVATION_TURN_MULTIPLIER = 2;
|
||||
const RESELECTION_TURN_MULTIPLIER = 12;
|
||||
@@ -41,7 +53,6 @@ const DEFAULT_MAX_GENERAL = 500;
|
||||
const DEFAULT_CREW_TYPE_ID = 1100;
|
||||
const MAX_GENERAL_TURNS = 30;
|
||||
const DEFAULT_TURN_ACTION = '휴식';
|
||||
const LEGACY_TIMEZONE_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
|
||||
const zCandidateInfo = z.object({
|
||||
uniqueName: z.string().min(1),
|
||||
@@ -49,7 +60,7 @@ const zCandidateInfo = z.object({
|
||||
leadership: z.number().int(),
|
||||
strength: z.number().int(),
|
||||
intel: z.number().int(),
|
||||
specialDomestic: z.string().min(1),
|
||||
specialDomestic: z.string().min(1).nullable(),
|
||||
specialWar: z.string().min(1).optional(),
|
||||
ego: z.string().min(1).optional(),
|
||||
experience: z.number().int().optional(),
|
||||
@@ -67,6 +78,7 @@ interface SelectPoolRow {
|
||||
ownerUserId: string | null;
|
||||
generalId: number | null;
|
||||
reservedUntil: Date | null;
|
||||
reservedUntilTick: bigint | null;
|
||||
info: unknown;
|
||||
}
|
||||
|
||||
@@ -76,8 +88,8 @@ export interface SelectPoolCandidateDto {
|
||||
leadership: number;
|
||||
strength: number;
|
||||
intel: number;
|
||||
specialDomestic: string;
|
||||
specialDomesticName: string;
|
||||
specialDomestic: string | null;
|
||||
specialDomesticName: string | null;
|
||||
specialDomesticInfo: string;
|
||||
specialWar: string | null;
|
||||
specialWarName: string | null;
|
||||
@@ -89,7 +101,7 @@ export interface SelectPoolCandidateDto {
|
||||
}
|
||||
|
||||
export interface SelectPoolReservationDto {
|
||||
poolName: typeof SUPPORTED_POOL;
|
||||
poolName: string;
|
||||
hasGeneral: boolean;
|
||||
validUntil: string;
|
||||
candidates: SelectPoolCandidateDto[];
|
||||
@@ -120,7 +132,8 @@ const resolveTurnTermMinutes = (worldState: WorldStateRow): number => {
|
||||
|
||||
export const isSelectionPoolWorld = (worldState: WorldStateRow): boolean => {
|
||||
const config = asRecord(worldState.config);
|
||||
return asNumber(config.npcMode, 0) === 2 && resolvePoolName(worldState) === SUPPORTED_POOL;
|
||||
const poolName = resolvePoolName(worldState);
|
||||
return asNumber(config.npcMode, 0) === 2 && poolName !== null && SUPPORTED_POOLS.has(poolName);
|
||||
};
|
||||
|
||||
export const resolveSelectionMaxGeneral = (worldState: WorldStateRow): number => {
|
||||
@@ -158,16 +171,61 @@ const parseCandidate = (row: Pick<SelectPoolRow, 'uniqueName' | 'info'>): Select
|
||||
return candidate;
|
||||
};
|
||||
|
||||
const candidateWeight = (candidate: SelectPoolCandidateInfo): number =>
|
||||
candidate.dex.reduce((sum, value) => sum + value, 0);
|
||||
export const calculateSelectionCandidateWeight = (
|
||||
poolName: string,
|
||||
candidate: SelectPoolCandidateInfo,
|
||||
ownerIsUser: boolean
|
||||
): number => {
|
||||
const dexWeight = candidate.dex.reduce((sum, value) => sum + value, 0);
|
||||
if (poolName !== CENTENNIAL_ALL_STAR_POOL) {
|
||||
return dexWeight;
|
||||
}
|
||||
const eligibleDexWeight = Math.max(100_000, dexWeight);
|
||||
if (!ownerIsUser) {
|
||||
return eligibleDexWeight;
|
||||
}
|
||||
const statTotal = candidate.leadership + candidate.strength + candidate.intel;
|
||||
const normalizedStat = Math.min(1, Math.max(0, (statTotal - 160) / 30));
|
||||
return eligibleDexWeight * (1 + 0.5 * normalizedStat);
|
||||
};
|
||||
|
||||
const resolveCentennialEnvironment = (worldState: WorldStateRow): CentennialAllStarEnvironment => {
|
||||
const scenarioMeta = asRecord(asRecord(worldState.meta).scenarioMeta);
|
||||
return {
|
||||
startYear: Math.trunc(asNumber(scenarioMeta.startYear, worldState.currentYear)),
|
||||
year: worldState.currentYear,
|
||||
month: worldState.currentMonth,
|
||||
};
|
||||
};
|
||||
|
||||
const resolveCentennialRules = (worldState: WorldStateRow): CentennialAllStarRules => {
|
||||
const config = asRecord(worldState.config);
|
||||
const stat = asRecord(config.stat);
|
||||
const configConst = asRecord(config.const);
|
||||
const defaultSpecialDomestic = configConst.defaultSpecialDomestic;
|
||||
return {
|
||||
defaultStatMin: asNumber(stat.min, 15),
|
||||
defaultStatMax: asNumber(stat.max, 80),
|
||||
defaultStatTotal: asNumber(stat.total, 165),
|
||||
maxStatLevel: asNumber(configConst.maxLevel, 255),
|
||||
defaultSpecialDomestic: typeof defaultSpecialDomestic === 'string' ? defaultSpecialDomestic : 'None',
|
||||
dexLimit: asNumber(configConst.dexLimit, CENTENNIAL_ALL_STAR_DEFAULT_DEX_LIMIT),
|
||||
};
|
||||
};
|
||||
|
||||
const asCentennialTarget = (candidate: SelectPoolCandidateInfo): CentennialAllStarTarget => ({
|
||||
...candidate,
|
||||
specialDomestic: candidate.specialDomestic,
|
||||
});
|
||||
|
||||
const eventDomesticTraitLoader = new EventDomesticTraitLoader();
|
||||
const warTraitLoader = new WarTraitLoader();
|
||||
|
||||
const toCandidateDto = async (candidate: SelectPoolCandidateInfo): Promise<SelectPoolCandidateDto> => {
|
||||
const trait = isEventDomesticTraitKey(candidate.specialDomestic)
|
||||
? await eventDomesticTraitLoader.load(candidate.specialDomestic)
|
||||
: null;
|
||||
const trait =
|
||||
candidate.specialDomestic && isEventDomesticTraitKey(candidate.specialDomestic)
|
||||
? await eventDomesticTraitLoader.load(candidate.specialDomestic)
|
||||
: null;
|
||||
const warTrait =
|
||||
candidate.specialWar && isWarTraitKey(candidate.specialWar)
|
||||
? await warTraitLoader.load(candidate.specialWar)
|
||||
@@ -179,7 +237,7 @@ const toCandidateDto = async (candidate: SelectPoolCandidateInfo): Promise<Selec
|
||||
strength: candidate.strength,
|
||||
intel: candidate.intel,
|
||||
specialDomestic: candidate.specialDomestic,
|
||||
specialDomesticName: trait?.name ?? candidate.specialDomestic.replace(/^che_event_/, ''),
|
||||
specialDomesticName: trait?.name ?? candidate.specialDomestic?.replace(/^che_event_/, '') ?? null,
|
||||
specialDomesticInfo: trait?.info ?? '',
|
||||
specialWar: candidate.specialWar ?? null,
|
||||
specialWarName: warTrait?.name ?? candidate.specialWar?.replace(/^che_(?:event_)?/u, '') ?? null,
|
||||
@@ -192,35 +250,63 @@ const toCandidateDto = async (candidate: SelectPoolCandidateInfo): Promise<Selec
|
||||
};
|
||||
|
||||
const toReservationDto = (
|
||||
rows: Array<Pick<SelectPoolRow, 'id' | 'uniqueName' | 'reservedUntil' | 'info'>>,
|
||||
hasGeneral: boolean
|
||||
rows: Array<Pick<SelectPoolRow, 'id' | 'uniqueName' | 'reservedUntil' | 'reservedUntilTick' | 'info'>>,
|
||||
hasGeneral: boolean,
|
||||
worldState: WorldStateRow,
|
||||
world: InMemoryTurnWorld
|
||||
): Promise<SelectPoolReservationDto> => {
|
||||
const validUntil = rows[0]?.reservedUntil;
|
||||
if (!validUntil) {
|
||||
const first = rows[0];
|
||||
if (!first || (first.reservedUntilTick === null && first.reservedUntil === null)) {
|
||||
throw new SelectPoolError('INTERNAL_SERVER_ERROR', '장수 선택 후보의 유효기간이 없습니다.');
|
||||
}
|
||||
const expiresAt = validUntil;
|
||||
const expiresAt =
|
||||
first.reservedUntilTick === null
|
||||
? first.reservedUntil!
|
||||
: world.gameTickToDate(toSafeReservationTick(first.reservedUntilTick, first.uniqueName));
|
||||
const poolName = resolvePoolName(worldState);
|
||||
if (!poolName || !SUPPORTED_POOLS.has(poolName)) {
|
||||
throw new SelectPoolError('PRECONDITION_FAILED', '선택 가능한 서버가 아닙니다');
|
||||
}
|
||||
const centennialEnvironment = resolveCentennialEnvironment(worldState);
|
||||
const centennialRules = resolveCentennialRules(worldState);
|
||||
const sorted = rows
|
||||
.map((row) => ({ id: row.id, info: parseCandidate(row) }))
|
||||
.sort((left, right) => candidateWeight(left.info) - candidateWeight(right.info) || left.id - right.id);
|
||||
.map((row) => {
|
||||
const raw = parseCandidate(row);
|
||||
if (poolName !== CENTENNIAL_ALL_STAR_POOL) {
|
||||
return { id: row.id, info: raw };
|
||||
}
|
||||
const target = asCentennialTarget(raw);
|
||||
const display = hasGeneral
|
||||
? calculateCentennialUserCurrentTargetStats(target, centennialEnvironment, centennialRules)
|
||||
: calculateCentennialUserInitialStats(target, centennialRules);
|
||||
return {
|
||||
id: row.id,
|
||||
info: {
|
||||
...raw,
|
||||
leadership: display.leadership,
|
||||
strength: display.strength,
|
||||
intel: display.intel,
|
||||
},
|
||||
};
|
||||
})
|
||||
.sort(
|
||||
(left, right) =>
|
||||
left.info.dex.reduce((sum, value) => sum + value, 0) -
|
||||
right.info.dex.reduce((sum, value) => sum + value, 0) || left.id - right.id
|
||||
);
|
||||
return Promise.all(sorted.map((entry) => toCandidateDto(entry.info))).then((candidates) => ({
|
||||
poolName: SUPPORTED_POOL,
|
||||
poolName,
|
||||
hasGeneral,
|
||||
validUntil: expiresAt.toISOString(),
|
||||
candidates,
|
||||
}));
|
||||
};
|
||||
|
||||
const formatLegacySeedTime = (value: Date): string => {
|
||||
const pad = (part: number): string => String(part).padStart(2, '0');
|
||||
const koreaTime = new Date(value.getTime() + LEGACY_TIMEZONE_OFFSET_MS);
|
||||
return `${koreaTime.getUTCFullYear()}-${pad(koreaTime.getUTCMonth() + 1)}-${pad(
|
||||
koreaTime.getUTCDate()
|
||||
)} ${pad(koreaTime.getUTCHours())}:${pad(koreaTime.getUTCMinutes())}:${pad(koreaTime.getUTCSeconds())}`;
|
||||
};
|
||||
|
||||
export const buildSelectPoolSeed = (hiddenSeed: string | number, ownerIdentity: string | number, now: Date): string =>
|
||||
simpleSerialize(hiddenSeed, 'selectPool', ownerIdentity, formatLegacySeedTime(now));
|
||||
export const buildSelectPoolSeed = (
|
||||
hiddenSeed: string | number,
|
||||
ownerIdentity: string | number,
|
||||
nowTick: number
|
||||
): string => simpleSerialize(hiddenSeed, 'selectPool', ownerIdentity, nowTick);
|
||||
|
||||
export const claimWeightedSelectionCandidates = async <T extends { id: number }>(options: {
|
||||
weighted: [T, number][];
|
||||
@@ -265,6 +351,27 @@ const getWorldHiddenSeed = (worldState: WorldStateRow): string | number => {
|
||||
: fail('INTERNAL_SERVER_ERROR', '장수 선택 비밀 seed가 설정되지 않았습니다.');
|
||||
};
|
||||
|
||||
const toSafeReservationTick = (value: bigint | number, uniqueName: string): number => {
|
||||
const tick = Number(value);
|
||||
if (!Number.isSafeInteger(tick)) {
|
||||
fail('INTERNAL_SERVER_ERROR', `장수 선택 후보 ${uniqueName}의 예약 tick이 안전한 정수 범위를 벗어났습니다.`);
|
||||
}
|
||||
return tick;
|
||||
};
|
||||
|
||||
const resolveAcceptedGameTick = (world: InMemoryTurnWorld, now: Date): number => {
|
||||
const tick = world.dateToGameTick(now);
|
||||
if (!Number.isSafeInteger(tick)) {
|
||||
fail('INTERNAL_SERVER_ERROR', '장수 선택 예약 tick이 안전한 정수 범위를 벗어났습니다.');
|
||||
}
|
||||
return tick;
|
||||
};
|
||||
|
||||
const isReservationActive = (row: SelectPoolRow, now: Date, nowTick: number): boolean =>
|
||||
row.reservedUntilTick !== null
|
||||
? toSafeReservationTick(row.reservedUntilTick, row.uniqueName) >= nowTick
|
||||
: row.reservedUntil !== null && row.reservedUntil.getTime() >= now.getTime();
|
||||
|
||||
const lockSelectionUser = async (db: DatabaseClient, userId: string): Promise<void> => {
|
||||
await acquireGameSchemaAdvisoryXactLock(db, `select-pool:user:${userId}`);
|
||||
};
|
||||
@@ -273,14 +380,18 @@ const requireSelectionToken = async (
|
||||
db: DatabaseClient,
|
||||
userId: string,
|
||||
uniqueName: string,
|
||||
now: Date
|
||||
now: Date,
|
||||
nowTick: number
|
||||
): Promise<SelectPoolRow> => {
|
||||
const token = await db.selectPoolEntry.findFirst({
|
||||
where: {
|
||||
ownerUserId: userId,
|
||||
uniqueName,
|
||||
reservedUntil: { gte: now },
|
||||
generalId: null,
|
||||
OR: [
|
||||
{ reservedUntilTick: { gte: BigInt(nowTick) } },
|
||||
{ reservedUntilTick: null, reservedUntil: { gte: now } },
|
||||
],
|
||||
},
|
||||
});
|
||||
if (!token) {
|
||||
@@ -289,17 +400,44 @@ const requireSelectionToken = async (
|
||||
return token as SelectPoolRow;
|
||||
};
|
||||
|
||||
const mapSelectionPoolRow = (row: SelectPoolRow): TurnGeneralPoolEntry => ({
|
||||
id: row.id,
|
||||
uniqueName: row.uniqueName,
|
||||
ownerUserId: row.ownerUserId,
|
||||
generalId: row.generalId,
|
||||
reservedUntil: row.reservedUntil ? new Date(row.reservedUntil.getTime()) : null,
|
||||
reservedUntilTick:
|
||||
row.reservedUntilTick === null ? null : toSafeReservationTick(row.reservedUntilTick, row.uniqueName),
|
||||
candidate: parseScenarioGeneralPoolCandidate({ id: row.id, uniqueName: row.uniqueName, info: row.info }),
|
||||
});
|
||||
|
||||
const synchronizeSelectionPoolWorld = async (
|
||||
db: DatabaseClient,
|
||||
world: InMemoryTurnWorld
|
||||
): Promise<SelectPoolRow[]> => {
|
||||
const rows = (await db.selectPoolEntry.findMany({ orderBy: { id: 'asc' } })) as SelectPoolRow[];
|
||||
world.replaceGeneralPoolEntries(rows.map(mapSelectionPoolRow));
|
||||
return rows;
|
||||
};
|
||||
|
||||
export const reserveSelectionPool = async (options: {
|
||||
db: DatabaseClient;
|
||||
world: InMemoryTurnWorld;
|
||||
worldState: WorldStateRow;
|
||||
userId: string;
|
||||
now?: Date;
|
||||
acceptedGameTick?: number;
|
||||
seedOwnerIdentity?: string | number;
|
||||
}): Promise<SelectPoolReservationDto> => {
|
||||
const { db, worldState, userId } = options;
|
||||
const { db, world, worldState, userId } = options;
|
||||
requirePoolWorld(worldState);
|
||||
const now = options.now ?? new Date();
|
||||
const acceptedGameTick = options.acceptedGameTick ?? resolveAcceptedGameTick(world, now);
|
||||
if (!Number.isSafeInteger(acceptedGameTick)) {
|
||||
fail('INTERNAL_SERVER_ERROR', '장수 선택 예약 tick이 안전한 정수 범위를 벗어났습니다.');
|
||||
}
|
||||
await lockSelectionUser(db, userId);
|
||||
await lockSelectionMutationTables(db);
|
||||
const general = await db.general.findFirst({
|
||||
where: { userId },
|
||||
select: { id: true, meta: true },
|
||||
@@ -309,77 +447,94 @@ export const reserveSelectionPool = async (options: {
|
||||
fail('PRECONDITION_FAILED', '아직 다시 고를 수 없습니다');
|
||||
}
|
||||
|
||||
const existing = await db.selectPoolEntry.findMany({
|
||||
where: {
|
||||
ownerUserId: userId,
|
||||
reservedUntil: { gte: now },
|
||||
generalId: null,
|
||||
},
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
let currentRows = await synchronizeSelectionPoolWorld(db, world);
|
||||
const existing = currentRows.filter(
|
||||
(row) => row.ownerUserId === userId && row.generalId === null && isReservationActive(row, now, acceptedGameTick)
|
||||
);
|
||||
if (existing.length > 0) {
|
||||
return toReservationDto(existing as SelectPoolRow[], Boolean(general));
|
||||
return toReservationDto(existing, Boolean(general), worldState, world);
|
||||
}
|
||||
|
||||
await db.selectPoolEntry.updateMany({
|
||||
where: {
|
||||
reservedUntil: { lt: now },
|
||||
generalId: null,
|
||||
OR: [
|
||||
{ reservedUntilTick: { lt: BigInt(acceptedGameTick) } },
|
||||
{ reservedUntilTick: null, reservedUntil: { lt: now } },
|
||||
],
|
||||
},
|
||||
data: {
|
||||
ownerUserId: null,
|
||||
reservedUntil: null,
|
||||
reservedUntilTick: null,
|
||||
},
|
||||
});
|
||||
|
||||
const available = (await db.selectPoolEntry.findMany({
|
||||
where: {
|
||||
ownerUserId: null,
|
||||
reservedUntil: null,
|
||||
generalId: null,
|
||||
},
|
||||
orderBy: { id: 'asc' },
|
||||
})) as SelectPoolRow[];
|
||||
currentRows = await synchronizeSelectionPoolWorld(db, world);
|
||||
const availableIds = new Set(
|
||||
world.listGeneralPoolCandidates(now, acceptedGameTick)?.map((candidate) => candidate.poolEntryId) ?? []
|
||||
);
|
||||
const available = currentRows.filter(
|
||||
(row) =>
|
||||
availableIds.has(row.id) &&
|
||||
row.ownerUserId === null &&
|
||||
row.reservedUntil === null &&
|
||||
row.reservedUntilTick === null &&
|
||||
row.generalId === null
|
||||
);
|
||||
if (available.length < RESERVATION_COUNT) {
|
||||
fail('PRECONDITION_FAILED', 'pool 부족');
|
||||
}
|
||||
|
||||
const rng = new RandUtil(
|
||||
new LiteHashDRBG(buildSelectPoolSeed(getWorldHiddenSeed(worldState), options.seedOwnerIdentity ?? userId, now))
|
||||
new LiteHashDRBG(
|
||||
buildSelectPoolSeed(getWorldHiddenSeed(worldState), options.seedOwnerIdentity ?? userId, acceptedGameTick)
|
||||
)
|
||||
);
|
||||
const weighted = available.map((row) => [row, candidateWeight(parseCandidate(row))] as [SelectPoolRow, number]);
|
||||
const reservedUntil = new Date(
|
||||
now.getTime() + resolveTurnTermMinutes(worldState) * RESERVATION_TURN_MULTIPLIER * 60_000
|
||||
const poolName = resolvePoolName(worldState)!;
|
||||
const weighted = available.map(
|
||||
(row) =>
|
||||
[row, calculateSelectionCandidateWeight(poolName, parseCandidate(row), true)] as [SelectPoolRow, number]
|
||||
);
|
||||
const reservedUntilTick = acceptedGameTick + RESERVATION_TURN_MULTIPLIER * GAME_TICKS_PER_TURN;
|
||||
if (!Number.isSafeInteger(reservedUntilTick)) {
|
||||
fail('INTERNAL_SERVER_ERROR', '장수 선택 예약 tick이 안전한 정수 범위를 벗어났습니다.');
|
||||
}
|
||||
const reservedUntil = world.gameTickToDate(reservedUntilTick);
|
||||
const selected = await claimWeightedSelectionCandidates({
|
||||
weighted,
|
||||
rng,
|
||||
count: RESERVATION_COUNT,
|
||||
claim: async (candidate) => {
|
||||
const claimed = await db.selectPoolEntry.updateMany({
|
||||
where: {
|
||||
id: candidate.id,
|
||||
ownerUserId: null,
|
||||
reservedUntil: null,
|
||||
generalId: null,
|
||||
},
|
||||
data: {
|
||||
ownerUserId: userId,
|
||||
reservedUntil,
|
||||
},
|
||||
});
|
||||
return claimed.count > 0;
|
||||
},
|
||||
claim: async () => true,
|
||||
});
|
||||
const reserved = selected.map((candidate) => ({
|
||||
...candidate,
|
||||
ownerUserId: userId,
|
||||
reservedUntil,
|
||||
reservedUntilTick: BigInt(reservedUntilTick),
|
||||
}));
|
||||
if (reserved.length !== RESERVATION_COUNT) {
|
||||
fail('CONFLICT', '장수 선택 후보를 예약하지 못했습니다. 다시 시도해 주세요.');
|
||||
}
|
||||
return toReservationDto(reserved, Boolean(general));
|
||||
const reservation = await toReservationDto(reserved, Boolean(general), worldState, world);
|
||||
const claimed = await db.selectPoolEntry.updateMany({
|
||||
where: {
|
||||
id: { in: selected.map((candidate) => candidate.id) },
|
||||
ownerUserId: null,
|
||||
reservedUntil: null,
|
||||
reservedUntilTick: null,
|
||||
generalId: null,
|
||||
},
|
||||
data: {
|
||||
ownerUserId: userId,
|
||||
reservedUntil,
|
||||
reservedUntilTick: BigInt(reservedUntilTick),
|
||||
},
|
||||
});
|
||||
if (claimed.count !== RESERVATION_COUNT) {
|
||||
throw new Error('장수 선택 후보의 DB 점유 수가 턴 데몬 선택 결과와 일치하지 않습니다.');
|
||||
}
|
||||
await synchronizeSelectionPoolWorld(db, world);
|
||||
return reservation;
|
||||
};
|
||||
|
||||
const lockSelectionMutationTables = async (db: DatabaseClient): Promise<void> => {
|
||||
@@ -403,15 +558,25 @@ const assertGeneralIdSnapshotMatches = async (db: DatabaseClient, world: InMemor
|
||||
}
|
||||
};
|
||||
|
||||
const clearUnusedReservations = async (db: DatabaseClient, userId: string, now: Date): Promise<void> => {
|
||||
const clearUnusedReservations = async (
|
||||
db: DatabaseClient,
|
||||
userId: string,
|
||||
now: Date,
|
||||
nowTick: number
|
||||
): Promise<void> => {
|
||||
await db.selectPoolEntry.updateMany({
|
||||
where: {
|
||||
generalId: null,
|
||||
OR: [{ ownerUserId: userId }, { reservedUntil: { lt: now } }],
|
||||
OR: [
|
||||
{ ownerUserId: userId },
|
||||
{ reservedUntilTick: { lt: BigInt(nowTick) } },
|
||||
{ reservedUntilTick: null, reservedUntil: { lt: now } },
|
||||
],
|
||||
},
|
||||
data: {
|
||||
ownerUserId: null,
|
||||
reservedUntil: null,
|
||||
reservedUntilTick: null,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -533,8 +698,10 @@ export const createGeneralFromSelectionPool = async (options: {
|
||||
const { db, world, worldState, userId, ownerDisplayName, uniqueName } = options;
|
||||
requirePoolWorld(worldState);
|
||||
const now = options.now ?? new Date();
|
||||
const nowTick = resolveAcceptedGameTick(world, now);
|
||||
await lockSelectionUser(db, userId);
|
||||
await lockSelectionMutationTables(db);
|
||||
await synchronizeSelectionPoolWorld(db, world);
|
||||
await assertGeneralIdSnapshotMatches(db, world);
|
||||
if (
|
||||
world.listGenerals().some((general) => general.userId === userId) ||
|
||||
@@ -542,8 +709,15 @@ export const createGeneralFromSelectionPool = async (options: {
|
||||
) {
|
||||
fail('PRECONDITION_FAILED', '이미 장수를 생성했습니다.');
|
||||
}
|
||||
const token = await requireSelectionToken(db, userId, uniqueName, now);
|
||||
const token = await requireSelectionToken(db, userId, uniqueName, now, nowTick);
|
||||
const info = parseCandidate(token);
|
||||
const poolName = resolvePoolName(worldState)!;
|
||||
const isCentennial = poolName === CENTENNIAL_ALL_STAR_POOL;
|
||||
const centennialTarget = isCentennial ? asCentennialTarget(info) : null;
|
||||
const centennialRules = resolveCentennialRules(worldState);
|
||||
const centennialInitialStats = centennialTarget
|
||||
? calculateCentennialUserInitialStats(centennialTarget, centennialRules)
|
||||
: null;
|
||||
|
||||
const config = asRecord(worldState.config);
|
||||
const configConst = asRecord(config.const);
|
||||
@@ -556,9 +730,22 @@ export const createGeneralFromSelectionPool = async (options: {
|
||||
const seedOwnerIdentity = options.seedOwnerIdentity ?? userId;
|
||||
const rng = resolvePoolRng(worldState, seedOwnerIdentity, uniqueName);
|
||||
const affinity = rng.nextRangeInt(1, 150);
|
||||
const cities = await db.city.findMany({ select: { id: true, name: true }, orderBy: { id: 'asc' } });
|
||||
const allCities = await db.city.findMany({
|
||||
select: { id: true, name: true, level: true, nationId: true },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
const centennialCities = allCities.filter((city) => city.level >= 5 && city.level <= 6);
|
||||
const neutralCentennialCities = centennialCities.filter((city) => city.nationId === 0);
|
||||
const cities = isCentennial
|
||||
? neutralCentennialCities.length > 0
|
||||
? neutralCentennialCities
|
||||
: centennialCities
|
||||
: allCities;
|
||||
if (cities.length === 0) {
|
||||
fail('PRECONDITION_FAILED', '생성 가능한 도시가 없습니다.');
|
||||
fail(
|
||||
'PRECONDITION_FAILED',
|
||||
isCentennial ? '장수를 생성할 소·중성이 없습니다.' : '생성 가능한 도시가 없습니다.'
|
||||
);
|
||||
}
|
||||
const city = rng.choice(cities);
|
||||
const turnTime = buildInitialTurnTime(rng, worldState, now);
|
||||
@@ -575,12 +762,45 @@ export const createGeneralFromSelectionPool = async (options: {
|
||||
const imageServer = useOwnerPicture ? (options.ownerImageServer ?? 1) : info.imgsvr;
|
||||
const defaultSpecialWar =
|
||||
typeof configConst.defaultSpecialWar === 'string' ? configConst.defaultSpecialWar : 'None';
|
||||
const defaultSpecialDomestic =
|
||||
typeof configConst.defaultSpecialDomestic === 'string' ? configConst.defaultSpecialDomestic : 'None';
|
||||
const personality = resolveSelectedPersonality(worldState, seedOwnerIdentity, uniqueName, options.personality);
|
||||
// 모든 사용자 입력과 DB 선조건을 검증한 뒤에만 allocator를 변경한다.
|
||||
// SelectPoolError는 정상 command 결과로 commit되므로 이보다 먼저
|
||||
// getNextGeneralId()를 호출하면 실패한 요청도 lastGeneralId를 소비한다.
|
||||
const generalId = world.getNextGeneralId();
|
||||
|
||||
const generalMeta: TurnGeneral['meta'] = {
|
||||
createdBy: 'select_pool',
|
||||
ownerName: ownerDisplayName,
|
||||
owner_name: ownerDisplayName,
|
||||
killturn: 5,
|
||||
specage: specialityAges.domestic,
|
||||
specage2: specialityAges.war,
|
||||
dex1: isCentennial ? 0 : info.dex[0],
|
||||
dex2: isCentennial ? 0 : info.dex[1],
|
||||
dex3: isCentennial ? 0 : info.dex[2],
|
||||
dex4: isCentennial ? 0 : info.dex[3],
|
||||
dex5: isCentennial ? 0 : info.dex[4],
|
||||
next_change: nextChangeAt.toISOString(),
|
||||
nextChangeAt: nextChangeAt.toISOString(),
|
||||
prestart_delete_after: prestartDeleteAfter.toISOString(),
|
||||
...(useOwnerPicture && options.ownerIconRevision ? { accountIconUpdatedAt: options.ownerIconRevision } : {}),
|
||||
npc_org: 0,
|
||||
...buildScenarioGeneralPoolClaimMeta(
|
||||
parseScenarioGeneralPoolCandidate({ id: token.id, uniqueName: token.uniqueName, info: token.info }),
|
||||
now
|
||||
),
|
||||
};
|
||||
if (centennialTarget && centennialInitialStats) {
|
||||
const mutableMeta: Record<string, unknown> = generalMeta;
|
||||
mutableMeta[CENTENNIAL_ALL_STAR_AUX_KEY] = initialCentennialAllStarAux(
|
||||
centennialTarget,
|
||||
centennialRules,
|
||||
centennialInitialStats
|
||||
);
|
||||
}
|
||||
|
||||
const general: TurnGeneral = {
|
||||
id: generalId,
|
||||
userId,
|
||||
@@ -595,9 +815,9 @@ export const createGeneralFromSelectionPool = async (options: {
|
||||
picture,
|
||||
imageServer,
|
||||
stats: {
|
||||
leadership: info.leadership,
|
||||
strength: info.strength,
|
||||
intelligence: info.intel,
|
||||
leadership: centennialInitialStats?.leadership ?? info.leadership,
|
||||
strength: centennialInitialStats?.strength ?? info.strength,
|
||||
intelligence: centennialInitialStats?.intel ?? info.intel,
|
||||
},
|
||||
experience: info.experience ?? age * 100,
|
||||
dedication: info.dedication ?? age * 100,
|
||||
@@ -614,8 +834,8 @@ export const createGeneralFromSelectionPool = async (options: {
|
||||
startAge: age,
|
||||
role: {
|
||||
personality,
|
||||
specialDomestic: info.specialDomestic,
|
||||
specialWar: info.specialWar ?? defaultSpecialWar,
|
||||
specialDomestic: isCentennial ? defaultSpecialDomestic : info.specialDomestic,
|
||||
specialWar: isCentennial ? defaultSpecialWar : (info.specialWar ?? defaultSpecialWar),
|
||||
items: {
|
||||
horse: null,
|
||||
weapon: null,
|
||||
@@ -632,26 +852,7 @@ export const createGeneralFromSelectionPool = async (options: {
|
||||
lastTurn: { command: DEFAULT_TURN_ACTION },
|
||||
penalty: {},
|
||||
refreshScoreTotal: 0,
|
||||
meta: {
|
||||
createdBy: 'select_pool',
|
||||
ownerName: ownerDisplayName,
|
||||
owner_name: ownerDisplayName,
|
||||
killturn: 5,
|
||||
specage: specialityAges.domestic,
|
||||
specage2: specialityAges.war,
|
||||
dex1: info.dex[0],
|
||||
dex2: info.dex[1],
|
||||
dex3: info.dex[2],
|
||||
dex4: info.dex[3],
|
||||
dex5: info.dex[4],
|
||||
next_change: nextChangeAt.toISOString(),
|
||||
nextChangeAt: nextChangeAt.toISOString(),
|
||||
prestart_delete_after: prestartDeleteAfter.toISOString(),
|
||||
...(useOwnerPicture && options.ownerIconRevision
|
||||
? { accountIconUpdatedAt: options.ownerIconRevision }
|
||||
: {}),
|
||||
npc_org: 0,
|
||||
},
|
||||
meta: generalMeta,
|
||||
};
|
||||
if (!world.addGeneral(general)) {
|
||||
throw new Error(`장수 번호 ${generalId}를 할당할 수 없습니다.`);
|
||||
@@ -674,13 +875,17 @@ export const createGeneralFromSelectionPool = async (options: {
|
||||
where: {
|
||||
id: token.id,
|
||||
ownerUserId: userId,
|
||||
reservedUntil: { gte: now },
|
||||
generalId: null,
|
||||
OR: [
|
||||
{ reservedUntilTick: { gte: BigInt(nowTick) } },
|
||||
{ reservedUntilTick: null, reservedUntil: { gte: now } },
|
||||
],
|
||||
},
|
||||
data: {
|
||||
generalId,
|
||||
ownerUserId: null,
|
||||
reservedUntil: null,
|
||||
reservedUntilTick: null,
|
||||
},
|
||||
});
|
||||
if (occupied.count === 0) {
|
||||
@@ -691,7 +896,8 @@ export const createGeneralFromSelectionPool = async (options: {
|
||||
update: { userId, lastRefresh: now },
|
||||
create: { generalId, userId, lastRefresh: now },
|
||||
});
|
||||
await clearUnusedReservations(db, userId, now);
|
||||
await clearUnusedReservations(db, userId, now, nowTick);
|
||||
await synchronizeSelectionPoolWorld(db, world);
|
||||
|
||||
const ownerJosaYi = JosaUtil.pick(ownerDisplayName, '이');
|
||||
const generalJosaRo = JosaUtil.pick(info.generalName, '로');
|
||||
@@ -718,8 +924,10 @@ export const reselectGeneralFromSelectionPool = async (options: {
|
||||
const { db, world, worldState, userId, ownerDisplayName, uniqueName } = options;
|
||||
requirePoolWorld(worldState);
|
||||
const now = options.now ?? new Date();
|
||||
const nowTick = resolveAcceptedGameTick(world, now);
|
||||
await lockSelectionUser(db, userId);
|
||||
await lockSelectionMutationTables(db);
|
||||
await synchronizeSelectionPoolWorld(db, world);
|
||||
const persistedGeneral = await db.general.findFirst({ where: { userId } });
|
||||
const general = world.listGenerals().find((candidate) => candidate.userId === userId);
|
||||
if (!persistedGeneral || !general) {
|
||||
@@ -735,21 +943,26 @@ export const reselectGeneralFromSelectionPool = async (options: {
|
||||
if (nextChangeAt && nextChangeAt.getTime() > now.getTime()) {
|
||||
fail('PRECONDITION_FAILED', '아직 다시 고를 수 없습니다');
|
||||
}
|
||||
const token = await requireSelectionToken(db, userId, uniqueName, now);
|
||||
const token = await requireSelectionToken(db, userId, uniqueName, now, nowTick);
|
||||
const info = parseCandidate(token);
|
||||
const isCentennial = resolvePoolName(worldState) === CENTENNIAL_ALL_STAR_POOL;
|
||||
|
||||
const provisionalGeneralId = -general.id;
|
||||
const claimed = await db.selectPoolEntry.updateMany({
|
||||
where: {
|
||||
id: token.id,
|
||||
ownerUserId: userId,
|
||||
reservedUntil: { gte: now },
|
||||
generalId: null,
|
||||
OR: [
|
||||
{ reservedUntilTick: { gte: BigInt(nowTick) } },
|
||||
{ reservedUntilTick: null, reservedUntil: { gte: now } },
|
||||
],
|
||||
},
|
||||
data: {
|
||||
generalId: provisionalGeneralId,
|
||||
ownerUserId: null,
|
||||
reservedUntil: null,
|
||||
reservedUntilTick: null,
|
||||
},
|
||||
});
|
||||
if (claimed.count === 0) {
|
||||
@@ -757,7 +970,7 @@ export const reselectGeneralFromSelectionPool = async (options: {
|
||||
}
|
||||
await db.selectPoolEntry.updateMany({
|
||||
where: { generalId: general.id },
|
||||
data: { generalId: null, ownerUserId: null, reservedUntil: null },
|
||||
data: { generalId: null, ownerUserId: null, reservedUntil: null, reservedUntilTick: null },
|
||||
});
|
||||
const finalized = await db.selectPoolEntry.updateMany({
|
||||
where: {
|
||||
@@ -772,30 +985,51 @@ export const reselectGeneralFromSelectionPool = async (options: {
|
||||
throw new Error('장수 재선택 중 선택 후보 확정에 실패했습니다.');
|
||||
}
|
||||
|
||||
const currentMeta = asRecord(general.meta);
|
||||
const cooldown = new Date(
|
||||
now.getTime() + resolveTurnTermMinutes(worldState) * RESELECTION_TURN_MULTIPLIER * 60_000
|
||||
);
|
||||
const updatedMeta = {
|
||||
...currentMeta,
|
||||
const centennialBaseGeneral = isCentennial
|
||||
? {
|
||||
...general,
|
||||
meta: prepareCentennialLegacyUserReselection(general, resolveCentennialRules(worldState)),
|
||||
}
|
||||
: general;
|
||||
const centennialGrowth = isCentennial
|
||||
? applyCentennialAllStarTarget(
|
||||
centennialBaseGeneral,
|
||||
asCentennialTarget(info),
|
||||
resolveCentennialEnvironment(worldState),
|
||||
resolveCentennialRules(worldState)
|
||||
)
|
||||
: null;
|
||||
const updatedMeta: TurnGeneral['meta'] = {
|
||||
...(centennialGrowth?.meta ?? general.meta),
|
||||
ownerName: ownerDisplayName,
|
||||
owner_name: ownerDisplayName,
|
||||
dex1: info.dex[0],
|
||||
dex2: info.dex[1],
|
||||
dex3: info.dex[2],
|
||||
dex4: info.dex[3],
|
||||
dex5: info.dex[4],
|
||||
...(!isCentennial
|
||||
? {
|
||||
dex1: info.dex[0],
|
||||
dex2: info.dex[1],
|
||||
dex3: info.dex[2],
|
||||
dex4: info.dex[3],
|
||||
dex5: info.dex[4],
|
||||
}
|
||||
: {}),
|
||||
next_change: cooldown.toISOString(),
|
||||
nextChangeAt: cooldown.toISOString(),
|
||||
...buildScenarioGeneralPoolClaimMeta(
|
||||
parseScenarioGeneralPoolCandidate({ id: token.id, uniqueName: token.uniqueName, info: token.info }),
|
||||
now
|
||||
),
|
||||
};
|
||||
const updated = world.updateGeneral(general.id, {
|
||||
name: info.generalName,
|
||||
stats: {
|
||||
stats: centennialGrowth?.stats ?? {
|
||||
leadership: info.leadership,
|
||||
strength: info.strength,
|
||||
intelligence: info.intel,
|
||||
},
|
||||
role: {
|
||||
role: centennialGrowth?.role ?? {
|
||||
...general.role,
|
||||
personality: info.ego ?? general.role.personality,
|
||||
specialDomestic: info.specialDomestic,
|
||||
@@ -803,12 +1037,13 @@ export const reselectGeneralFromSelectionPool = async (options: {
|
||||
},
|
||||
picture: info.picture,
|
||||
imageServer: info.imgsvr,
|
||||
meta: updatedMeta as unknown as TurnGeneral['meta'],
|
||||
meta: updatedMeta,
|
||||
});
|
||||
if (!updated) {
|
||||
throw new Error('턴 데몬에서 장수 정보를 갱신하지 못했습니다.');
|
||||
}
|
||||
await clearUnusedReservations(db, userId, now);
|
||||
await clearUnusedReservations(db, userId, now, nowTick);
|
||||
await synchronizeSelectionPoolWorld(db, world);
|
||||
|
||||
const ownerJosaYi = JosaUtil.pick(ownerDisplayName, '이');
|
||||
const generalJosaRo = JosaUtil.pick(info.generalName, '로');
|
||||
|
||||
@@ -53,7 +53,7 @@ import { createAuctionBidder } from '../auction/bidder.js';
|
||||
import { createNeutralAuctionRegistrar } from '../auction/neutralRegistrar.js';
|
||||
import { createTournamentRewardFinalizer } from '../tournament/finalizer.js';
|
||||
import { createTournamentAutoStartHandler } from './tournamentAutoStart.js';
|
||||
import { createYearbookHandler } from './yearbookHandler.js';
|
||||
import { createDynastyStatisticsHandler, createYearbookHandler } from './yearbookHandler.js';
|
||||
import {
|
||||
createMonthlyEventHandler,
|
||||
createRandomizeCityTradeRateHandler,
|
||||
@@ -79,6 +79,7 @@ import { createFinishNationBettingHandler, createOpenNationBettingHandler } from
|
||||
import { createScoutBlockHandler } from './monthlyScoutBlockAction.js';
|
||||
import { createAddGlobalBetrayHandler, createAssignGeneralSpecialityHandler } from './monthlySpecialityBetrayAction.js';
|
||||
import { createLostUniqueItemHandler, createMergeInheritPointRankHandler } from './monthlyUniqueInheritAction.js';
|
||||
import { createAdvanceCentennialAllStarHandler } from './monthlyCentennialAllStarAction.js';
|
||||
import {
|
||||
createNewYearHandler,
|
||||
createNoticeToHistoryLogHandler,
|
||||
@@ -386,6 +387,7 @@ const createMonthlyEventActions = (options: {
|
||||
})
|
||||
);
|
||||
eventActions.set('MergeInheritPointRank', createMergeInheritPointRankHandler({ getWorld: options.getWorld }));
|
||||
eventActions.set('AdvanceCentennialAllStar', createAdvanceCentennialAllStarHandler({ getWorld: options.getWorld }));
|
||||
eventActions.set('ProcessIncome', createProcessIncomeActionHandler(options.incomeHandler));
|
||||
eventActions.set('NoticeToHistoryLog', createNoticeToHistoryLogHandler({ getWorld: options.getWorld }));
|
||||
eventActions.set('NewYear', createNewYearHandler({ getWorld: options.getWorld }));
|
||||
@@ -464,6 +466,7 @@ const createMonthlyCalendarRuntime = async (options: {
|
||||
now: () => options.getWorld()?.getGameNow(new Date(options.clock.nowMs())) ?? new Date(options.clock.nowMs()),
|
||||
});
|
||||
const calendarHandler = composeCalendarHandlers(
|
||||
createDynastyStatisticsHandler({ getWorld: options.getWorld }).handler,
|
||||
options.monthlyEventHandler,
|
||||
options.hasEventAction('ProcessIncome') ? null : options.incomeHandler,
|
||||
createYearbookHandler({ profileName: options.profileName, getWorld: options.getWorld }).handler,
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
Nation,
|
||||
ScenarioConfig,
|
||||
ScenarioMeta,
|
||||
ScenarioGeneralPoolCandidate,
|
||||
Troop,
|
||||
UnitSetDefinition,
|
||||
WorldSnapshot,
|
||||
@@ -62,6 +63,16 @@ export interface TurnEvent {
|
||||
meta: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TurnGeneralPoolEntry {
|
||||
id: number;
|
||||
uniqueName: string;
|
||||
ownerUserId: string | null;
|
||||
generalId: number | null;
|
||||
reservedUntil: Date | null;
|
||||
reservedUntilTick: number | null;
|
||||
candidate: ScenarioGeneralPoolCandidate;
|
||||
}
|
||||
|
||||
export interface PendingNeutralAuction {
|
||||
registrationKey: string;
|
||||
type: 'BUY_RICE' | 'SELL_RICE';
|
||||
@@ -152,6 +163,7 @@ export interface TurnWorldSnapshot extends Omit<
|
||||
diplomacy: TurnDiplomacy[];
|
||||
events: TurnEvent[];
|
||||
initialEvents: TurnEvent[];
|
||||
generalPoolEntries?: TurnGeneralPoolEntry[];
|
||||
generals: TurnGeneral[];
|
||||
cities: City[];
|
||||
nations: Nation[];
|
||||
|
||||
@@ -3,7 +3,7 @@ import { LogCategory, LogFormat, LogScope, type LogEntryDraft } from '@sammo-ts/
|
||||
|
||||
import type { InMemoryTurnWorld, TurnCalendarContext, TurnCalendarHandler } from './inMemoryWorld.js';
|
||||
import type { PendingUnificationAuctionCancellation } from './types.js';
|
||||
import { queueYearbookSnapshot } from './yearbookHandler.js';
|
||||
import { queueYearbookSnapshot, updateDynastyStatistics } from './yearbookHandler.js';
|
||||
|
||||
const UNIFIER_POINT = 2000;
|
||||
const INVADER_MESSAGE_OPTIONS = [
|
||||
@@ -67,6 +67,7 @@ export const createUnificationHandler = (options: {
|
||||
if (cities.length === 0 || cities.some((city) => city.nationId !== winner.id)) return;
|
||||
|
||||
const serverId = resolveServerId(world, options.profileName);
|
||||
updateDynastyStatistics(world);
|
||||
world.pushLog(buildNationHistoryLog(winner.id, winner.name));
|
||||
|
||||
const auctionCancellations = (await options.loadPendingUniqueAuctions?.()) ?? [];
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
} from './scenarioStaticEvents.js';
|
||||
import {
|
||||
createGeneralFromSelectionPool,
|
||||
reserveSelectionPool,
|
||||
reselectGeneralFromSelectionPool,
|
||||
SelectPoolError,
|
||||
} from './selectPoolService.js';
|
||||
@@ -158,6 +159,7 @@ const resolveCommandAcceptedAt = async (
|
||||
| 'ensureDieOnPrestartStatus'
|
||||
| 'joinCreateGeneral'
|
||||
| 'npcPossessGeneral'
|
||||
| 'selectPoolReserve'
|
||||
| 'selectPoolCreate'
|
||||
| 'selectPoolReselect'
|
||||
| 'adjustGeneralIcon';
|
||||
@@ -183,6 +185,21 @@ const resolveCommandAcceptedAt = async (
|
||||
return event.createdAt;
|
||||
};
|
||||
|
||||
const resolveSelectionCommandAcceptedAt = async (
|
||||
db: DatabaseClient,
|
||||
world: InMemoryTurnWorld,
|
||||
command: Extract<TurnDaemonCommand, { type: 'selectPoolReserve' | 'selectPoolCreate' | 'selectPoolReselect' }>
|
||||
): Promise<Date> => {
|
||||
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
|
||||
if (command.acceptedGameTick !== undefined) {
|
||||
return world.gameTickToDate(command.acceptedGameTick);
|
||||
}
|
||||
if (command.acceptedGameAt !== undefined) {
|
||||
return new Date(command.acceptedGameAt);
|
||||
}
|
||||
return world.getGameNow(operationalAcceptedAt);
|
||||
};
|
||||
|
||||
const resolveOperationalAcceptedAt = async (
|
||||
db: DatabaseClient,
|
||||
command: Pick<TurnDaemonCommand, 'type' | 'requestId'>
|
||||
@@ -347,8 +364,7 @@ async function handleSelectPoolCreate(
|
||||
if (!worldState) {
|
||||
throw new Error('Selection-pool world state is missing.');
|
||||
}
|
||||
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
|
||||
const acceptedAt = ctx.world.getGameNow(operationalAcceptedAt);
|
||||
const acceptedAt = await resolveSelectionCommandAcceptedAt(db, ctx.world, command);
|
||||
try {
|
||||
return {
|
||||
type: 'selectPoolCreate',
|
||||
@@ -380,6 +396,45 @@ async function handleSelectPoolCreate(
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSelectPoolReserve(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'selectPoolReserve' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
const db = requireCommandDatabase(ctx);
|
||||
const worldState = await db.worldState.findUnique({
|
||||
where: { id: ctx.world.getState().id },
|
||||
});
|
||||
if (!worldState) {
|
||||
throw new Error('Selection-pool world state is missing.');
|
||||
}
|
||||
const acceptedAt = await resolveSelectionCommandAcceptedAt(db, ctx.world, command);
|
||||
try {
|
||||
return {
|
||||
type: 'selectPoolReserve',
|
||||
ok: true,
|
||||
reservation: await reserveSelectionPool({
|
||||
db,
|
||||
world: ctx.world,
|
||||
worldState,
|
||||
userId: command.userId,
|
||||
seedOwnerIdentity: command.seedOwnerIdentity,
|
||||
now: acceptedAt,
|
||||
...(command.acceptedGameTick === undefined ? {} : { acceptedGameTick: command.acceptedGameTick }),
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof SelectPoolError) {
|
||||
return {
|
||||
type: 'selectPoolReserve',
|
||||
ok: false,
|
||||
code: error.code,
|
||||
reason: error.message,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSelectPoolReselect(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'selectPoolReselect' }>
|
||||
@@ -391,8 +446,7 @@ async function handleSelectPoolReselect(
|
||||
if (!worldState) {
|
||||
throw new Error('Selection-pool world state is missing.');
|
||||
}
|
||||
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
|
||||
const acceptedAt = ctx.world.getGameNow(operationalAcceptedAt);
|
||||
const acceptedAt = await resolveSelectionCommandAcceptedAt(db, ctx.world, command);
|
||||
try {
|
||||
return {
|
||||
type: 'selectPoolReselect',
|
||||
@@ -420,7 +474,10 @@ async function handleSelectPoolReselect(
|
||||
}
|
||||
|
||||
interface AuctionFinalizer {
|
||||
finalize(auctionId: number, db?: GamePrisma.TransactionClient): Promise<TurnDaemonCommandResult>;
|
||||
finalize(
|
||||
command: Extract<TurnDaemonCommand, { type: 'auctionFinalize' }>,
|
||||
db?: GamePrisma.TransactionClient
|
||||
): Promise<TurnDaemonCommandResult>;
|
||||
}
|
||||
|
||||
interface AuctionBidder {
|
||||
@@ -1780,7 +1837,7 @@ async function handleAuctionFinalize(
|
||||
reason: '경매 확정기가 준비되지 않았습니다.',
|
||||
};
|
||||
}
|
||||
return ctx.auctionFinalizer.finalize(command.auctionId, ctx.commandDb);
|
||||
return ctx.auctionFinalizer.finalize(command, ctx.commandDb);
|
||||
}
|
||||
|
||||
async function handleAuctionOpen(
|
||||
@@ -2277,49 +2334,57 @@ async function handleTournamentBettingPayout(
|
||||
};
|
||||
}
|
||||
|
||||
const tournamentName = ['전력전', '통솔전', '일기토', '설전'][command.tournamentType ?? -1] ?? '대회';
|
||||
const payoutsByGeneral = new Map<number, number>();
|
||||
for (const payout of command.payouts) {
|
||||
if (
|
||||
!payout ||
|
||||
typeof payout.generalId !== 'number' ||
|
||||
typeof payout.amount !== 'number' ||
|
||||
payout.amount <= 0
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
payoutsByGeneral.set(payout.generalId, (payoutsByGeneral.get(payout.generalId) ?? 0) + payout.amount);
|
||||
}
|
||||
|
||||
let processed = 0;
|
||||
let missing = 0;
|
||||
let totalPayout = 0;
|
||||
const metaDeltas = new Map<number, { betwin: number; betwingold: number }>();
|
||||
|
||||
for (const payout of command.payouts) {
|
||||
if (!payout || typeof payout.generalId !== 'number' || typeof payout.amount !== 'number') {
|
||||
continue;
|
||||
}
|
||||
if (payout.amount <= 0) {
|
||||
continue;
|
||||
}
|
||||
const general = world.getGeneralById(payout.generalId);
|
||||
for (const [generalId, amount] of payoutsByGeneral) {
|
||||
const general = world.getGeneralById(generalId);
|
||||
if (!general) {
|
||||
missing += 1;
|
||||
continue;
|
||||
}
|
||||
world.updateGeneral(payout.generalId, {
|
||||
gold: general.gold + payout.amount,
|
||||
const shouldRecordRank =
|
||||
general.npcState === 0 ||
|
||||
(general.npcState === 1 &&
|
||||
typeof general.meta.betgold === 'number' &&
|
||||
Number.isFinite(general.meta.betgold) &&
|
||||
general.meta.betgold > 0);
|
||||
const nextMeta = { ...general.meta } as TurnGeneral['meta'];
|
||||
if (shouldRecordRank) {
|
||||
const currentBetwin = typeof nextMeta.betwin === 'number' ? Number(nextMeta.betwin) : 0;
|
||||
const currentBetwingold = typeof nextMeta.betwingold === 'number' ? Number(nextMeta.betwingold) : 0;
|
||||
nextMeta.betwin = currentBetwin + 1;
|
||||
nextMeta.betwingold = currentBetwingold + amount;
|
||||
}
|
||||
world.updateGeneral(generalId, {
|
||||
gold: general.gold + amount,
|
||||
...(shouldRecordRank ? { meta: nextMeta } : {}),
|
||||
});
|
||||
processed += 1;
|
||||
totalPayout += payout.amount;
|
||||
const currentDelta = metaDeltas.get(payout.generalId) ?? { betwin: 0, betwingold: 0 };
|
||||
metaDeltas.set(payout.generalId, {
|
||||
betwin: currentDelta.betwin + 1,
|
||||
betwingold: currentDelta.betwingold + payout.amount,
|
||||
totalPayout += amount;
|
||||
world.pushLog({
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.EVENT_PLAIN,
|
||||
generalId,
|
||||
text: `<C>${tournamentName}</>의 베팅 당첨 보상으로 <C>${amount.toLocaleString('ko-KR')}</>의 <S>금</> 획득!`,
|
||||
meta: {},
|
||||
});
|
||||
}
|
||||
|
||||
for (const [generalId, delta] of metaDeltas) {
|
||||
const general = world.getGeneralById(generalId);
|
||||
if (!general) {
|
||||
continue;
|
||||
}
|
||||
const nextMeta = { ...general.meta } as TurnGeneral['meta'];
|
||||
const betwinKey = 'betwin';
|
||||
const betwingoldKey = 'betwingold';
|
||||
const currentBetwin = typeof nextMeta[betwinKey] === 'number' ? Number(nextMeta[betwinKey]) : 0;
|
||||
const currentBetwingold = typeof nextMeta[betwingoldKey] === 'number' ? Number(nextMeta[betwingoldKey]) : 0;
|
||||
nextMeta[betwinKey] = currentBetwin + delta.betwin;
|
||||
nextMeta[betwingoldKey] = currentBetwingold + delta.betwingold;
|
||||
world.updateGeneral(generalId, { meta: nextMeta });
|
||||
}
|
||||
return {
|
||||
type: 'tournamentBettingPayout',
|
||||
ok: true,
|
||||
@@ -2346,7 +2411,147 @@ async function handleTournamentReward(
|
||||
return ctx.tournamentRewardFinalizer.finalize(command, ctx.commandDb);
|
||||
}
|
||||
|
||||
// 설문 보상은 API에서 전달된 RNG 결과를 재검증한 뒤 월드에 반영한다.
|
||||
type VoteSelectionPersistenceResult = 'inserted' | 'existing-match' | 'existing-mismatch' | 'missing';
|
||||
|
||||
const normalizePersistedVoteSelection = (value: unknown): number[] | null => {
|
||||
let parsed = value;
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
parsed = JSON.parse(value) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(parsed)) return null;
|
||||
if (parsed.some((entry) => typeof entry !== 'number' || !Number.isInteger(entry))) return null;
|
||||
const normalized = [...parsed].sort((left, right) => left - right);
|
||||
return new Set(normalized).size === normalized.length ? normalized : null;
|
||||
};
|
||||
|
||||
const voteSelectionsMatch = (stored: unknown, submitted: number[]): boolean => {
|
||||
const normalized = normalizePersistedVoteSelection(stored);
|
||||
return (
|
||||
normalized !== null &&
|
||||
normalized.length === submitted.length &&
|
||||
normalized.every((value, index) => value === submitted[index])
|
||||
);
|
||||
};
|
||||
|
||||
const readExistingVoteSelection = async (
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'voteReward' }>,
|
||||
selection: number[]
|
||||
): Promise<Exclude<VoteSelectionPersistenceResult, 'inserted'>> => {
|
||||
if (!ctx.commandDb) return 'missing';
|
||||
const rows = await ctx.commandDb.$queryRaw<Array<{ selection: unknown }>>(GamePrisma.sql`
|
||||
SELECT selection
|
||||
FROM vote
|
||||
WHERE vote_id = ${command.voteId}
|
||||
AND general_id = ${command.generalId}
|
||||
FOR UPDATE
|
||||
`);
|
||||
if (!rows[0]) return 'missing';
|
||||
return voteSelectionsMatch(rows[0].selection, selection) ? 'existing-match' : 'existing-mismatch';
|
||||
};
|
||||
|
||||
const insertVoteSelection = async (
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'voteReward' }>,
|
||||
general: TurnGeneral,
|
||||
selection: number[]
|
||||
): Promise<VoteSelectionPersistenceResult> => {
|
||||
if (!ctx.commandDb) {
|
||||
return 'missing';
|
||||
}
|
||||
const rows = await ctx.commandDb.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
|
||||
INSERT INTO vote (vote_id, general_id, nation_id, selection)
|
||||
SELECT poll.id,
|
||||
${general.id},
|
||||
${general.nationId},
|
||||
CAST(${JSON.stringify(selection)} AS jsonb)
|
||||
FROM vote_poll poll
|
||||
WHERE poll.id = ${command.voteId}
|
||||
ON CONFLICT (vote_id, general_id) DO NOTHING
|
||||
RETURNING id
|
||||
`);
|
||||
if (rows[0]?.id) return 'inserted';
|
||||
return readExistingVoteSelection(ctx, command, selection);
|
||||
};
|
||||
|
||||
type VotePollValidationRow = {
|
||||
options: unknown;
|
||||
multipleOptions: number;
|
||||
endAt: Date | null;
|
||||
endTick: bigint | number | string | null;
|
||||
closedAt: Date | null;
|
||||
};
|
||||
|
||||
export const hasVotePollDeadlinePassed = (
|
||||
poll: Pick<VotePollValidationRow, 'endAt' | 'endTick' | 'closedAt'>,
|
||||
acceptedGameAt: Date,
|
||||
acceptedGameTick: number
|
||||
): boolean => {
|
||||
const endTick =
|
||||
poll.endTick === null ? null : typeof poll.endTick === 'bigint' ? poll.endTick : BigInt(poll.endTick);
|
||||
return (
|
||||
poll.closedAt !== null ||
|
||||
(endTick !== null
|
||||
? endTick < BigInt(acceptedGameTick)
|
||||
: Boolean(poll.endAt && poll.endAt.getTime() < acceptedGameAt.getTime()))
|
||||
);
|
||||
};
|
||||
|
||||
const parseVoteOptionCount = (value: unknown): number => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((entry) => typeof entry === 'string').length;
|
||||
}
|
||||
if (typeof value !== 'string') return 0;
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
return Array.isArray(parsed) ? parsed.filter((entry) => typeof entry === 'string').length : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
const validateVoteSelectionInTransaction = async (
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'voteReward' }>,
|
||||
selection: number[]
|
||||
): Promise<string | null> => {
|
||||
if (!ctx.commandDb) return '설문 응답 트랜잭션이 준비되지 않았습니다.';
|
||||
const rows = await ctx.commandDb.$queryRaw<VotePollValidationRow[]>(GamePrisma.sql`
|
||||
SELECT options,
|
||||
multiple_options AS "multipleOptions",
|
||||
end_at AS "endAt",
|
||||
end_tick AS "endTick",
|
||||
closed_at AS "closedAt"
|
||||
FROM vote_poll
|
||||
WHERE id = ${command.voteId}
|
||||
FOR UPDATE
|
||||
`);
|
||||
const poll = rows[0];
|
||||
if (!poll) return '설문조사가 없습니다.';
|
||||
|
||||
const processingNow = ctx.world.getGameNow(new Date());
|
||||
const acceptedGameTick = command.acceptedGameTick ?? ctx.world.dateToGameTick(processingNow);
|
||||
const acceptedGameAt =
|
||||
command.acceptedGameTick === undefined ? processingNow : ctx.world.gameTickToDate(command.acceptedGameTick);
|
||||
if (hasVotePollDeadlinePassed(poll, acceptedGameAt, acceptedGameTick)) {
|
||||
return '설문조사가 종료되었습니다.';
|
||||
}
|
||||
|
||||
const optionCount = parseVoteOptionCount(poll.options);
|
||||
if (selection.some((value) => value < 0 || value >= optionCount)) {
|
||||
return '선택한 항목이 없습니다.';
|
||||
}
|
||||
if (poll.multipleOptions >= 1 && selection.length > poll.multipleOptions) {
|
||||
return '선택한 항목이 너무 많습니다.';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// 설문 응답과 보상은 같은 ENGINE mutation transaction에서 확정한다.
|
||||
async function handleVoteReward(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'voteReward' }>
|
||||
@@ -2367,7 +2572,8 @@ async function handleVoteReward(
|
||||
const metaRecord = asRecord(baseMeta);
|
||||
const existingRewards = asRecord(metaRecord.voteRewards);
|
||||
const rewardKey = String(command.voteId);
|
||||
if (Object.prototype.hasOwnProperty.call(existingRewards, rewardKey)) {
|
||||
const hasExistingReward = Object.prototype.hasOwnProperty.call(existingRewards, rewardKey);
|
||||
const buildAlreadyAppliedResult = (): TurnDaemonCommandResult => {
|
||||
const existingValue = existingRewards[rewardKey];
|
||||
const existingEntry = asRecord(existingValue);
|
||||
const existingItemKey = typeof existingEntry.itemKey === 'string' ? existingEntry.itemKey : null;
|
||||
@@ -2381,6 +2587,71 @@ async function handleVoteReward(
|
||||
itemKey: existingItemKey ?? null,
|
||||
alreadyApplied: true,
|
||||
};
|
||||
};
|
||||
|
||||
const sortedSelection = [...command.selection].sort((a, b) => a - b);
|
||||
if (
|
||||
sortedSelection.length === 0 ||
|
||||
sortedSelection.some((value) => !Number.isInteger(value)) ||
|
||||
new Set(sortedSelection).size !== sortedSelection.length
|
||||
) {
|
||||
return {
|
||||
type: 'voteReward',
|
||||
ok: false,
|
||||
voteId: command.voteId,
|
||||
generalId: command.generalId,
|
||||
reason: '선택한 항목이 올바르지 않습니다.',
|
||||
};
|
||||
}
|
||||
if (!ctx.commandDb) {
|
||||
return {
|
||||
type: 'voteReward',
|
||||
ok: false,
|
||||
voteId: command.voteId,
|
||||
generalId: command.generalId,
|
||||
reason: '설문 응답 트랜잭션이 준비되지 않았습니다.',
|
||||
};
|
||||
}
|
||||
if (hasExistingReward) {
|
||||
// The reward marker is the idempotency authority. Do not invent a vote
|
||||
// row from a later retry when the original selection is unavailable.
|
||||
return buildAlreadyAppliedResult();
|
||||
}
|
||||
|
||||
const selectionError = await validateVoteSelectionInTransaction(ctx, command, sortedSelection);
|
||||
let votePersistence: VoteSelectionPersistenceResult | null = null;
|
||||
if (selectionError === '설문조사가 종료되었습니다.') {
|
||||
// Before vote+reward became one ENGINE transaction, the API could
|
||||
// persist the vote and fail before the reward command committed. A
|
||||
// matching locked row is sufficient evidence to repair that partial
|
||||
// even when the poll has since closed.
|
||||
votePersistence = await readExistingVoteSelection(ctx, command, sortedSelection);
|
||||
if (votePersistence === 'existing-mismatch') {
|
||||
return {
|
||||
type: 'voteReward',
|
||||
ok: false,
|
||||
voteId: command.voteId,
|
||||
generalId: command.generalId,
|
||||
reason: '이미 설문조사를 완료하였습니다.',
|
||||
};
|
||||
}
|
||||
if (votePersistence !== 'existing-match') {
|
||||
return {
|
||||
type: 'voteReward',
|
||||
ok: false,
|
||||
voteId: command.voteId,
|
||||
generalId: command.generalId,
|
||||
reason: selectionError,
|
||||
};
|
||||
}
|
||||
} else if (selectionError) {
|
||||
return {
|
||||
type: 'voteReward',
|
||||
ok: false,
|
||||
voteId: command.voteId,
|
||||
generalId: command.generalId,
|
||||
reason: selectionError,
|
||||
};
|
||||
}
|
||||
|
||||
const worldState = world.getState();
|
||||
@@ -2391,9 +2662,20 @@ async function handleVoteReward(
|
||||
const initMonth = readMetaNumber(worldMeta, 'initMonth', 1);
|
||||
const scenarioId = readMetaNumber(worldMeta, 'scenarioId', 0);
|
||||
const hiddenSeed = worldMeta.hiddenSeed ?? worldMeta.seed ?? worldState.id;
|
||||
const configConst = asRecord(world.getScenarioConfig().const);
|
||||
const configuredDevelCost = readMetaNumber(
|
||||
configConst,
|
||||
'develCost',
|
||||
readMetaNumber(configConst, 'develcost', readMetaNumber(configConst, 'develrate', 0))
|
||||
);
|
||||
const voteReward =
|
||||
readMetaNumber(
|
||||
worldMeta,
|
||||
'develcost',
|
||||
readMetaNumber(worldMeta, 'develCost', readMetaNumber(worldMeta, 'develrate', configuredDevelCost))
|
||||
) * 5;
|
||||
|
||||
const itemRegistry = await getItemRegistry();
|
||||
const configConst = asRecord(world.getScenarioConfig().const);
|
||||
const uniqueConfig = resolveUniqueConfig(configConst);
|
||||
const generals = world.listGenerals();
|
||||
const occupiedUniqueCounts = countOccupiedUniqueItems(
|
||||
@@ -2438,34 +2720,27 @@ async function handleVoteReward(
|
||||
acquireType: '설문조사',
|
||||
});
|
||||
|
||||
const expectedUnique = command.unique?.expected ?? false;
|
||||
const expectedItemKey = command.unique?.itemKey ?? null;
|
||||
if (expectedUnique !== Boolean(itemKey)) {
|
||||
const awardedItemModule = itemKey ? itemRegistry.get(itemKey) : null;
|
||||
if (itemKey && !awardedItemModule) {
|
||||
return {
|
||||
type: 'voteReward',
|
||||
ok: false,
|
||||
voteId: command.voteId,
|
||||
generalId: command.generalId,
|
||||
reason: '유니크 판정이 일치하지 않습니다.',
|
||||
reason: '유니크 아이템을 찾을 수 없습니다.',
|
||||
};
|
||||
}
|
||||
if (expectedUnique) {
|
||||
if (!expectedItemKey || !itemKey || expectedItemKey !== itemKey) {
|
||||
return {
|
||||
type: 'voteReward',
|
||||
ok: false,
|
||||
voteId: command.voteId,
|
||||
generalId: command.generalId,
|
||||
reason: '유니크 판정이 일치하지 않습니다.',
|
||||
};
|
||||
}
|
||||
} else if (itemKey) {
|
||||
|
||||
if (votePersistence === null) {
|
||||
votePersistence = await insertVoteSelection(ctx, command, general, sortedSelection);
|
||||
}
|
||||
if (votePersistence !== 'inserted' && votePersistence !== 'existing-match') {
|
||||
return {
|
||||
type: 'voteReward',
|
||||
ok: false,
|
||||
voteId: command.voteId,
|
||||
generalId: command.generalId,
|
||||
reason: '유니크 판정이 일치하지 않습니다.',
|
||||
reason: '이미 설문조사를 완료하였습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2488,36 +2763,26 @@ async function handleVoteReward(
|
||||
};
|
||||
|
||||
const patch: Partial<TurnGeneral> = {
|
||||
gold: general.gold + command.goldReward,
|
||||
gold: general.gold + voteReward,
|
||||
meta: nextMeta,
|
||||
};
|
||||
|
||||
if (itemKey) {
|
||||
const itemModule = itemRegistry.get(itemKey);
|
||||
if (!itemModule) {
|
||||
return {
|
||||
type: 'voteReward',
|
||||
ok: false,
|
||||
voteId: command.voteId,
|
||||
generalId: command.generalId,
|
||||
reason: '유니크 아이템을 찾을 수 없습니다.',
|
||||
};
|
||||
}
|
||||
if (itemKey && awardedItemModule) {
|
||||
const nextGeneral = {
|
||||
...general,
|
||||
role: { ...general.role, items: { ...general.role.items } },
|
||||
itemInventory: cloneItemInventory(ensureItemInventory(general)),
|
||||
};
|
||||
equipNewItem(nextGeneral, itemModule.slot, itemKey, {
|
||||
...(itemModule.initialCharges === undefined ? {} : { charges: itemModule.initialCharges }),
|
||||
equipNewItem(nextGeneral, awardedItemModule.slot, itemKey, {
|
||||
...(awardedItemModule.initialCharges === undefined ? {} : { charges: awardedItemModule.initialCharges }),
|
||||
});
|
||||
patch.role = nextGeneral.role;
|
||||
patch.itemInventory = nextGeneral.itemInventory;
|
||||
|
||||
const nationName = world.getNationById(general.nationId)?.name ?? '재야';
|
||||
const generalName = general.name;
|
||||
const itemName = itemModule.name;
|
||||
const itemRawName = itemModule.rawName;
|
||||
const itemName = awardedItemModule.name;
|
||||
const itemRawName = awardedItemModule.rawName;
|
||||
const josaYi = JosaUtil.pick(generalName, '이');
|
||||
const josaUl = JosaUtil.pick(itemRawName, '을');
|
||||
|
||||
@@ -2677,6 +2942,8 @@ export const createTurnDaemonCommandHandler = (options: {
|
||||
handleJoinCreateGeneral(ctx, command as Extract<TurnDaemonCommand, { type: 'joinCreateGeneral' }>),
|
||||
npcPossessGeneral: (command) =>
|
||||
handleNpcPossessGeneral(ctx, command as Extract<TurnDaemonCommand, { type: 'npcPossessGeneral' }>),
|
||||
selectPoolReserve: (command) =>
|
||||
handleSelectPoolReserve(ctx, command as Extract<TurnDaemonCommand, { type: 'selectPoolReserve' }>),
|
||||
selectPoolCreate: (command) =>
|
||||
handleSelectPoolCreate(ctx, command as Extract<TurnDaemonCommand, { type: 'selectPoolCreate' }>),
|
||||
selectPoolReselect: (command) =>
|
||||
|
||||
@@ -17,11 +17,13 @@ import type {
|
||||
GeneralLastTurn,
|
||||
Nation,
|
||||
ScenarioConfig,
|
||||
ScenarioGeneralPoolCandidate,
|
||||
ScenarioMeta,
|
||||
Troop,
|
||||
TriggerValue,
|
||||
} from '@sammo-ts/logic';
|
||||
import { normalizeScenarioEffect } from '@sammo-ts/logic';
|
||||
import { parseScenarioGeneralPoolCandidate } from '@sammo-ts/logic';
|
||||
import { projectItemSlots, readItemInventoryFromMeta } from '@sammo-ts/logic/items/index.js';
|
||||
import { z } from 'zod';
|
||||
import { GameClock, asRecord, isRecord, type GameClockMode } from '@sammo-ts/common';
|
||||
@@ -30,7 +32,7 @@ import type { MapLoaderOptions } from '../scenario/mapLoader.js';
|
||||
import { loadMapDefinitionByName } from '../scenario/mapLoader.js';
|
||||
import type { UnitSetLoaderOptions } from '../scenario/unitSetLoader.js';
|
||||
import { loadUnitSetDefinitionByName } from '../scenario/unitSetLoader.js';
|
||||
import type { TurnDiplomacy, TurnEvent, TurnGeneral, TurnWorldLoadResult } from './types.js';
|
||||
import type { TurnDiplomacy, TurnEvent, TurnGeneral, TurnGeneralPoolEntry, TurnWorldLoadResult } from './types.js';
|
||||
import { readDiplomacyMeta } from '@sammo-ts/logic';
|
||||
import { applyPersistedRankRowsToMeta } from './rankData.js';
|
||||
|
||||
@@ -360,6 +362,27 @@ const mapEventRow = (row: {
|
||||
meta: asRecord(row.meta),
|
||||
});
|
||||
|
||||
const mapGeneralPoolRow = (row: {
|
||||
id: number;
|
||||
uniqueName: string;
|
||||
ownerUserId: string | null;
|
||||
generalId: number | null;
|
||||
reservedUntil: Date | null;
|
||||
reservedUntilTick: bigint | null;
|
||||
info: JsonValue;
|
||||
}): TurnGeneralPoolEntry => ({
|
||||
id: row.id,
|
||||
uniqueName: row.uniqueName,
|
||||
ownerUserId: row.ownerUserId,
|
||||
generalId: row.generalId,
|
||||
reservedUntil: row.reservedUntil,
|
||||
reservedUntilTick:
|
||||
row.reservedUntilTick === null
|
||||
? null
|
||||
: toSafeTick(row.reservedUntilTick, `select_pool.reserved_until_tick(${row.id})`),
|
||||
candidate: parseScenarioGeneralPoolCandidate(row) satisfies ScenarioGeneralPoolCandidate,
|
||||
});
|
||||
|
||||
const mapTroopRow = (row: TurnEngineTroopRow): Troop => ({
|
||||
id: row.troopLeaderId,
|
||||
nationId: row.nationId,
|
||||
@@ -386,6 +409,7 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
|
||||
diplomacyRows,
|
||||
troopRows,
|
||||
eventRows,
|
||||
generalPoolRows,
|
||||
] = await Promise.all([
|
||||
prisma.general.findMany(),
|
||||
prisma.rankData.findMany(),
|
||||
@@ -398,6 +422,7 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
|
||||
prisma.event.findMany({
|
||||
orderBy: [{ priority: 'desc' }, { id: 'asc' }],
|
||||
}),
|
||||
prisma.selectPoolEntry.findMany({ orderBy: { id: 'asc' } }),
|
||||
]);
|
||||
|
||||
const meta = asRecord(worldState.meta);
|
||||
@@ -465,6 +490,7 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
|
||||
|
||||
const worldConfig = asRecord(worldState.config);
|
||||
const scenarioConfig = mapScenarioConfig(worldState.config);
|
||||
const targetGeneralPool = scenarioConfig.map.targetGeneralPool;
|
||||
const mapName = scenarioConfig.environment?.mapName ?? 'che';
|
||||
const map = await loadMapDefinitionByName(mapName, options.mapOptions);
|
||||
const unitSetName = scenarioConfig.environment?.unitSet ?? 'che';
|
||||
@@ -508,6 +534,9 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
|
||||
diplomacy,
|
||||
events,
|
||||
initialEvents,
|
||||
...(typeof targetGeneralPool === 'string'
|
||||
? { generalPoolEntries: generalPoolRows.map(mapGeneralPoolRow) }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
|
||||
@@ -58,6 +58,9 @@ const resolveStartYear = (meta: Record<string, unknown>): number => {
|
||||
return 0;
|
||||
};
|
||||
|
||||
const readStoredSnapshotNumber = (value: unknown): number | null =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
|
||||
const buildMapSnapshot = (world: InMemoryTurnWorld, year: number, month: number): YearbookMap => {
|
||||
const state = world.getState();
|
||||
const cityList: MapCityCompact[] = world.listCities().map((city) => {
|
||||
@@ -134,40 +137,57 @@ const buildNationSnapshot = (world: InMemoryTurnWorld): YearbookNation[] => {
|
||||
generalStatsByNation.set(general.nationId, entry);
|
||||
}
|
||||
|
||||
return nations.map((nation) => {
|
||||
const projected = nations.map((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 = asNumber(asRecord(nation.meta).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(nation.power);
|
||||
let power = 1;
|
||||
if (nation.id !== 0) {
|
||||
if (storedPower !== null) {
|
||||
power = storedPower;
|
||||
} else {
|
||||
// Old or malformed in-memory snapshots can lack the monthly
|
||||
// nation projection. Only that absence uses the former derived
|
||||
// value; current worlds archive the stored Ref-compatible value.
|
||||
const cityStats = cityStatsByNation.get(nation.id) ?? { popSum: 0, valueSum: 0, maxSum: 0 };
|
||||
const resource = Math.round((nation.gold + nation.rice + generalStats.goldRice) / 100);
|
||||
const tech = asNumber(nationMeta.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) ?? [],
|
||||
};
|
||||
});
|
||||
// Ref's getCurrentHistory() replaces nation 0 with its canonical static
|
||||
// projection and then applies a stable descending-power sort.
|
||||
return projected.sort((left, right) => right.power - left.power);
|
||||
};
|
||||
|
||||
const increment = (target: Record<string, number>, key: string): void => {
|
||||
target[key] = (target[key] ?? 0) + 1;
|
||||
};
|
||||
|
||||
const updateDynastyStatistics = (world: InMemoryTurnWorld): void => {
|
||||
export const updateDynastyStatistics = (world: InMemoryTurnWorld): void => {
|
||||
const state = world.getState();
|
||||
const previous = asRecord(state.meta.dynastyStatistics);
|
||||
const activeNations = world
|
||||
@@ -247,8 +267,22 @@ export const createYearbookHandler = (options: {
|
||||
beforeMonthChanged: (context) => {
|
||||
const world = options.getWorld();
|
||||
if (!world) return;
|
||||
updateDynastyStatistics(world);
|
||||
queueYearbookSnapshot(world, options.profileName, context.previousYear, context.previousMonth);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const createDynastyStatisticsHandler = (options: {
|
||||
getWorld: () => InMemoryTurnWorld | null;
|
||||
}): { handler: TurnCalendarHandler } => ({
|
||||
handler: {
|
||||
// Ref calls checkStatistic() after turnDate() only when the newly
|
||||
// entered month is January. Unification takes one additional sample.
|
||||
onMonthChanged: (context) => {
|
||||
if (context.currentMonth !== 1) return;
|
||||
const world = options.getWorld();
|
||||
if (!world) return;
|
||||
updateDynastyStatistics(world);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user