fix: Ref 게임 로직과 시나리오 풀 호환을 보정
월 경계, 전투 기술 상한, 연감과 베팅·설문·경매 정산 순서를 Ref 계약에 맞춘다.\n\n시나리오 일반 풀을 ENGINE mutation과 logical tick 기반으로 직렬화하고 914·915 catalog 및 조건부 100기 pool 실행 경계를 추가한다.\n\n경매 worker는 세대별 durable event만 만들고 ENGINE이 row lock 후 상태 전이와 정산을 단일 transaction으로 소유한다.
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
createGamePostgresConnector,
|
||||
createRedisConnector,
|
||||
GamePrisma,
|
||||
type GamePrismaClient,
|
||||
resolvePostgresConfigFromEnv,
|
||||
resolveRedisConfigFromEnv,
|
||||
@@ -9,7 +8,7 @@ import {
|
||||
|
||||
import { resolveGameApiConfigFromEnv } from '../config.js';
|
||||
import { createBestEffortResourceCloser } from '../services/bestEffortResourceCloser.js';
|
||||
import { loadCurrentGameTime } from '../services/gameClock.js';
|
||||
import { loadCurrentGameTime, type CurrentGameTime } from '../services/gameClock.js';
|
||||
import { createPollingWorkerControl, waitForWorkerPoll } from '../services/pollingWorkerLifecycle.js';
|
||||
import { buildAuctionTimerKeys } from './keys.js';
|
||||
import { resolveAuctionTimerScore, seedAuctionTimers } from './scheduler.js';
|
||||
@@ -29,27 +28,84 @@ interface RedisTimerClient {
|
||||
|
||||
const AUCTION_FINALIZE_RECOVERY_LIMIT = 1;
|
||||
|
||||
const buildAuctionFinalizeRequestId = (auctionId: number, closeAt: Date, retry = 0): string => {
|
||||
const generation = closeAt.getTime();
|
||||
interface AuctionFinalizeDeadline {
|
||||
closeAt: Date;
|
||||
closeTick: bigint | null;
|
||||
}
|
||||
|
||||
interface AuctionFinalizeCommand {
|
||||
type: 'auctionFinalize';
|
||||
requestId: string;
|
||||
auctionId: number;
|
||||
expectedCloseAt: string;
|
||||
expectedCloseTick?: number;
|
||||
}
|
||||
|
||||
interface AuctionFinalizeEventRecord {
|
||||
target: string;
|
||||
eventType: string;
|
||||
payload: unknown;
|
||||
status: string;
|
||||
result: unknown;
|
||||
}
|
||||
|
||||
const readSafeCloseTick = (closeTick: bigint | null): number | undefined => {
|
||||
if (closeTick === null) return undefined;
|
||||
const value = Number(closeTick);
|
||||
if (!Number.isSafeInteger(value)) {
|
||||
throw new Error(`Auction close tick is unsafe: ${closeTick}`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export const buildAuctionFinalizeRequestId = (
|
||||
auctionId: number,
|
||||
deadline: AuctionFinalizeDeadline,
|
||||
retry = 0
|
||||
): string => {
|
||||
const generation =
|
||||
deadline.closeTick === null ? deadline.closeAt.getTime().toString() : `tick:${deadline.closeTick.toString()}`;
|
||||
const base = `auction:finalize:${auctionId}:${generation}`;
|
||||
return retry > 0 ? `${base}:retry:${retry}` : base;
|
||||
};
|
||||
|
||||
const buildLegacyAuctionFinalizeRequestId = (auctionId: number, closeAt: Date, retry = 0): string => {
|
||||
const base = `auction:finalize:${auctionId}:${closeAt.getTime()}`;
|
||||
return retry > 0 ? `${base}:retry:${retry}` : base;
|
||||
};
|
||||
|
||||
const buildAuctionFinalizeCommand = (
|
||||
auctionId: number,
|
||||
deadline: AuctionFinalizeDeadline,
|
||||
requestId: string
|
||||
): AuctionFinalizeCommand => ({
|
||||
type: 'auctionFinalize',
|
||||
requestId,
|
||||
auctionId,
|
||||
expectedCloseAt: deadline.closeAt.toISOString(),
|
||||
...(deadline.closeTick === null ? {} : { expectedCloseTick: readSafeCloseTick(deadline.closeTick) }),
|
||||
});
|
||||
|
||||
const isMatchingAuctionFinalizeEvent = (
|
||||
event: { target: string; eventType: string; payload: unknown },
|
||||
command: { type: 'auctionFinalize'; requestId: string; auctionId: number }
|
||||
command: AuctionFinalizeCommand
|
||||
): boolean => {
|
||||
const payload = event.payload;
|
||||
const payloadRecord =
|
||||
payload !== null && typeof payload === 'object' && !Array.isArray(payload)
|
||||
? (payload as Record<string, unknown>)
|
||||
: null;
|
||||
const expectedGenerationMatches =
|
||||
payloadRecord?.expectedCloseTick !== undefined
|
||||
? payloadRecord.expectedCloseTick === command.expectedCloseTick
|
||||
: payloadRecord?.expectedCloseAt === undefined || payloadRecord.expectedCloseAt === command.expectedCloseAt;
|
||||
return (
|
||||
event.target === 'ENGINE' &&
|
||||
event.eventType === command.type &&
|
||||
payloadRecord?.type === command.type &&
|
||||
payloadRecord.requestId === command.requestId &&
|
||||
payloadRecord.auctionId === command.auctionId
|
||||
payloadRecord.auctionId === command.auctionId &&
|
||||
expectedGenerationMatches
|
||||
);
|
||||
};
|
||||
|
||||
@@ -80,6 +136,66 @@ const getNextDueMs = async (redis: RedisTimerClient, timerKey: string): Promise<
|
||||
return next[0]?.score ?? null;
|
||||
};
|
||||
|
||||
export const reconcilePendingAuctionTimers = async (options: {
|
||||
db: Pick<GamePrismaClient, 'auction' | 'inputEvent'>;
|
||||
redis: Pick<RedisTimerClient, 'zAdd' | 'zRem'>;
|
||||
timerKey: string;
|
||||
auctionIds: readonly number[];
|
||||
gameTime: CurrentGameTime;
|
||||
}): Promise<{ pendingIds: number[]; rescheduled: number }> => {
|
||||
const auctionIds = [...new Set(options.auctionIds)];
|
||||
if (auctionIds.length === 0) {
|
||||
return { pendingIds: [], rescheduled: 0 };
|
||||
}
|
||||
const rows = await options.db.auction.findMany({
|
||||
where: { id: { in: auctionIds } },
|
||||
select: { id: true, status: true, closeAt: true, closeTick: true },
|
||||
});
|
||||
const pendingIds: number[] = [];
|
||||
const timers: Array<{ score: number; value: string }> = [];
|
||||
for (const row of rows) {
|
||||
if (row.status !== 'OPEN' && row.status !== 'FINALIZING') {
|
||||
continue;
|
||||
}
|
||||
const deadline = { closeAt: row.closeAt, closeTick: row.closeTick };
|
||||
const canonicalBase = buildAuctionFinalizeRequestId(row.id, deadline);
|
||||
const legacyBase = buildLegacyAuctionFinalizeRequestId(row.id, row.closeAt);
|
||||
const bases = [...new Set([canonicalBase, legacyBase])];
|
||||
const events = await options.db.inputEvent.findMany({
|
||||
where: {
|
||||
OR: bases.flatMap((base) => [{ requestId: base }, { requestId: { startsWith: `${base}:retry:` } }]),
|
||||
},
|
||||
select: { requestId: true, target: true, eventType: true, payload: true, status: true },
|
||||
orderBy: { sequence: 'desc' },
|
||||
});
|
||||
const hasPendingCurrentGeneration = events.some((event) => {
|
||||
if (event.status !== 'PENDING' && event.status !== 'PROCESSING') return false;
|
||||
return isMatchingAuctionFinalizeEvent(
|
||||
event,
|
||||
buildAuctionFinalizeCommand(row.id, deadline, event.requestId)
|
||||
);
|
||||
});
|
||||
if (hasPendingCurrentGeneration) {
|
||||
pendingIds.push(row.id);
|
||||
continue;
|
||||
}
|
||||
timers.push({
|
||||
score:
|
||||
row.status === 'FINALIZING'
|
||||
? (options.gameTime.tick ?? options.gameTime.now.getTime())
|
||||
: resolveAuctionTimerScore(options.gameTime, row.closeAt, row.closeTick),
|
||||
value: String(row.id),
|
||||
});
|
||||
}
|
||||
if (pendingIds.length > 0) {
|
||||
await options.redis.zRem(options.timerKey, pendingIds.map(String));
|
||||
}
|
||||
if (timers.length > 0) {
|
||||
await options.redis.zAdd(options.timerKey, timers);
|
||||
}
|
||||
return { pendingIds, rescheduled: timers.length };
|
||||
};
|
||||
|
||||
export const processDueAuctionId = async (options: {
|
||||
db: GamePrismaClient;
|
||||
redis: RedisTimerClient;
|
||||
@@ -89,7 +205,7 @@ export const processDueAuctionId = async (options: {
|
||||
nowMs: number;
|
||||
nowTick?: number | null;
|
||||
historyNowMs?: number;
|
||||
}): Promise<'FINALIZING' | 'RESCHEDULED' | 'IGNORED'> => {
|
||||
}): Promise<'PENDING' | 'RESCHEDULED' | 'IGNORED'> => {
|
||||
const { db, redis, timerKey, historyKey, id, nowMs, nowTick = null, historyNowMs = nowMs } = options;
|
||||
const auctionId = Number(id);
|
||||
if (!Number.isSafeInteger(auctionId) || auctionId < 1) {
|
||||
@@ -97,73 +213,75 @@ export const processDueAuctionId = async (options: {
|
||||
}
|
||||
const now = new Date(nowMs);
|
||||
const outcome = await db.$transaction(async (transaction) => {
|
||||
const updated = await transaction.$executeRaw(
|
||||
GamePrisma.sql`
|
||||
UPDATE auction
|
||||
SET status = 'FINALIZING',
|
||||
finalizing_at = ${now},
|
||||
updated_at = ${now}
|
||||
WHERE id = ${auctionId}
|
||||
AND status = 'OPEN'
|
||||
AND (
|
||||
(close_tick IS NOT NULL AND close_tick <= ${nowTick === null ? null : BigInt(nowTick)})
|
||||
OR (close_tick IS NULL AND close_at <= ${now})
|
||||
)
|
||||
`
|
||||
);
|
||||
|
||||
const current = await transaction.auction.findUnique({
|
||||
where: { id: auctionId },
|
||||
select: { status: true, closeAt: true, closeTick: true },
|
||||
});
|
||||
if (!current) {
|
||||
if (updated > 0) {
|
||||
throw new Error(`Auction disappeared after FINALIZING transition: ${auctionId}`);
|
||||
}
|
||||
return { status: 'IGNORED' as const };
|
||||
}
|
||||
if (current.status === 'OPEN') {
|
||||
const isDue =
|
||||
current.closeTick !== null && nowTick !== null
|
||||
? current.closeTick <= BigInt(nowTick)
|
||||
: current.closeTick === null && current.closeAt.getTime() <= now.getTime();
|
||||
if (!isDue) {
|
||||
return { status: 'RESCHEDULED' as const, closeAt: current.closeAt, closeTick: current.closeTick };
|
||||
}
|
||||
if (current.status !== 'FINALIZING') {
|
||||
}
|
||||
if (current.status !== 'OPEN' && current.status !== 'FINALIZING') {
|
||||
return { status: 'IGNORED' as const };
|
||||
}
|
||||
|
||||
const deadline = { closeAt: current.closeAt, closeTick: current.closeTick };
|
||||
for (let retry = 0; retry <= AUCTION_FINALIZE_RECOVERY_LIMIT; retry += 1) {
|
||||
const requestId = buildAuctionFinalizeRequestId(auctionId, current.closeAt, retry);
|
||||
const command = { type: 'auctionFinalize' as const, requestId, auctionId };
|
||||
const existing = await transaction.inputEvent.findUnique({
|
||||
where: { requestId },
|
||||
const requestId = buildAuctionFinalizeRequestId(auctionId, deadline, retry);
|
||||
const legacyRequestId = buildLegacyAuctionFinalizeRequestId(auctionId, current.closeAt, retry);
|
||||
const candidateRequestIds = [...new Set([requestId, legacyRequestId])];
|
||||
let existing: AuctionFinalizeEventRecord | null = null;
|
||||
let existingRequestId = requestId;
|
||||
for (const candidateRequestId of candidateRequestIds) {
|
||||
existing = await transaction.inputEvent.findUnique({
|
||||
where: { requestId: candidateRequestId },
|
||||
select: { target: true, eventType: true, payload: true, status: true, result: true },
|
||||
});
|
||||
if (existing) {
|
||||
existingRequestId = candidateRequestId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const command = buildAuctionFinalizeCommand(auctionId, deadline, existingRequestId);
|
||||
if (!existing) {
|
||||
const nextCommand = buildAuctionFinalizeCommand(auctionId, deadline, requestId);
|
||||
await transaction.inputEvent.create({
|
||||
data: {
|
||||
requestId,
|
||||
target: 'ENGINE',
|
||||
eventType: command.type,
|
||||
payload: command,
|
||||
eventType: nextCommand.type,
|
||||
payload: { ...nextCommand },
|
||||
},
|
||||
});
|
||||
return { status: 'FINALIZING' as const };
|
||||
return { status: 'PENDING' as const };
|
||||
}
|
||||
if (!isMatchingAuctionFinalizeEvent(existing, command)) {
|
||||
throw new Error(`Conflicting durable auction finalization event: ${requestId}`);
|
||||
throw new Error(`Conflicting durable auction finalization event: ${existingRequestId}`);
|
||||
}
|
||||
if (existing.status === 'PENDING' || existing.status === 'PROCESSING') {
|
||||
return { status: 'FINALIZING' as const };
|
||||
return { status: 'PENDING' as const };
|
||||
}
|
||||
if (existing.status === 'SUCCEEDED' && isSuccessfulAuctionFinalizeResult(existing.result, auctionId)) {
|
||||
throw new Error(`Auction remained FINALIZING after successful durable event: ${requestId}`);
|
||||
throw new Error(
|
||||
`Auction remained ${current.status} after successful durable event: ${existingRequestId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
throw new Error(`Auction finalization recovery exhausted: ${auctionId}`);
|
||||
});
|
||||
|
||||
if (outcome.status === 'FINALIZING') {
|
||||
if (outcome.status === 'PENDING') {
|
||||
// history retention은 운영 경과시간 기준이며 게임의 논리 시각과 분리한다.
|
||||
await redis.zAdd(historyKey, [{ score: historyNowMs, value: id }]);
|
||||
return 'FINALIZING';
|
||||
return 'PENDING';
|
||||
}
|
||||
if (outcome.status === 'RESCHEDULED') {
|
||||
const gameTime = await loadCurrentGameTime(db, now);
|
||||
@@ -198,6 +316,7 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom
|
||||
]);
|
||||
|
||||
let nextResyncAt = Date.now();
|
||||
const pendingFinalizationIds = new Set<number>();
|
||||
|
||||
try {
|
||||
while (!control.signal.aborted) {
|
||||
@@ -205,20 +324,32 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom
|
||||
const gameTime = await loadCurrentGameTime(postgres.prisma, new Date(operationalNowMs));
|
||||
const gameNowMs = gameTime.now.getTime();
|
||||
const dueScore = gameTime.tick ?? gameNowMs;
|
||||
const historyTrimBefore = operationalNowMs - config.auctionTimerRetentionSeconds * 1000;
|
||||
if (historyTrimBefore > 0) {
|
||||
await redis.client.zRemRangeByScore(keys.historyKey, 0, historyTrimBefore);
|
||||
}
|
||||
if (operationalNowMs >= nextResyncAt) {
|
||||
await seedAuctionTimers(postgres.prisma, redis.client, keys);
|
||||
nextResyncAt = operationalNowMs + config.auctionTimerResyncMs;
|
||||
}
|
||||
|
||||
if (pendingFinalizationIds.size > 0) {
|
||||
const reconciliation = await reconcilePendingAuctionTimers({
|
||||
db: postgres.prisma,
|
||||
redis: redis.client,
|
||||
timerKey: keys.timerKey,
|
||||
auctionIds: [...pendingFinalizationIds],
|
||||
gameTime,
|
||||
});
|
||||
pendingFinalizationIds.clear();
|
||||
for (const auctionId of reconciliation.pendingIds) {
|
||||
pendingFinalizationIds.add(auctionId);
|
||||
}
|
||||
}
|
||||
const historyTrimBefore = operationalNowMs - config.auctionTimerRetentionSeconds * 1000;
|
||||
if (historyTrimBefore > 0) {
|
||||
await redis.client.zRemRangeByScore(keys.historyKey, 0, historyTrimBefore);
|
||||
}
|
||||
const dueIds = await popDueAuctionIds(redis.client, keys.timerKey, dueScore, 100);
|
||||
if (dueIds.length > 0) {
|
||||
for (const id of dueIds) {
|
||||
try {
|
||||
await processDueAuctionId({
|
||||
const outcome = await processDueAuctionId({
|
||||
db: postgres.prisma,
|
||||
redis: redis.client,
|
||||
timerKey: keys.timerKey,
|
||||
@@ -228,6 +359,9 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom
|
||||
nowTick: gameTime.tick,
|
||||
historyNowMs: operationalNowMs,
|
||||
});
|
||||
if (outcome === 'PENDING') {
|
||||
pendingFinalizationIds.add(Number(id));
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown auction worker error';
|
||||
const trace = error instanceof Error ? error.stack : undefined;
|
||||
|
||||
@@ -112,6 +112,7 @@ export const buildBattleSimEnvironment = async (
|
||||
maxAtmosByCommand: resolveNumber(constValues, ['maxAtmosByCommand'], DEFAULT_WAR_CONFIG.maxAtmosByCommand),
|
||||
maxTrainByWar: resolveNumber(constValues, ['maxTrainByWar'], DEFAULT_WAR_CONFIG.maxTrainByWar),
|
||||
maxAtmosByWar: resolveNumber(constValues, ['maxAtmosByWar'], DEFAULT_WAR_CONFIG.maxAtmosByWar),
|
||||
maxTechLevel: resolveNumber(constValues, ['maxTechLevel'], 12),
|
||||
maxGeneralStat: resolveNumber(constValues, ['maxLevel'], LEGACY_DEFAULT_MAX_LEVEL),
|
||||
statUpgradeLimit: resolveNumber(constValues, ['upgradeLimit'], 30),
|
||||
castleCrewTypeId,
|
||||
|
||||
@@ -21,14 +21,26 @@ const stableJson = (value: unknown): string => {
|
||||
}
|
||||
return JSON.stringify(value) ?? 'null';
|
||||
};
|
||||
const commandIdentityJson = (value: unknown): string => {
|
||||
export const commandIdentityJson = (value: unknown): string => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return stableJson(value);
|
||||
}
|
||||
const commandType = String(Reflect.get(value, 'type'));
|
||||
if (
|
||||
value &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
Reflect.get(value, 'type') === 'npcPossessGeneral'
|
||||
[
|
||||
'npcPossessGeneral',
|
||||
'selectPoolReserve',
|
||||
'selectPoolCreate',
|
||||
'selectPoolReselect',
|
||||
'voteReward',
|
||||
'auctionBid',
|
||||
].includes(commandType)
|
||||
) {
|
||||
const { acceptedGameAt: _acceptedGameAt, ...identity } = value as Record<string, unknown>;
|
||||
const {
|
||||
acceptedGameAt: _acceptedGameAt,
|
||||
acceptedGameTick: _acceptedGameTick,
|
||||
...identity
|
||||
} = value as Record<string, unknown>;
|
||||
return stableJson(identity);
|
||||
}
|
||||
return stableJson(value);
|
||||
|
||||
@@ -43,6 +43,7 @@ interface AuctionRow {
|
||||
detail: unknown;
|
||||
status: string;
|
||||
closeAt: Date;
|
||||
closeTick: bigint | null;
|
||||
}
|
||||
|
||||
export interface AuctionDetail {
|
||||
@@ -55,6 +56,14 @@ export interface AuctionDetail {
|
||||
remainCloseDateExtensionCnt?: number | null;
|
||||
}
|
||||
|
||||
export const hasAuctionClosePassed = (
|
||||
auction: { closeAt: Date; closeTick: bigint | null },
|
||||
time: { now: Date; tick: number | null }
|
||||
): boolean =>
|
||||
auction.closeTick !== null && time.tick !== null
|
||||
? auction.closeTick < BigInt(time.tick)
|
||||
: auction.closeAt.getTime() < time.now.getTime();
|
||||
|
||||
interface AuctionBidRow {
|
||||
id: number;
|
||||
generalId: number;
|
||||
@@ -109,7 +118,8 @@ const loadAuction = async (db: DatabaseClient, auctionId: number): Promise<Aucti
|
||||
host_general_id as "hostGeneralId",
|
||||
detail,
|
||||
status,
|
||||
close_at as "closeAt"
|
||||
close_at as "closeAt",
|
||||
close_tick as "closeTick"
|
||||
FROM auction
|
||||
WHERE id = ${auctionId}
|
||||
`
|
||||
@@ -181,6 +191,8 @@ const shouldUsePrevBid = (highestBid: AuctionBidRow | null, myPrevBid: AuctionBi
|
||||
return myPrevBid;
|
||||
};
|
||||
|
||||
const MIN_AUCTION_REMAINING_RESOURCE = 1_000;
|
||||
|
||||
export const auctionRouter = router({
|
||||
getOverview: authedProcedure.query(async ({ ctx }) => {
|
||||
const auth = requireAuth(ctx);
|
||||
@@ -372,8 +384,7 @@ export const auctionRouter = router({
|
||||
}
|
||||
|
||||
const gameTime = await loadCurrentGameTime(ctx.db);
|
||||
const { now } = gameTime;
|
||||
if (auction.closeAt <= now) {
|
||||
if (hasAuctionClosePassed(auction, gameTime)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '경매가 종료되었습니다.' });
|
||||
}
|
||||
if (auction.hostGeneralId === general.id) {
|
||||
@@ -407,7 +418,7 @@ export const auctionRouter = router({
|
||||
if (morePoint <= 0) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '입찰가가 유효하지 않습니다.' });
|
||||
}
|
||||
if (general.gold < morePoint) {
|
||||
if (general.gold < morePoint + MIN_AUCTION_REMAINING_RESOURCE) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '금이 부족합니다.' });
|
||||
}
|
||||
|
||||
@@ -417,6 +428,7 @@ export const auctionRouter = router({
|
||||
auctionId: auction.id,
|
||||
generalId: general.id,
|
||||
amount: input.amount,
|
||||
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
|
||||
tryExtendCloseDate: true,
|
||||
});
|
||||
if (!result || result.type !== 'auctionBid') {
|
||||
@@ -448,8 +460,7 @@ export const auctionRouter = router({
|
||||
}
|
||||
|
||||
const gameTime = await loadCurrentGameTime(ctx.db);
|
||||
const { now } = gameTime;
|
||||
if (auction.closeAt <= now) {
|
||||
if (hasAuctionClosePassed(auction, gameTime)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '경매가 종료되었습니다.' });
|
||||
}
|
||||
if (auction.hostGeneralId === general.id) {
|
||||
@@ -483,7 +494,7 @@ export const auctionRouter = router({
|
||||
if (morePoint <= 0) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '입찰가가 유효하지 않습니다.' });
|
||||
}
|
||||
if (general.rice < morePoint) {
|
||||
if (general.rice < morePoint + MIN_AUCTION_REMAINING_RESOURCE) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '쌀이 부족합니다.' });
|
||||
}
|
||||
|
||||
@@ -493,6 +504,7 @@ export const auctionRouter = router({
|
||||
auctionId: auction.id,
|
||||
generalId: general.id,
|
||||
amount: input.amount,
|
||||
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
|
||||
tryExtendCloseDate: true,
|
||||
});
|
||||
if (!result || result.type !== 'auctionBid') {
|
||||
@@ -524,8 +536,7 @@ export const auctionRouter = router({
|
||||
}
|
||||
|
||||
const gameTime = await loadCurrentGameTime(ctx.db);
|
||||
const { now } = gameTime;
|
||||
if (auction.closeAt <= now) {
|
||||
if (hasAuctionClosePassed(auction, gameTime)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '경매가 종료되었습니다.' });
|
||||
}
|
||||
|
||||
@@ -630,6 +641,7 @@ export const auctionRouter = router({
|
||||
auctionId: auction.id,
|
||||
generalId: general.id,
|
||||
amount: input.amount,
|
||||
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
|
||||
tryExtendCloseDate: input.tryExtendCloseDate ?? false,
|
||||
});
|
||||
if (!result || result.type !== 'auctionBid') {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
WarTraitLoader,
|
||||
WAR_TRAIT_KEYS,
|
||||
isWarTraitKey,
|
||||
isCentennialStatResetAllowed,
|
||||
} from '@sammo-ts/logic';
|
||||
import type { InheritBuffType, ItemSlot, MessageDraft, MessageRecordDraft } from '@sammo-ts/logic';
|
||||
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
|
||||
@@ -307,6 +308,7 @@ export const inheritRouter = router({
|
||||
const resetTurnLevel = asNumber(asRecord(general.meta).inheritResetTurnTime, -1) + 1;
|
||||
|
||||
const config = asRecord(worldState.config);
|
||||
const canResetStat = isCentennialStatResetAllowed(config);
|
||||
const constValues = asRecord(config.const);
|
||||
const availableSpecialWar = Array.isArray(constValues.availableSpecialWar)
|
||||
? constValues.availableSpecialWar.filter((key): key is string => typeof key === 'string')
|
||||
@@ -347,6 +349,7 @@ export const inheritRouter = router({
|
||||
availableTargetGenerals: others,
|
||||
turnTimeZones: buildTurnTimeZoneList(Math.max(1, Math.round(worldState.tickSeconds / 60))),
|
||||
isUnited,
|
||||
canResetStat,
|
||||
currentSpecialWar: general.special2Code ?? 'None',
|
||||
currentStat: {
|
||||
leadership: general.leadership,
|
||||
@@ -700,12 +703,6 @@ export const inheritRouter = router({
|
||||
});
|
||||
}
|
||||
|
||||
const currentPoint = await readInheritancePoint(ctx.db, userId, 'previous');
|
||||
const cost = bonusSum > 0 ? inheritConst.inheritBornStatPoint : 0;
|
||||
if (currentPoint < cost) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '유산 포인트가 부족합니다.' });
|
||||
}
|
||||
|
||||
const general = await ctx.db.general.findFirst({
|
||||
where: { userId },
|
||||
select: { id: true, npcState: true },
|
||||
@@ -716,6 +713,18 @@ export const inheritRouter = router({
|
||||
if (general.npcState >= 2) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'NPC는 능력치 초기화를 할 수 없습니다.' });
|
||||
}
|
||||
if (!isCentennialStatResetAllowed(config)) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: '100기 올스타 장수는 능력치 초기화를 사용할 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const currentPoint = await readInheritancePoint(ctx.db, userId, 'previous');
|
||||
const cost = bonusSum > 0 ? inheritConst.inheritBornStatPoint : 0;
|
||||
if (currentPoint < cost) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '유산 포인트가 부족합니다.' });
|
||||
}
|
||||
|
||||
const seasonValue = resolveSeasonValue(worldMeta);
|
||||
if (seasonValue !== null) {
|
||||
|
||||
@@ -16,11 +16,7 @@ import {
|
||||
import { readInheritancePoint, resolveInheritConstants } from '../../services/inheritance.js';
|
||||
import { loadAuthoritativeAccountIcon } from '../../services/accountIconSync.js';
|
||||
import { loadCurrentGameTime } from '../../services/gameClock.js';
|
||||
import {
|
||||
getSelectionPoolStatus,
|
||||
reserveSelectionPool,
|
||||
resolveSelectionMaxGeneral,
|
||||
} from '@sammo-ts/game-engine/turn/selectPoolService.js';
|
||||
import { getSelectionPoolStatus, resolveSelectionMaxGeneral } from '@sammo-ts/game-engine/turn/selectPoolService.js';
|
||||
import {
|
||||
ConflictingTurnDaemonCommandError,
|
||||
RejectedNpcPossessionCommandError,
|
||||
@@ -54,6 +50,31 @@ const resolveSelectionCommandResult = (
|
||||
return { ok: true, generalId: result.generalId };
|
||||
};
|
||||
|
||||
const resolveSelectionReservationCommandResult = (
|
||||
result: Awaited<ReturnType<GameApiContext['turnDaemon']['requestCommand']>> | null
|
||||
) => {
|
||||
if (!result) {
|
||||
throw new TRPCError({
|
||||
code: 'TIMEOUT',
|
||||
message:
|
||||
'장수 선택 목록 요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다. 같은 요청으로 다시 시도해 주세요.',
|
||||
});
|
||||
}
|
||||
if (result.type !== 'selectPoolReserve') {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: '턴 데몬이 올바르지 않은 장수 선택 목록 결과를 반환했습니다.',
|
||||
});
|
||||
}
|
||||
if (!result.ok) {
|
||||
throw new TRPCError({ code: result.code, message: result.reason });
|
||||
}
|
||||
return result.reservation;
|
||||
};
|
||||
|
||||
const resolveSelectionReservationRequestId = (contextRequestId: string | undefined, userId: string) =>
|
||||
contextRequestId ? `select-pool:${userId}:${contextRequestId}:reserve` : undefined;
|
||||
|
||||
const resolveSelectionRequestId = (
|
||||
contextRequestId: string | undefined,
|
||||
userId: string,
|
||||
@@ -366,26 +387,22 @@ export const joinRouter = router({
|
||||
},
|
||||
};
|
||||
}),
|
||||
getSelectionPool: authedProcedure.mutation(async ({ ctx }) => {
|
||||
getSelectionPool: engineAuthedProcedure.mutation(async ({ ctx }) => {
|
||||
const userId = ctx.auth?.user.id;
|
||||
if (!userId) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
const worldState = await ctx.db.worldState.findFirst();
|
||||
if (!worldState) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'World state is not initialized.',
|
||||
});
|
||||
}
|
||||
const gameTime = await loadCurrentGameTime(ctx.db);
|
||||
return reserveSelectionPool({
|
||||
db: ctx.db,
|
||||
worldState,
|
||||
const commandRequestId = resolveSelectionReservationRequestId(ctx.requestId, userId);
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'selectPoolReserve',
|
||||
...(commandRequestId ? { requestId: commandRequestId } : {}),
|
||||
userId,
|
||||
now: gameTime.now,
|
||||
seedOwnerIdentity: ctx.auth?.user.legacyMemberNo ?? userId,
|
||||
acceptedGameAt: gameTime.now.toISOString(),
|
||||
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
|
||||
});
|
||||
return resolveSelectionReservationCommandResult(result);
|
||||
}),
|
||||
selectPoolGeneral: engineAuthedProcedure
|
||||
.input(
|
||||
@@ -413,6 +430,7 @@ export const joinRouter = router({
|
||||
});
|
||||
}
|
||||
const commandRequestId = resolveSelectionRequestId(ctx.requestId, userId, input.clientRequestId, 'create');
|
||||
const gameTime = await loadCurrentGameTime(ctx.db);
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'selectPoolCreate',
|
||||
...(commandRequestId ? { requestId: commandRequestId } : {}),
|
||||
@@ -421,6 +439,8 @@ export const joinRouter = router({
|
||||
uniqueName: input.uniqueName,
|
||||
personality: input.personality,
|
||||
seedOwnerIdentity: auth.user.legacyMemberNo ?? userId,
|
||||
acceptedGameAt: gameTime.now.toISOString(),
|
||||
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
|
||||
...(selectedIcon
|
||||
? {
|
||||
ownerPicture: selectedIcon.picture,
|
||||
@@ -450,12 +470,15 @@ export const joinRouter = router({
|
||||
input.clientRequestId,
|
||||
'reselect'
|
||||
);
|
||||
const gameTime = await loadCurrentGameTime(ctx.db);
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'selectPoolReselect',
|
||||
...(commandRequestId ? { requestId: commandRequestId } : {}),
|
||||
userId,
|
||||
ownerDisplayName: auth.user.displayName,
|
||||
uniqueName: input.uniqueName,
|
||||
acceptedGameAt: gameTime.now.toISOString(),
|
||||
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
|
||||
});
|
||||
return resolveSelectionCommandResult(result, 'selectPoolReselect');
|
||||
}),
|
||||
|
||||
@@ -221,11 +221,14 @@ const resolveScenarioStat = (config: Record<string, unknown>): { max: number; np
|
||||
};
|
||||
};
|
||||
|
||||
const resolveCommandEnv = (config: Record<string, unknown>): { develCost: number; defaultCrewTypeId: number } => {
|
||||
const resolveCommandEnv = (
|
||||
config: Record<string, unknown>
|
||||
): { develCost: number; defaultCrewTypeId: number; maxTechLevel: number } => {
|
||||
const constValues = asRecord(config.const ?? config.consts);
|
||||
return {
|
||||
develCost: resolveNumberFromKeys(constValues, ['develCost', 'develcost', 'develrate'], 0),
|
||||
defaultCrewTypeId: resolveNumberFromKeys(constValues, ['defaultCrewTypeId'], 0),
|
||||
maxTechLevel: resolveNumberFromKeys(constValues, ['maxTechLevel'], 12),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -345,13 +348,14 @@ const buildZeroPolicy = async (
|
||||
nationTech: number;
|
||||
develCost: number;
|
||||
defaultCrewTypeId: number;
|
||||
maxTechLevel: number;
|
||||
unitSetName: string;
|
||||
}
|
||||
): Promise<NationPolicy> => {
|
||||
const { statMax, statNpcMax, nationTech, develCost, defaultCrewTypeId, unitSetName } = options;
|
||||
const { statMax, statNpcMax, nationTech, develCost, defaultCrewTypeId, maxTechLevel, unitSetName } = options;
|
||||
const unitSet = await loadUnitSetDefinitionByName(unitSetName);
|
||||
const crewType = findCrewTypeById(unitSet, defaultCrewTypeId || unitSet.defaultCrewTypeId || 0);
|
||||
const techCost = getTechCost(nationTech);
|
||||
const techCost = getTechCost(nationTech, maxTechLevel);
|
||||
const next = clonePolicy(policy);
|
||||
|
||||
if (next.reqNPCDevelGold === 0) {
|
||||
@@ -511,6 +515,7 @@ export const npcRouter = router({
|
||||
nationTech,
|
||||
develCost: env.develCost,
|
||||
defaultCrewTypeId: env.defaultCrewTypeId,
|
||||
maxTechLevel: env.maxTechLevel,
|
||||
unitSetName,
|
||||
});
|
||||
|
||||
|
||||
@@ -29,6 +29,13 @@ const resolveNumber = (source: Record<string, unknown>, keys: string[], fallback
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const resolveCurrentDevelCost = (worldState: { config?: unknown; meta?: unknown } | null): number => {
|
||||
const config = asRecord(worldState?.config ?? {});
|
||||
const constValues = asRecord(config.const ?? config);
|
||||
const configured = resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0);
|
||||
return resolveNumber(asRecord(worldState?.meta), ['develcost', 'develCost', 'develrate'], configured);
|
||||
};
|
||||
|
||||
const adminProcedure = authedProcedure.use(({ ctx, next }) => {
|
||||
const roles = ctx.auth?.user.roles ?? [];
|
||||
if (!hasAdminRole(roles, ctx.profile.name)) {
|
||||
@@ -403,9 +410,7 @@ export const tournamentRouter = router({
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '참가 인원이 가득 찼습니다.' });
|
||||
}
|
||||
|
||||
const config = asRecord(worldState?.config ?? {});
|
||||
const constValues = asRecord(config.const ?? config);
|
||||
const develCost = resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0);
|
||||
const develCost = resolveCurrentDevelCost(worldState);
|
||||
const feeResult = await ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralResources',
|
||||
reason: 'tournamentJoin',
|
||||
@@ -462,9 +467,7 @@ export const tournamentRouter = router({
|
||||
const [participants, bets] = await Promise.all([store.getParticipants(), store.getBettingEntries()]);
|
||||
|
||||
const worldState = await ctx.db.worldState.findFirst();
|
||||
const config = asRecord(worldState?.config ?? {});
|
||||
const constValues = asRecord(config.const ?? config);
|
||||
const develCost = resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0);
|
||||
const develCost = resolveCurrentDevelCost(worldState);
|
||||
|
||||
const refundMap = new Map<number, number>();
|
||||
for (const participant of participants) {
|
||||
|
||||
@@ -1,20 +1,8 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { asRecord, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { GamePrisma } from '@sammo-ts/infra';
|
||||
import {
|
||||
ITEM_KEYS,
|
||||
addOccupiedUniqueItemKeys,
|
||||
buildVoteUniqueSeed,
|
||||
countOccupiedUniqueItems,
|
||||
createItemModuleRegistry,
|
||||
loadItemModules,
|
||||
resolveUniqueConfig,
|
||||
rollUniqueLottery,
|
||||
type GeneralItemSlots,
|
||||
type ItemModule,
|
||||
} from '@sammo-ts/logic';
|
||||
|
||||
import { authedProcedure, router } from '../../trpc.js';
|
||||
import { getMyGeneral } from '../shared/general.js';
|
||||
@@ -40,15 +28,6 @@ const adminProcedure = authedProcedure.use(({ ctx, next }) => {
|
||||
return next();
|
||||
});
|
||||
|
||||
let itemRegistryPromise: Promise<Map<string, ItemModule>> | null = null;
|
||||
|
||||
const getItemRegistry = async (): Promise<Map<string, ItemModule>> => {
|
||||
if (!itemRegistryPromise) {
|
||||
itemRegistryPromise = loadItemModules([...ITEM_KEYS]).then((modules) => createItemModuleRegistry(modules));
|
||||
}
|
||||
return itemRegistryPromise;
|
||||
};
|
||||
|
||||
const resolveNumber = (source: Record<string, unknown>, keys: string[], fallback: number): number => {
|
||||
for (const key of keys) {
|
||||
const value = source[key];
|
||||
@@ -65,30 +44,9 @@ const resolveNumber = (source: Record<string, unknown>, keys: string[], fallback
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const normalizeCode = (value: string | null | undefined): string | null => {
|
||||
if (!value || value === 'None') {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const normalizeOptions = (options: string[]): string[] =>
|
||||
options.map((option) => option.trim()).filter((option) => option.length > 0);
|
||||
|
||||
const readMetaNumber = (meta: Record<string, unknown>, key: string, fallback: number): number => {
|
||||
const value = meta[key];
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return Math.floor(value);
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const parseOptions = (value: unknown): string[] => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((entry): entry is string => typeof entry === 'string');
|
||||
@@ -138,11 +96,14 @@ type VotePollRow = {
|
||||
closed_at: Date | null;
|
||||
};
|
||||
|
||||
const hasPollEnded = (poll: Pick<VotePollRow, 'closed_at' | 'end_at' | 'end_tick'>, time: CurrentGameTime): boolean =>
|
||||
export const hasPollEnded = (
|
||||
poll: Pick<VotePollRow, 'closed_at' | 'end_at' | 'end_tick'>,
|
||||
time: CurrentGameTime
|
||||
): boolean =>
|
||||
Boolean(poll.closed_at) ||
|
||||
(poll.end_tick !== null && time.tick !== null
|
||||
? poll.end_tick <= BigInt(time.tick)
|
||||
: Boolean(poll.end_at && poll.end_at <= time.now));
|
||||
? poll.end_tick < BigInt(time.tick)
|
||||
: Boolean(poll.end_at && poll.end_at.getTime() < time.now.getTime()));
|
||||
|
||||
const toGameTickOrNull = (time: CurrentGameTime, date: Date | null): bigint | null => {
|
||||
if (!date) return null;
|
||||
@@ -388,116 +349,12 @@ export const voteRouter = router({
|
||||
}
|
||||
const general = await getMyGeneral(ctx);
|
||||
|
||||
const rows = await ctx.db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
|
||||
INSERT INTO vote (vote_id, general_id, nation_id, selection)
|
||||
VALUES (
|
||||
${input.voteId},
|
||||
${general.id},
|
||||
${general.nationId},
|
||||
CAST(${JSON.stringify(sortedSelection)} AS jsonb)
|
||||
)
|
||||
ON CONFLICT (vote_id, general_id) DO NOTHING
|
||||
RETURNING id
|
||||
`);
|
||||
|
||||
if (!rows[0]?.id) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 설문조사를 완료하였습니다.' });
|
||||
}
|
||||
|
||||
const worldState = await ctx.db.worldState.findFirst();
|
||||
if (!worldState) {
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state is not initialized.' });
|
||||
}
|
||||
|
||||
const worldMeta = asRecord(worldState.meta);
|
||||
const config = asRecord(worldState.config);
|
||||
const constValues = asRecord(config.const);
|
||||
const develCost = resolveNumber(
|
||||
worldMeta,
|
||||
['develcost', 'develCost'],
|
||||
resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0)
|
||||
);
|
||||
const voteReward = develCost * 5;
|
||||
|
||||
const scenarioMeta = asRecord(worldMeta.scenarioMeta);
|
||||
const startYear = readMetaNumber(scenarioMeta, 'startYear', worldState.currentYear);
|
||||
const initYear = readMetaNumber(worldMeta, 'initYear', startYear);
|
||||
const initMonth = readMetaNumber(worldMeta, 'initMonth', 1);
|
||||
const scenarioId = readMetaNumber(worldMeta, 'scenarioId', 0);
|
||||
const hiddenSeed = worldMeta.hiddenSeed ?? worldMeta.seed ?? worldState.id;
|
||||
|
||||
const itemRegistry = await getItemRegistry();
|
||||
const uniqueConfig = resolveUniqueConfig(constValues);
|
||||
|
||||
const [generalRows, reservedUniqueRows] = await Promise.all([
|
||||
ctx.db.general.findMany({
|
||||
select: {
|
||||
horseCode: true,
|
||||
weaponCode: true,
|
||||
bookCode: true,
|
||||
itemCode: true,
|
||||
},
|
||||
}),
|
||||
ctx.db.auction.findMany({
|
||||
where: {
|
||||
type: 'UNIQUE_ITEM',
|
||||
status: { in: ['OPEN', 'FINALIZING'] },
|
||||
targetCode: { not: null },
|
||||
},
|
||||
select: { targetCode: true },
|
||||
}),
|
||||
]);
|
||||
const generalItems: GeneralItemSlots[] = generalRows.map((row) => ({
|
||||
horse: normalizeCode(row.horseCode),
|
||||
weapon: normalizeCode(row.weaponCode),
|
||||
book: normalizeCode(row.bookCode),
|
||||
item: normalizeCode(row.itemCode),
|
||||
}));
|
||||
|
||||
const occupiedUniqueCounts = countOccupiedUniqueItems(generalItems, itemRegistry);
|
||||
addOccupiedUniqueItemKeys(
|
||||
occupiedUniqueCounts,
|
||||
reservedUniqueRows.map((row) => row.targetCode),
|
||||
itemRegistry
|
||||
);
|
||||
const userCount = await ctx.db.general.count({ where: { npcState: { lt: 2 } } });
|
||||
|
||||
const rngSeed = buildVoteUniqueSeed(
|
||||
typeof hiddenSeed === 'string' || typeof hiddenSeed === 'number' ? hiddenSeed : String(hiddenSeed),
|
||||
input.voteId,
|
||||
general.id
|
||||
);
|
||||
const rng = new RandUtil(LiteHashDRBG.build(rngSeed));
|
||||
const itemKey = rollUniqueLottery({
|
||||
rng,
|
||||
config: uniqueConfig,
|
||||
itemRegistry,
|
||||
generalItems: {
|
||||
horse: normalizeCode(general.horseCode),
|
||||
weapon: normalizeCode(general.weaponCode),
|
||||
book: normalizeCode(general.bookCode),
|
||||
item: normalizeCode(general.itemCode),
|
||||
},
|
||||
occupiedUniqueCounts,
|
||||
scenarioId,
|
||||
userCount,
|
||||
currentYear: worldState.currentYear,
|
||||
currentMonth: worldState.currentMonth,
|
||||
startYear,
|
||||
initYear,
|
||||
initMonth,
|
||||
acquireType: '설문조사',
|
||||
});
|
||||
|
||||
const rewardResult = await ctx.turnDaemon.requestCommand({
|
||||
type: 'voteReward',
|
||||
voteId: input.voteId,
|
||||
generalId: general.id,
|
||||
goldReward: voteReward,
|
||||
unique: {
|
||||
expected: Boolean(itemKey),
|
||||
itemKey: itemKey ?? null,
|
||||
},
|
||||
selection: sortedSelection,
|
||||
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
|
||||
});
|
||||
|
||||
if (!rewardResult || rewardResult.type !== 'voteReward') {
|
||||
@@ -559,6 +416,7 @@ export const voteRouter = router({
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
const openerName = ctx.auth?.user.username ?? general.name;
|
||||
const options = normalizeOptions(input.options);
|
||||
if (options.length === 0) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '항목이 없습니다.' });
|
||||
@@ -610,7 +468,7 @@ export const voteRouter = router({
|
||||
${multipleOptions},
|
||||
${input.revealMode},
|
||||
${general.id},
|
||||
${general.name},
|
||||
${openerName},
|
||||
${gameTime.now},
|
||||
${gameTime.tick === null ? null : BigInt(gameTime.tick)},
|
||||
${endAt},
|
||||
|
||||
@@ -45,6 +45,9 @@ const recordHistoryAccess = async (ctx: GameApiContext): Promise<void> => {
|
||||
const parseTextArray = (value: unknown): string[] =>
|
||||
Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
|
||||
|
||||
const readStoredSnapshotNumber = (value: unknown): number | null =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
|
||||
const parseYearbookNations = (value: unknown): YearbookNation[] => {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
@@ -114,6 +117,7 @@ const buildNationSnapshot = async (ctx: GameApiContext) => {
|
||||
gold: true,
|
||||
rice: true,
|
||||
tech: true,
|
||||
meta: true,
|
||||
},
|
||||
orderBy: { id: 'asc' },
|
||||
}),
|
||||
@@ -197,13 +201,20 @@ const buildNationSnapshot = async (ctx: GameApiContext) => {
|
||||
generalStatsByNation.set(general.nationId, entry);
|
||||
}
|
||||
|
||||
return nationRows.map<YearbookNation>((nation) => {
|
||||
const projected = nationRows.map<YearbookNation>((nation) => {
|
||||
const generalStats = generalStatsByNation.get(nation.id) ?? {
|
||||
goldRice: 0,
|
||||
statPower: 0,
|
||||
expDed: 0,
|
||||
generalCount: 0,
|
||||
};
|
||||
const nationMeta = asRecord(nation.meta);
|
||||
const storedPower = readStoredSnapshotNumber(nationMeta.power);
|
||||
let power = 1;
|
||||
if (nation.id !== 0) {
|
||||
if (storedPower !== null) {
|
||||
power = storedPower;
|
||||
} else {
|
||||
const cityStats = cityStatsByNation.get(nation.id) ?? { popSum: 0, valueSum: 0, maxSum: 0 };
|
||||
const resource = Math.round(((nation.gold ?? 0) + (nation.rice ?? 0) + generalStats.goldRice) / 100);
|
||||
const tech = nation.tech ?? 0;
|
||||
@@ -212,18 +223,23 @@ const buildNationSnapshot = async (ctx: GameApiContext) => {
|
||||
? 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);
|
||||
power = Math.round((resource + tech + cityPower + generalStats.statPower + expDed) / 10);
|
||||
}
|
||||
}
|
||||
const storedGeneralCount = readStoredSnapshotNumber(nationMeta.gennum);
|
||||
const generalCount = nation.id === 0 ? 1 : (storedGeneralCount ?? generalStats.generalCount);
|
||||
|
||||
return {
|
||||
id: nation.id,
|
||||
name: nation.name,
|
||||
color: nation.color,
|
||||
level: nation.level,
|
||||
name: nation.id === 0 ? '재야' : nation.name,
|
||||
color: nation.id === 0 ? '#000000' : nation.color,
|
||||
level: nation.id === 0 ? 0 : nation.level,
|
||||
power,
|
||||
generalCount: generalStats.generalCount,
|
||||
generalCount,
|
||||
cities: cityNamesByNation.get(nation.id) ?? [],
|
||||
};
|
||||
});
|
||||
return projected.sort((left, right) => right.power - left.power);
|
||||
};
|
||||
|
||||
const readGlobalActionLogs = async (ctx: GameApiContext, year: number, month: number) => {
|
||||
|
||||
@@ -565,6 +565,7 @@ export const settleTournamentOutcome = async (options: {
|
||||
type: 'tournamentBettingPayout',
|
||||
requestId: `tournament:${settledState.bettingId}:betting-payout`,
|
||||
bettingId: settledState.bettingId,
|
||||
tournamentType: settledState.type,
|
||||
payouts: payoutInfo.payouts,
|
||||
reason: 'winner_payout',
|
||||
});
|
||||
|
||||
@@ -617,11 +617,22 @@ export const buildBettingPayouts = (
|
||||
winnerId: number,
|
||||
entries: TournamentBetEntry[]
|
||||
): { payouts: Array<{ generalId: number; amount: number }>; total: number; refundAll: boolean } => {
|
||||
const total = entries.reduce((sum, entry) => sum + entry.amount, 0);
|
||||
const aggregated = new Map<string, TournamentBetEntry>();
|
||||
for (const entry of entries) {
|
||||
const key = `${entry.generalId}:${entry.targetId}`;
|
||||
const previous = aggregated.get(key);
|
||||
if (previous) {
|
||||
previous.amount += entry.amount;
|
||||
} else {
|
||||
aggregated.set(key, { ...entry });
|
||||
}
|
||||
}
|
||||
const normalizedEntries = [...aggregated.values()];
|
||||
const total = normalizedEntries.reduce((sum, entry) => sum + entry.amount, 0);
|
||||
if (total <= 0) {
|
||||
return { payouts: [], total: 0, refundAll: false };
|
||||
}
|
||||
const winners = entries.filter((entry) => entry.targetId === winnerId);
|
||||
const winners = normalizedEntries.filter((entry) => entry.targetId === winnerId);
|
||||
const winnersTotal = winners.reduce((sum, entry) => sum + entry.amount, 0);
|
||||
if (winnersTotal <= 0) {
|
||||
// Legacy Betting::_calcRewardExclusive() builds a refund candidate list
|
||||
|
||||
@@ -396,6 +396,7 @@ const buildConstraintEnv = (worldState: WorldStateRow): Record<string, unknown>
|
||||
relYear,
|
||||
join_mode: joinMode,
|
||||
openingPartYear: resolveNumber(constValues, ['openingPartYear'], 0),
|
||||
maxTechLevel: resolveNumber(constValues, ['maxTechLevel'], 12),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -515,19 +516,31 @@ export const buildRecruitmentCommandInfo = (options: {
|
||||
const city = options.city ? mapCityRow(options.city) : undefined;
|
||||
const nation = options.nation ? mapNationRow(options.nation) : null;
|
||||
const cities = options.cities.map(mapCityRow);
|
||||
const context = city ? { general, city, nation } : { general, nation };
|
||||
const command = new RecruitmentCommandResolver(options.generalActionModules ?? [], {});
|
||||
const tech = options.nation?.tech ?? 0;
|
||||
const techAbility = getTechAbility(tech);
|
||||
const commandEnv = buildCommandEnv(options.worldState);
|
||||
const constraintEnv = buildConstraintEnv(options.worldState);
|
||||
const startYear = typeof constraintEnv.startYear === 'number' ? constraintEnv.startYear : undefined;
|
||||
const configuredStartYear = typeof constraintEnv.startYear === 'number' ? constraintEnv.startYear : undefined;
|
||||
const startYear = configuredStartYear ?? options.worldState.currentYear;
|
||||
const context = {
|
||||
general,
|
||||
...(city ? { city } : {}),
|
||||
nation,
|
||||
time: {
|
||||
year: options.worldState.currentYear,
|
||||
month: options.worldState.currentMonth,
|
||||
startYear,
|
||||
},
|
||||
maxTechLevel: commandEnv.maxTechLevel,
|
||||
};
|
||||
const command = new RecruitmentCommandResolver(options.generalActionModules ?? [], commandEnv);
|
||||
const tech = options.nation?.tech ?? 0;
|
||||
const techAbility = getTechAbility(tech, commandEnv.maxTechLevel);
|
||||
const availabilityContext = {
|
||||
general,
|
||||
nation,
|
||||
map: options.map,
|
||||
cities,
|
||||
currentYear: options.worldState.currentYear,
|
||||
...(startYear === undefined ? {} : { startYear }),
|
||||
...(configuredStartYear === undefined ? {} : { startYear: configuredStartYear }),
|
||||
};
|
||||
const crewTypes = options.unitSet.crewTypes ?? [];
|
||||
const armTypes = Object.entries(options.unitSet.armTypes ?? {})
|
||||
@@ -565,7 +578,7 @@ export const buildRecruitmentCommandInfo = (options: {
|
||||
const currentCrewTypeName = crewTypes.find((crewType) => crewType.id === general.crewTypeId)?.name ?? '-';
|
||||
|
||||
return {
|
||||
techLevel: getTechLevel(tech),
|
||||
techLevel: getTechLevel(tech, commandEnv.maxTechLevel),
|
||||
leadership: command.resolveLeadership(context),
|
||||
fullLeadership: command.resolveFullLeadership(context),
|
||||
currentCrewTypeId: general.crewTypeId,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { InMemoryFlushStore } from '../src/auth/flushStore.js';
|
||||
import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js';
|
||||
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
|
||||
import { appRouter } from '../src/router.js';
|
||||
import { hasAuctionClosePassed } from '../src/router/auction/index.js';
|
||||
|
||||
const buildGeneral = (overrides: Partial<GeneralRow> = {}): GeneralRow => ({
|
||||
id: 7,
|
||||
@@ -80,6 +81,7 @@ const buildContext = (options: {
|
||||
isunited?: number;
|
||||
requestId?: string;
|
||||
transaction?: ReturnType<typeof vi.fn>;
|
||||
clockTick?: number;
|
||||
}) => {
|
||||
const auth = options.auth === undefined ? buildAuth() : options.auth;
|
||||
const general = options.general === undefined ? buildGeneral() : options.general;
|
||||
@@ -106,6 +108,14 @@ const buildContext = (options: {
|
||||
currentYear: 200,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 3600,
|
||||
...(options.clockTick === undefined
|
||||
? {}
|
||||
: {
|
||||
clockBaseTime: new Date('2026-07-26T00:00:00.000Z'),
|
||||
clockTick: BigInt(options.clockTick),
|
||||
clockMode: 'manual',
|
||||
clockWallAnchor: new Date('2026-07-26T00:00:00.000Z'),
|
||||
}),
|
||||
config: {
|
||||
const: {
|
||||
auctionName: ['청룡', '백호', '주작', '현무'],
|
||||
@@ -169,6 +179,20 @@ const buildContext = (options: {
|
||||
};
|
||||
|
||||
describe('auction router actor and permission boundaries', () => {
|
||||
it('keeps the auction open through its authoritative close tick', () => {
|
||||
const closeAt = new Date('2026-07-27T00:00:00.000Z');
|
||||
const auction = { closeAt, closeTick: 72_000_000n };
|
||||
|
||||
expect(hasAuctionClosePassed(auction, { now: closeAt, tick: 72_000_000 })).toBe(false);
|
||||
expect(
|
||||
hasAuctionClosePassed(auction, {
|
||||
now: new Date(closeAt.getTime() + 1),
|
||||
tick: 72_000_001,
|
||||
})
|
||||
).toBe(true);
|
||||
expect(hasAuctionClosePassed({ closeAt, closeTick: null }, { now: closeAt, tick: null })).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects unauthenticated auction reads', async () => {
|
||||
const fixture = buildContext({ auth: null });
|
||||
|
||||
@@ -329,6 +353,7 @@ describe('auction router actor and permission boundaries', () => {
|
||||
detail: { startBidAmount: 100, isReverse: false },
|
||||
status: 'OPEN',
|
||||
closeAt: new Date(Date.now() + 60 * 60_000),
|
||||
closeTick: 100n,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -346,6 +371,7 @@ describe('auction router actor and permission boundaries', () => {
|
||||
}
|
||||
return [];
|
||||
},
|
||||
clockTick: 100,
|
||||
});
|
||||
|
||||
await appRouter.createCaller(fixture.context).auction.bidUnique({
|
||||
@@ -358,7 +384,43 @@ describe('auction router actor and permission boundaries', () => {
|
||||
auctionId: 31,
|
||||
generalId: 7,
|
||||
amount: 110,
|
||||
acceptedGameTick: 100,
|
||||
tryExtendCloseDate: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the Ref 1000 gold reserve after a resource-auction bid', async () => {
|
||||
const queryRaw = async (query: GamePrisma.Sql) => {
|
||||
const text = sqlText(query);
|
||||
if (text.includes('FROM auction') && text.includes('WHERE id =')) {
|
||||
return [
|
||||
{
|
||||
id: 31,
|
||||
type: 'BUY_RICE',
|
||||
targetCode: '100',
|
||||
hostGeneralId: 88,
|
||||
detail: { title: '쌀 100 경매', amount: 100, startBidAmount: 500, isReverse: false },
|
||||
status: 'OPEN',
|
||||
closeAt: new Date(Date.now() + 60 * 60_000),
|
||||
},
|
||||
];
|
||||
}
|
||||
if (text.includes('FROM auction_bid')) {
|
||||
return [];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
const accepted = buildContext({ general: buildGeneral({ gold: 1_500 }), queryRaw });
|
||||
|
||||
await expect(
|
||||
appRouter.createCaller(accepted.context).auction.bidBuyRice({ auctionId: 31, amount: 500 })
|
||||
).resolves.toEqual({ ok: true });
|
||||
expect(accepted.requestCommand).toHaveBeenCalledOnce();
|
||||
|
||||
const rejected = buildContext({ general: buildGeneral({ gold: 1_499 }), queryRaw });
|
||||
await expect(
|
||||
appRouter.createCaller(rejected.context).auction.bidBuyRice({ auctionId: 31, amount: 500 })
|
||||
).rejects.toMatchObject({ code: 'BAD_REQUEST', message: '금이 부족합니다.' });
|
||||
expect(rejected.requestCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,7 +21,7 @@ import type { GamePrisma } from '@sammo-ts/infra';
|
||||
import type { MapDefinition, ScenarioConfig, ScenarioMeta, TurnSchedule } from '@sammo-ts/logic';
|
||||
|
||||
import { buildAuctionTimerKeys } from '../src/auction/keys.js';
|
||||
import { processDueAuctionId, runAuctionWorker } from '../src/auction/worker.js';
|
||||
import { buildAuctionFinalizeRequestId, processDueAuctionId, runAuctionWorker } from '../src/auction/worker.js';
|
||||
|
||||
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
||||
const liveDescribe = databaseUrl && process.env.REDIS_URL ? describe : describe.skip;
|
||||
@@ -80,8 +80,11 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
return auction;
|
||||
};
|
||||
|
||||
const requestIdFor = (auction: { id: number; closeAt: Date }): string =>
|
||||
`auction:finalize:${auction.id}:${auction.closeAt.getTime()}`;
|
||||
const requestIdFor = (auction: { id: number; closeAt: Date; closeTick?: bigint | null }): string =>
|
||||
buildAuctionFinalizeRequestId(auction.id, {
|
||||
closeAt: auction.closeAt,
|
||||
closeTick: auction.closeTick ?? null,
|
||||
});
|
||||
|
||||
const memoryRedis = () => ({
|
||||
zRangeByScore: vi.fn(async () => []),
|
||||
@@ -91,7 +94,7 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
zRemRangeByScore: vi.fn(async () => 0),
|
||||
});
|
||||
|
||||
it('atomically moves OPEN to FINALIZING and creates one deterministic input event', async () => {
|
||||
it('leaves OPEN and creates one deterministic input event', async () => {
|
||||
const auction = await createAuction('OPEN');
|
||||
const redis = memoryRedis();
|
||||
|
||||
@@ -104,7 +107,7 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
id: String(auction.id),
|
||||
nowMs: Date.now(),
|
||||
})
|
||||
).resolves.toBe('FINALIZING');
|
||||
).resolves.toBe('PENDING');
|
||||
await expect(
|
||||
processDueAuctionId({
|
||||
db: connector.prisma,
|
||||
@@ -114,13 +117,13 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
id: String(auction.id),
|
||||
nowMs: Date.now(),
|
||||
})
|
||||
).resolves.toBe('FINALIZING');
|
||||
).resolves.toBe('PENDING');
|
||||
|
||||
const [storedAuction, events] = await Promise.all([
|
||||
connector.prisma.auction.findUniqueOrThrow({ where: { id: auction.id } }),
|
||||
connector.prisma.inputEvent.findMany({ where: { requestId: requestIdFor(auction) } }),
|
||||
]);
|
||||
expect(storedAuction.status).toBe('FINALIZING');
|
||||
expect(storedAuction.status).toBe('OPEN');
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toMatchObject({
|
||||
target: 'ENGINE',
|
||||
@@ -130,6 +133,7 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
type: 'auctionFinalize',
|
||||
requestId: requestIdFor(auction),
|
||||
auctionId: auction.id,
|
||||
expectedCloseAt: auction.closeAt.toISOString(),
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -191,7 +195,7 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
id: String(auction.id),
|
||||
nowMs: Date.now(),
|
||||
})
|
||||
).resolves.toBe('FINALIZING');
|
||||
).resolves.toBe('PENDING');
|
||||
await expect(
|
||||
processDueAuctionId({
|
||||
db: connector.prisma,
|
||||
@@ -201,7 +205,7 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
id: String(auction.id),
|
||||
nowMs: Date.now(),
|
||||
})
|
||||
).resolves.toBe('FINALIZING');
|
||||
).resolves.toBe('PENDING');
|
||||
await expect(
|
||||
connector.prisma.inputEvent.findMany({
|
||||
where: { requestId: { startsWith: requestId } },
|
||||
@@ -258,7 +262,7 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
id: String(auction.id),
|
||||
nowMs: Date.now(),
|
||||
})
|
||||
).resolves.toBe('FINALIZING');
|
||||
).resolves.toBe('PENDING');
|
||||
|
||||
await expect(
|
||||
connector.prisma.inputEvent.findUnique({ where: { requestId: requestIdFor(auction) } })
|
||||
@@ -531,7 +535,11 @@ liveDescribe('auction worker durable recovery', () => {
|
||||
where: { id: extensionAuction.id },
|
||||
data: { closeAt: secondCloseAt, closeTick: BigInt(secondCloseTick) },
|
||||
});
|
||||
const secondExtensionRequestId = requestIdFor({ id: extensionAuction.id, closeAt: secondCloseAt });
|
||||
const secondExtensionRequestId = requestIdFor({
|
||||
id: extensionAuction.id,
|
||||
closeAt: secondCloseAt,
|
||||
closeTick: BigInt(secondCloseTick),
|
||||
});
|
||||
await processDueAuctionId({
|
||||
db: connector.prisma,
|
||||
redis: memoryRedis(),
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { GamePrismaClient } from '@sammo-ts/infra';
|
||||
|
||||
import { processDueAuctionId } from '../src/auction/worker.js';
|
||||
import { processDueAuctionId, reconcilePendingAuctionTimers } from '../src/auction/worker.js';
|
||||
import { resolveAuctionSeedScore } from '../src/auction/scheduler.js';
|
||||
|
||||
const buildRedis = () => ({
|
||||
@@ -32,14 +32,16 @@ const buildDb = (options: {
|
||||
const transaction = {
|
||||
$executeRaw: vi.fn(async () => options.updated),
|
||||
auction: {
|
||||
findUnique: vi.fn(async () => options.auction ?? null),
|
||||
findUnique: vi.fn(async () =>
|
||||
options.auction ? { ...options.auction, closeTick: options.auction.closeTick ?? null } : null
|
||||
),
|
||||
},
|
||||
inputEvent: {
|
||||
findUnique: vi.fn(
|
||||
async ({ where }: { where: { requestId: string } }) =>
|
||||
options.existingEvents?.find((event) => event.requestId === where.requestId) ?? null
|
||||
),
|
||||
create: vi.fn(async () => ({ sequence: 1n })),
|
||||
create: vi.fn(async (_args?: { data: { eventType: string } }) => ({ sequence: 1n })),
|
||||
},
|
||||
};
|
||||
return {
|
||||
@@ -84,6 +86,108 @@ describe('auction worker clock-shift race', () => {
|
||||
).toBe(36_000_000);
|
||||
});
|
||||
|
||||
it('requeues an auction immediately after the engine commits an OPEN extension', async () => {
|
||||
const redis = buildRedis();
|
||||
const closeAt = new Date('2099-01-01T00:00:00.000Z');
|
||||
const pendingRequestId = 'auction:finalize:8:tick:72000000';
|
||||
const db = {
|
||||
auction: {
|
||||
findMany: vi.fn(async () => [
|
||||
{ id: 7, status: 'OPEN', closeAt, closeTick: 72_000_000n },
|
||||
{ id: 8, status: 'FINALIZING', closeAt, closeTick: 72_000_000n },
|
||||
{ id: 9, status: 'FINISHED', closeAt, closeTick: 72_000_000n },
|
||||
]),
|
||||
},
|
||||
inputEvent: {
|
||||
findMany: vi.fn(async () => [
|
||||
{
|
||||
requestId: pendingRequestId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'auctionFinalize',
|
||||
payload: {
|
||||
type: 'auctionFinalize',
|
||||
requestId: pendingRequestId,
|
||||
auctionId: 8,
|
||||
expectedCloseAt: closeAt.toISOString(),
|
||||
expectedCloseTick: 72_000_000,
|
||||
},
|
||||
status: 'PENDING',
|
||||
},
|
||||
]),
|
||||
},
|
||||
} as unknown as Pick<GamePrismaClient, 'auction' | 'inputEvent'>;
|
||||
const now = new Date('2026-07-30T12:00:00.000Z');
|
||||
|
||||
await expect(
|
||||
reconcilePendingAuctionTimers({
|
||||
db,
|
||||
redis,
|
||||
timerKey: 'timer',
|
||||
auctionIds: [7, 8, 9],
|
||||
gameTime: {
|
||||
now,
|
||||
wallNow: now,
|
||||
tick: 36_000_000,
|
||||
mode: 'manual',
|
||||
running: false,
|
||||
startsAt: null,
|
||||
dateToTick: () => 72_000_000,
|
||||
},
|
||||
})
|
||||
).resolves.toEqual({ pendingIds: [8], rescheduled: 1 });
|
||||
expect(redis.zAdd).toHaveBeenCalledWith('timer', [{ score: 72_000_000, value: '7' }]);
|
||||
expect(redis.zRem).toHaveBeenCalledWith('timer', ['8']);
|
||||
});
|
||||
|
||||
it('ignores a prior pending generation and schedules the extended OPEN deadline', async () => {
|
||||
const redis = buildRedis();
|
||||
const closeAt = new Date('2099-01-01T00:30:00.000Z');
|
||||
const previousRequestId = 'auction:finalize:7:tick:72000000';
|
||||
const db = {
|
||||
auction: {
|
||||
findMany: vi.fn(async () => [{ id: 7, status: 'OPEN', closeAt, closeTick: 108_000_000n }]),
|
||||
},
|
||||
inputEvent: {
|
||||
findMany: vi.fn(async () => [
|
||||
{
|
||||
requestId: previousRequestId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'auctionFinalize',
|
||||
payload: {
|
||||
type: 'auctionFinalize',
|
||||
requestId: previousRequestId,
|
||||
auctionId: 7,
|
||||
expectedCloseAt: new Date('2099-01-01T00:00:00.000Z').toISOString(),
|
||||
expectedCloseTick: 72_000_000,
|
||||
},
|
||||
status: 'PENDING',
|
||||
},
|
||||
]),
|
||||
},
|
||||
} as unknown as Pick<GamePrismaClient, 'auction' | 'inputEvent'>;
|
||||
const now = new Date('2026-07-30T12:00:00.000Z');
|
||||
|
||||
await expect(
|
||||
reconcilePendingAuctionTimers({
|
||||
db,
|
||||
redis,
|
||||
timerKey: 'timer',
|
||||
auctionIds: [7],
|
||||
gameTime: {
|
||||
now,
|
||||
wallNow: now,
|
||||
tick: 72_000_000,
|
||||
mode: 'manual',
|
||||
running: false,
|
||||
startsAt: null,
|
||||
dateToTick: () => 108_000_000,
|
||||
},
|
||||
})
|
||||
).resolves.toEqual({ pendingIds: [], rescheduled: 1 });
|
||||
expect(redis.zAdd).toHaveBeenCalledWith('timer', [{ score: 108_000_000, value: '7' }]);
|
||||
expect(redis.zRem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requeues an OPEN auction at its current DB deadline when an old due score loses the race', async () => {
|
||||
const redis = buildRedis();
|
||||
const closeAt = new Date('2026-07-30T12:15:00.000Z');
|
||||
@@ -128,11 +232,11 @@ describe('auction worker clock-shift race', () => {
|
||||
expect(redis.zAdd).toHaveBeenCalledWith('timer', [{ score: 72_000_000, value: '7' }]);
|
||||
});
|
||||
|
||||
it('commits the FINALIZING transition and durable command in one transaction before recording history', async () => {
|
||||
it('leaves OPEN untouched and creates one durable command before recording history', async () => {
|
||||
const redis = buildRedis();
|
||||
const closeAt = new Date('2026-07-30T11:00:00.000Z');
|
||||
const requestId = `auction:finalize:7:${closeAt.getTime()}`;
|
||||
const { db, transaction } = buildDb({ updated: 1, auction: { status: 'FINALIZING', closeAt } });
|
||||
const { db, transaction } = buildDb({ updated: 0, auction: { status: 'OPEN', closeAt } });
|
||||
const nowMs = new Date('2026-07-30T12:00:00.000Z').getTime();
|
||||
|
||||
await expect(
|
||||
@@ -144,7 +248,7 @@ describe('auction worker clock-shift race', () => {
|
||||
id: '7',
|
||||
nowMs,
|
||||
})
|
||||
).resolves.toBe('FINALIZING');
|
||||
).resolves.toBe('PENDING');
|
||||
|
||||
expect(redis.zAdd).toHaveBeenCalledWith('history', [{ score: nowMs, value: '7' }]);
|
||||
expect(transaction.inputEvent.create).toHaveBeenCalledWith({
|
||||
@@ -152,15 +256,119 @@ describe('auction worker clock-shift race', () => {
|
||||
requestId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'auctionFinalize',
|
||||
payload: { type: 'auctionFinalize', requestId, auctionId: 7 },
|
||||
payload: {
|
||||
type: 'auctionFinalize',
|
||||
requestId,
|
||||
auctionId: 7,
|
||||
expectedCloseAt: closeAt.toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(transaction.$executeRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('enqueues finalization at the exact close tick without changing auction status', async () => {
|
||||
const redis = buildRedis();
|
||||
const closeAt = new Date('2099-01-01T00:00:00.000Z');
|
||||
const requestId = 'auction:finalize:7:tick:72000000';
|
||||
const { db, transaction } = buildDb({
|
||||
updated: 0,
|
||||
auction: { status: 'OPEN', closeAt, closeTick: 72_000_000n },
|
||||
});
|
||||
|
||||
await expect(
|
||||
processDueAuctionId({
|
||||
db,
|
||||
redis,
|
||||
timerKey: 'timer',
|
||||
historyKey: 'history',
|
||||
id: '7',
|
||||
nowMs: new Date('2042-01-01T00:00:00.000Z').getTime(),
|
||||
nowTick: 72_000_000,
|
||||
})
|
||||
).resolves.toBe('PENDING');
|
||||
expect(transaction.inputEvent.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
requestId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'auctionFinalize',
|
||||
payload: {
|
||||
type: 'auctionFinalize',
|
||||
requestId,
|
||||
auctionId: 7,
|
||||
expectedCloseAt: closeAt.toISOString(),
|
||||
expectedCloseTick: 72_000_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(transaction.$executeRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps an already-enqueued bid ahead of finalization and does not block it out of band', async () => {
|
||||
const redis = buildRedis();
|
||||
const closeAt = new Date('2026-07-30T11:00:00.000Z');
|
||||
const queuedTypes = ['auctionBid'];
|
||||
const { db, transaction } = buildDb({ updated: 0, auction: { status: 'OPEN', closeAt } });
|
||||
|
||||
await expect(
|
||||
processDueAuctionId({
|
||||
db,
|
||||
redis,
|
||||
timerKey: 'timer',
|
||||
historyKey: 'history',
|
||||
id: '7',
|
||||
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
||||
})
|
||||
).resolves.toBe('PENDING');
|
||||
|
||||
const created = transaction.inputEvent.create.mock.calls[0]?.[0] as { data: { eventType: string } } | undefined;
|
||||
if (created) queuedTypes.push(created.data.eventType);
|
||||
expect(queuedTypes).toEqual(['auctionBid', 'auctionFinalize']);
|
||||
expect(transaction.$executeRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reuses the same pending OPEN-generation event after a worker retry or restart', async () => {
|
||||
const redis = buildRedis();
|
||||
const closeAt = new Date('2026-07-30T11:00:00.000Z');
|
||||
const requestId = `auction:finalize:7:${closeAt.getTime()}`;
|
||||
const { db, transaction } = buildDb({
|
||||
updated: 0,
|
||||
auction: { status: 'OPEN', closeAt },
|
||||
existingEvents: [
|
||||
{
|
||||
requestId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'auctionFinalize',
|
||||
payload: {
|
||||
type: 'auctionFinalize',
|
||||
requestId,
|
||||
auctionId: 7,
|
||||
expectedCloseAt: closeAt.toISOString(),
|
||||
},
|
||||
status: 'PENDING',
|
||||
result: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
processDueAuctionId({
|
||||
db,
|
||||
redis,
|
||||
timerKey: 'timer',
|
||||
historyKey: 'history',
|
||||
id: '7',
|
||||
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
||||
})
|
||||
).resolves.toBe('PENDING');
|
||||
expect(transaction.inputEvent.create).not.toHaveBeenCalled();
|
||||
expect(transaction.$executeRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('records operational history time separately from logical settlement time', async () => {
|
||||
const redis = buildRedis();
|
||||
const closeAt = new Date('2026-07-30T11:00:00.000Z');
|
||||
const { db } = buildDb({ updated: 1, auction: { status: 'FINALIZING', closeAt } });
|
||||
const { db } = buildDb({ updated: 0, auction: { status: 'FINALIZING', closeAt } });
|
||||
const logicalNowMs = new Date('0190-01-01T00:00:00.000Z').getTime();
|
||||
const operationalNowMs = new Date('2026-07-30T12:00:00.000Z').getTime();
|
||||
|
||||
@@ -204,7 +412,7 @@ describe('auction worker clock-shift race', () => {
|
||||
id: '7',
|
||||
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
||||
})
|
||||
).resolves.toBe('FINALIZING');
|
||||
).resolves.toBe('PENDING');
|
||||
|
||||
expect(transaction.inputEvent.create).not.toHaveBeenCalled();
|
||||
expect(redis.zAdd).toHaveBeenCalledWith('history', [
|
||||
@@ -241,14 +449,19 @@ describe('auction worker clock-shift race', () => {
|
||||
id: '7',
|
||||
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
||||
})
|
||||
).resolves.toBe('FINALIZING');
|
||||
).resolves.toBe('PENDING');
|
||||
|
||||
expect(transaction.inputEvent.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
requestId: retryRequestId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'auctionFinalize',
|
||||
payload: { type: 'auctionFinalize', requestId: retryRequestId, auctionId: 7 },
|
||||
payload: {
|
||||
type: 'auctionFinalize',
|
||||
requestId: retryRequestId,
|
||||
auctionId: 7,
|
||||
expectedCloseAt: closeAt.toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -260,8 +473,8 @@ describe('auction worker clock-shift race', () => {
|
||||
const previousRequestId = `auction:finalize:7:${previousCloseAt.getTime()}`;
|
||||
const requestId = `auction:finalize:7:${closeAt.getTime()}`;
|
||||
const { db, transaction } = buildDb({
|
||||
updated: 1,
|
||||
auction: { status: 'FINALIZING', closeAt },
|
||||
updated: 0,
|
||||
auction: { status: 'OPEN', closeAt },
|
||||
existingEvents: [
|
||||
{
|
||||
requestId: previousRequestId,
|
||||
@@ -283,22 +496,27 @@ describe('auction worker clock-shift race', () => {
|
||||
id: '7',
|
||||
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
||||
})
|
||||
).resolves.toBe('FINALIZING');
|
||||
).resolves.toBe('PENDING');
|
||||
|
||||
expect(transaction.inputEvent.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
requestId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'auctionFinalize',
|
||||
payload: { type: 'auctionFinalize', requestId, auctionId: 7 },
|
||||
payload: {
|
||||
type: 'auctionFinalize',
|
||||
requestId,
|
||||
auctionId: 7,
|
||||
expectedCloseAt: closeAt.toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('rolls the auction transition back when durable event creation fails', async () => {
|
||||
it('does not touch auction status when durable event creation fails', async () => {
|
||||
const redis = buildRedis();
|
||||
const closeAt = new Date('2026-07-30T11:00:00.000Z');
|
||||
const { db, transaction } = buildDb({ updated: 1, auction: { status: 'FINALIZING', closeAt } });
|
||||
const { db, transaction } = buildDb({ updated: 0, auction: { status: 'OPEN', closeAt } });
|
||||
transaction.inputEvent.create.mockRejectedValueOnce(new Error('event insert failed'));
|
||||
|
||||
await expect(
|
||||
@@ -313,5 +531,6 @@ describe('auction worker clock-shift race', () => {
|
||||
).rejects.toThrow('event insert failed');
|
||||
|
||||
expect(redis.zAdd).not.toHaveBeenCalled();
|
||||
expect(transaction.$executeRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,7 +37,6 @@ const classifications = {
|
||||
'diplomacy.respondLetter',
|
||||
'diplomacy.rollbackLetter',
|
||||
'diplomacy.sendLetter',
|
||||
'join.getSelectionPool',
|
||||
'join.listPossessCandidates',
|
||||
'messages.readLatest',
|
||||
'turns.repeatNation',
|
||||
@@ -63,6 +62,7 @@ const classifications = {
|
||||
'general.vacation',
|
||||
'inherit.openUniqueAuction',
|
||||
'join.createGeneral',
|
||||
'join.getSelectionPool',
|
||||
'join.possessGeneral',
|
||||
'join.reselectPoolGeneral',
|
||||
'join.selectPoolGeneral',
|
||||
|
||||
@@ -2,6 +2,11 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { IdempotentTurnDaemonTransport } from '../src/daemon/idempotentTransport.js';
|
||||
import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js';
|
||||
import {
|
||||
commandIdentityJson,
|
||||
ConflictingTurnDaemonCommandError,
|
||||
DatabaseTurnDaemonTransport,
|
||||
} from '../src/daemon/databaseTransport.js';
|
||||
|
||||
describe('IdempotentTurnDaemonTransport', () => {
|
||||
it('derives stable ordered engine request IDs from the API input event', async () => {
|
||||
@@ -19,4 +24,108 @@ describe('IdempotentTurnDaemonTransport', () => {
|
||||
'api-event:engine:0:vacation',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the first durable acceptance tick authoritative across an idempotent retry', () => {
|
||||
const auctionBid = {
|
||||
type: 'auctionBid',
|
||||
requestId: 'auction-bid',
|
||||
auctionId: 31,
|
||||
generalId: 7,
|
||||
amount: 500,
|
||||
acceptedGameTick: 100,
|
||||
};
|
||||
expect(commandIdentityJson({ ...auctionBid, acceptedGameTick: 101 })).toBe(commandIdentityJson(auctionBid));
|
||||
expect(commandIdentityJson({ ...auctionBid, amount: 501 })).not.toBe(commandIdentityJson(auctionBid));
|
||||
|
||||
const voteReward = {
|
||||
type: 'voteReward',
|
||||
requestId: 'vote-reward',
|
||||
voteId: 1,
|
||||
generalId: 7,
|
||||
selection: [0],
|
||||
acceptedGameTick: 100,
|
||||
};
|
||||
expect(commandIdentityJson({ ...voteReward, acceptedGameTick: 101 })).toBe(commandIdentityJson(voteReward));
|
||||
expect(commandIdentityJson({ ...voteReward, selection: [1] })).not.toBe(commandIdentityJson(voteReward));
|
||||
|
||||
const selectionCommands = [
|
||||
{
|
||||
type: 'selectPoolReserve',
|
||||
requestId: 'select-pool-reserve',
|
||||
userId: 'user-7',
|
||||
seedOwnerIdentity: 7,
|
||||
acceptedGameAt: '0200-01-01T00:00:00.000Z',
|
||||
acceptedGameTick: 100,
|
||||
},
|
||||
{
|
||||
type: 'selectPoolCreate',
|
||||
requestId: 'select-pool-create',
|
||||
userId: 'user-7',
|
||||
ownerDisplayName: '사용자',
|
||||
uniqueName: '풀장수',
|
||||
personality: 'che_안전',
|
||||
acceptedGameAt: '0200-01-01T00:00:00.000Z',
|
||||
acceptedGameTick: 100,
|
||||
},
|
||||
{
|
||||
type: 'selectPoolReselect',
|
||||
requestId: 'select-pool-reselect',
|
||||
userId: 'user-7',
|
||||
ownerDisplayName: '사용자',
|
||||
uniqueName: '풀장수',
|
||||
acceptedGameAt: '0200-01-01T00:00:00.000Z',
|
||||
acceptedGameTick: 100,
|
||||
},
|
||||
];
|
||||
for (const command of selectionCommands) {
|
||||
expect(
|
||||
commandIdentityJson({
|
||||
...command,
|
||||
acceptedGameAt: '0200-01-01T00:01:00.000Z',
|
||||
acceptedGameTick: 101,
|
||||
})
|
||||
).toBe(commandIdentityJson(command));
|
||||
expect(commandIdentityJson({ ...command, userId: 'other-user' })).not.toBe(commandIdentityJson(command));
|
||||
}
|
||||
});
|
||||
|
||||
it('reuses a successful vote event when only the retry acceptance tick has changed', async () => {
|
||||
const persistedPayload = {
|
||||
type: 'voteReward' as const,
|
||||
requestId: 'vote-reward',
|
||||
voteId: 1,
|
||||
generalId: 7,
|
||||
selection: [0],
|
||||
acceptedGameTick: 100,
|
||||
};
|
||||
const create = async () => {
|
||||
throw Object.assign(new Error('duplicate'), { code: 'P2002' });
|
||||
};
|
||||
const transport = new DatabaseTurnDaemonTransport(
|
||||
{
|
||||
inputEvent: {
|
||||
create,
|
||||
findUniqueOrThrow: async () => ({ eventType: 'voteReward', payload: persistedPayload }),
|
||||
},
|
||||
} as any,
|
||||
100
|
||||
);
|
||||
|
||||
await expect(
|
||||
transport.sendCommand({
|
||||
...persistedPayload,
|
||||
acceptedGameTick: 101,
|
||||
})
|
||||
).resolves.toBe('vote-reward');
|
||||
|
||||
for (const changedIdentity of [{ selection: [1] }, { voteId: 2 }, { generalId: 8 }]) {
|
||||
await expect(
|
||||
transport.sendCommand({
|
||||
...persistedPayload,
|
||||
...changedIdentity,
|
||||
acceptedGameTick: 101,
|
||||
})
|
||||
).rejects.toBeInstanceOf(ConflictingTurnDaemonCommandError);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -110,6 +110,7 @@ const buildContext = (options: {
|
||||
rankRows?: Array<{ type: string; value: number }>;
|
||||
inheritanceLogs?: Array<{ id: number; year: number; month: number; text: string; createdAt: Date }>;
|
||||
configConst?: Record<string, unknown>;
|
||||
configMap?: Record<string, unknown>;
|
||||
}) => {
|
||||
const auth = options.auth === undefined ? buildAuth() : options.auth;
|
||||
const general = options.general === undefined ? buildGeneral() : options.general;
|
||||
@@ -128,12 +129,14 @@ const buildContext = (options: {
|
||||
const inheritanceLogFindMany = vi.fn(async () => options.inheritanceLogs ?? []);
|
||||
const webPushOutboxCreateMany = vi.fn(async () => ({ count: 1 }));
|
||||
const activeWorldState =
|
||||
options.configConst === undefined
|
||||
options.configConst === undefined && options.configMap === undefined
|
||||
? worldState
|
||||
: {
|
||||
...worldState,
|
||||
config: {
|
||||
const: options.configConst,
|
||||
...worldState.config,
|
||||
...(options.configConst === undefined ? {} : { const: options.configConst }),
|
||||
...(options.configMap === undefined ? {} : { map: options.configMap }),
|
||||
},
|
||||
};
|
||||
const messageRows: CapturedMessage[] = [];
|
||||
@@ -269,6 +272,30 @@ describe('inherit router actor and permission boundaries', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('reports and enforces the Ref S100 stat-reset ban without dispatching or charging', async () => {
|
||||
const fixture = buildContext({
|
||||
configMap: { targetGeneralPool: 'SPoolUnderU100' },
|
||||
inheritancePoint: 0,
|
||||
});
|
||||
const caller = appRouter.createCaller(fixture.context);
|
||||
|
||||
await expect(caller.inherit.getStatus()).resolves.toMatchObject({ canResetStat: false });
|
||||
await expect(
|
||||
caller.inherit.resetStat({
|
||||
leadership: 70,
|
||||
strength: 45,
|
||||
intel: 85,
|
||||
inheritBonusStat: [2, 1, 1],
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: 'BAD_REQUEST',
|
||||
message: '100기 올스타 장수는 능력치 초기화를 사용할 수 없습니다.',
|
||||
});
|
||||
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
||||
expect(fixture.pointUpsert).not.toHaveBeenCalled();
|
||||
expect(fixture.logCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('projects every Ref inheritance source with its own coefficient and stored/calculated boundary', async () => {
|
||||
const fixture = buildContext({
|
||||
general: buildGeneral({
|
||||
|
||||
@@ -693,6 +693,42 @@ describe('appRouter', () => {
|
||||
).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||
});
|
||||
|
||||
it('queues selection-pool reservation with the authenticated actor and server logical time', async () => {
|
||||
const transport = new InMemoryTurnDaemonTransport();
|
||||
const requestId = 'select-pool-reserve-http';
|
||||
const commandRequestId = `select-pool:user-1:${requestId}:reserve`;
|
||||
const acceptedGameAt = '2026-07-30T12:00:00.000Z';
|
||||
const reservation = {
|
||||
poolName: 'SPoolUnderU30',
|
||||
hasGeneral: false,
|
||||
validUntil: '2026-07-30T12:20:00.000Z',
|
||||
candidates: [],
|
||||
};
|
||||
transport.setCommandResult(commandRequestId, {
|
||||
type: 'selectPoolReserve',
|
||||
ok: true,
|
||||
reservation,
|
||||
});
|
||||
const state = {
|
||||
...buildWorldState(),
|
||||
clockBaseTime: new Date(acceptedGameAt),
|
||||
clockTick: 0n,
|
||||
clockMode: 'manual',
|
||||
clockWallAnchor: new Date(acceptedGameAt),
|
||||
} as WorldStateRow;
|
||||
const context = { ...buildContext({ state, transport }), requestId };
|
||||
|
||||
await expect(appRouter.createCaller(context).join.getSelectionPool()).resolves.toEqual(reservation);
|
||||
expect(transport.commands.at(-1)?.command).toEqual({
|
||||
type: 'selectPoolReserve',
|
||||
requestId: commandRequestId,
|
||||
userId: 'user-1',
|
||||
seedOwnerIdentity: 'user-1',
|
||||
acceptedGameAt,
|
||||
acceptedGameTick: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('queues turn daemon run commands', async () => {
|
||||
const transport = new InMemoryTurnDaemonTransport();
|
||||
const caller = appRouter.createCaller(buildContext({ transport }));
|
||||
|
||||
@@ -221,6 +221,25 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
||||
expect(concurrentReservation).toEqual(firstReservation);
|
||||
expect(firstReservation.candidates).toHaveLength(14);
|
||||
expect(await db.selectPoolEntry.count({ where: { ownerUserId: userId } })).toBe(14);
|
||||
const reservedNames = new Set(firstReservation.candidates.map((candidate) => candidate.uniqueName));
|
||||
expect(
|
||||
runtime!.world
|
||||
.listGeneralPoolCandidates(new Date(firstReservation.validUntil))
|
||||
?.some((candidate) => reservedNames.has(candidate.uniqueName))
|
||||
).toBe(false);
|
||||
await expect(
|
||||
db.inputEvent.findUniqueOrThrow({
|
||||
where: { requestId: `select-pool:${userId}:select-pool-reserve-a:reserve` },
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
eventType: 'selectPoolReserve',
|
||||
status: 'SUCCEEDED',
|
||||
actorUserId: userId,
|
||||
payload: {
|
||||
acceptedGameAt: expect.any(String),
|
||||
acceptedGameTick: expect.any(Number),
|
||||
},
|
||||
});
|
||||
|
||||
const attempts = await Promise.allSettled([
|
||||
appRouter.createCaller(buildContext('select-pool-create-a')).join.selectPoolGeneral({
|
||||
@@ -290,6 +309,10 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
||||
expect(initialRankRows.every(({ nationId, value }) => nationId === 0 && value === 0)).toBe(true);
|
||||
expect(await db.selectPoolEntry.count({ where: { generalId: initial.id } })).toBe(1);
|
||||
expect(await db.selectPoolEntry.count({ where: { ownerUserId: userId } })).toBe(0);
|
||||
expect(runtime!.world.listGeneralPoolEntries()?.filter((entry) => entry.ownerUserId === userId)).toEqual([]);
|
||||
expect(
|
||||
runtime!.world.listGeneralPoolEntries()?.find((entry) => entry.generalId === initial.id)?.candidate.name
|
||||
).toBe(initial.name);
|
||||
expect(
|
||||
await db.logEntry.count({
|
||||
where: { meta: { path: ['ownerUserId'], equals: userId } },
|
||||
@@ -336,6 +359,16 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
||||
.createCaller(buildContext('select-pool-reselect'))
|
||||
.join.reselectPoolGeneral({ uniqueName: target.uniqueName })
|
||||
).resolves.toEqual({ ok: true, generalId: initial.id });
|
||||
await expect(
|
||||
db.inputEvent.findUniqueOrThrow({ where: { requestId: 'select-pool-reselect:join.reselectPoolGeneral' } })
|
||||
).resolves.toMatchObject({
|
||||
eventType: 'selectPoolReselect',
|
||||
actorUserId: userId,
|
||||
payload: {
|
||||
acceptedGameAt: expect.any(String),
|
||||
acceptedGameTick: expect.any(Number),
|
||||
},
|
||||
});
|
||||
|
||||
const updated = await db.general.findUniqueOrThrow({ where: { id: initial.id } });
|
||||
expect(updated).toMatchObject({
|
||||
@@ -368,6 +401,10 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
||||
ownerUserId: null,
|
||||
reservedUntil: null,
|
||||
});
|
||||
expect(runtime!.world.listGeneralPoolEntries()?.filter((entry) => entry.ownerUserId === userId)).toEqual([]);
|
||||
expect(
|
||||
runtime!.world.listGeneralPoolEntries()?.find((entry) => entry.generalId === initial.id)?.uniqueName
|
||||
).toBe(target.uniqueName);
|
||||
expect(
|
||||
await db.logEntry.count({
|
||||
where: { meta: { path: ['ownerUserId'], equals: userId } },
|
||||
@@ -518,6 +555,10 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
||||
status: 'SUCCEEDED',
|
||||
attempts: 1,
|
||||
actorUserId: otherUserId,
|
||||
payload: {
|
||||
acceptedGameAt: expect.any(String),
|
||||
acceptedGameTick: expect.any(Number),
|
||||
},
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
|
||||
@@ -19,11 +19,11 @@ const loadWeightedRows = async (): Promise<Array<[{ id: number }, number]>> => {
|
||||
|
||||
const drawVector = async (hiddenSeed: string): Promise<{ selected: number[]; draws: number[] }> => {
|
||||
const weighted = await loadWeightedRows();
|
||||
const now = new Date('2026-07-30T03:34:56.000Z');
|
||||
const nowTick = 72_000_000;
|
||||
const draws: number[] = [];
|
||||
const selected = await claimWeightedSelectionCandidates({
|
||||
weighted,
|
||||
rng: new RandUtil(new LiteHashDRBG(buildSelectPoolSeed(hiddenSeed, 42, now))),
|
||||
rng: new RandUtil(new LiteHashDRBG(buildSelectPoolSeed(hiddenSeed, 42, nowTick))),
|
||||
count: 14,
|
||||
claim: async () => true,
|
||||
onDraw: (candidate) => draws.push(candidate.id),
|
||||
@@ -33,21 +33,21 @@ const drawVector = async (hiddenSeed: string): Promise<{ selected: number[]; dra
|
||||
|
||||
describe('select pool Ref RNG parity', () => {
|
||||
it('uses the legacy seed serialization and fixed UnderS30 draw vector', async () => {
|
||||
const now = new Date('2026-07-30T03:34:56.000Z');
|
||||
expect(buildSelectPoolSeed('vector-hidden', 42, now)).toBe(
|
||||
'str(13,vector-hidden)|str(10,selectPool)|int(42)|str(19,2026-07-30 12:34:56)'
|
||||
const nowTick = 72_000_000;
|
||||
expect(buildSelectPoolSeed('vector-hidden', 42, nowTick)).toBe(
|
||||
'str(13,vector-hidden)|str(10,selectPool)|int(42)|int(72000000)'
|
||||
);
|
||||
|
||||
await expect(drawVector('vector-hidden')).resolves.toEqual({
|
||||
selected: [72, 1283, 110, 1659, 608, 1408, 1543, 1573, 1096, 1081, 278, 1256, 872, 1369],
|
||||
draws: [72, 1283, 110, 1659, 608, 1408, 1543, 1573, 1096, 1081, 278, 1256, 872, 1369],
|
||||
selected: [1547, 199, 1266, 756, 1741, 1435, 303, 753, 214, 576, 387, 388, 394, 252],
|
||||
draws: [1547, 199, 1266, 756, 1741, 1435, 303, 753, 214, 576, 387, 388, 394, 252],
|
||||
});
|
||||
});
|
||||
|
||||
it('consumes duplicate draws without removing the candidate from the weighted pool', async () => {
|
||||
await expect(drawVector('vector-hidden-28')).resolves.toEqual({
|
||||
selected: [314, 865, 1485, 1382, 110, 550, 27, 368, 399, 1298, 152, 39, 189, 760],
|
||||
draws: [314, 865, 1485, 1382, 110, 550, 27, 368, 399, 1298, 27, 152, 39, 189, 760],
|
||||
await expect(drawVector('vector-hidden-2')).resolves.toEqual({
|
||||
selected: [1632, 543, 640, 1351, 691, 966, 1110, 1358, 224, 936, 262, 109, 852, 456],
|
||||
draws: [1632, 543, 640, 1351, 691, 966, 1110, 1358, 224, 936, 262, 109, 966, 852, 456],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -129,6 +129,7 @@ const buildContext = (options: {
|
||||
userId: string;
|
||||
roles?: string[];
|
||||
develCost?: number;
|
||||
currentDevelCost?: number;
|
||||
rankRows?: Array<{ generalId: number; type: string; value: number }>;
|
||||
}): GameApiContext => {
|
||||
const db = {
|
||||
@@ -142,7 +143,10 @@ const buildContext = (options: {
|
||||
findMany: async () => options.rankRows ?? [],
|
||||
},
|
||||
worldState: {
|
||||
findFirst: async () => ({ config: { const: { develCost: options.develCost ?? 200 } } }),
|
||||
findFirst: async () => ({
|
||||
config: { const: { develCost: options.develCost ?? 200 } },
|
||||
...(options.currentDevelCost === undefined ? {} : { meta: { develcost: options.currentDevelCost } }),
|
||||
}),
|
||||
},
|
||||
} as unknown as DatabaseClient;
|
||||
return {
|
||||
@@ -267,6 +271,42 @@ describe('tournament router permissions and mutations', () => {
|
||||
expect(snapshot.participants[0]!.groupId).toBeLessThan(8);
|
||||
});
|
||||
|
||||
it('charges the current game_env develcost instead of the scenario snapshot', async () => {
|
||||
const redis = new MemoryRedis();
|
||||
const transport = new TournamentTransport();
|
||||
const general = buildGeneral(1, 'user-1');
|
||||
transport.gold.set(general.id, general.gold);
|
||||
await setTournamentFixture(redis, {
|
||||
stage: 1,
|
||||
phase: 0,
|
||||
type: 0,
|
||||
auto: true,
|
||||
openYear: 193,
|
||||
openMonth: 1,
|
||||
termSeconds: 60,
|
||||
nextAt: '2026-07-26T01:00:00.000Z',
|
||||
});
|
||||
await redis.set('sammo:che:default:tournament:participants', '[]');
|
||||
const caller = appRouter.createCaller(
|
||||
buildContext({
|
||||
redis,
|
||||
transport,
|
||||
generals: [general],
|
||||
userId: 'user-1',
|
||||
develCost: 200,
|
||||
currentDevelCost: 64,
|
||||
})
|
||||
);
|
||||
|
||||
await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 });
|
||||
expect(transport.gold.get(general.id)).toBe(1_936);
|
||||
expect(transport.commands).toContainEqual({
|
||||
type: 'adjustGeneralResources',
|
||||
reason: 'tournamentJoin',
|
||||
adjustments: [{ generalId: general.id, goldDelta: -64, minGoldAfter: 0 }],
|
||||
});
|
||||
});
|
||||
|
||||
it('serializes concurrent bets and enforces the legacy per-user 1000 limit', async () => {
|
||||
const redis = new MemoryRedis();
|
||||
const transport = new TournamentTransport();
|
||||
|
||||
@@ -320,6 +320,24 @@ describe('tournament worker (in-memory)', () => {
|
||||
).toEqual({ payouts: [], total: 300, refundAll: false });
|
||||
});
|
||||
|
||||
it('coalesces duplicate legacy rows before one winner payout and rounding', () => {
|
||||
expect(
|
||||
buildBettingPayouts(10, [
|
||||
{ generalId: 1, targetId: 10, amount: 101 },
|
||||
{ generalId: 1, targetId: 10, amount: 99 },
|
||||
{ generalId: 2, targetId: 10, amount: 100 },
|
||||
{ generalId: 3, targetId: 11, amount: 100 },
|
||||
])
|
||||
).toEqual({
|
||||
payouts: [
|
||||
{ generalId: 1, amount: 267 },
|
||||
{ generalId: 2, amount: 133 },
|
||||
],
|
||||
total: 400,
|
||||
refundAll: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('locks 64 applicants into eight groups of eight', async () => {
|
||||
const redis = new MemoryRedis();
|
||||
const store = new TournamentStore(redis, buildTournamentKeys('test-groups'));
|
||||
|
||||
@@ -9,6 +9,7 @@ import { InMemoryFlushStore } from '../src/auth/flushStore.js';
|
||||
import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js';
|
||||
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
|
||||
import { appRouter } from '../src/router.js';
|
||||
import { hasPollEnded } from '../src/router/vote/index.js';
|
||||
|
||||
const poll = {
|
||||
id: 1,
|
||||
@@ -95,10 +96,11 @@ const buildContext = (options: {
|
||||
configConst?: Record<string, unknown>;
|
||||
metaDevelCost?: number;
|
||||
auctionTargets?: string[];
|
||||
clockTick?: number;
|
||||
}) => {
|
||||
const auth = options.auth === undefined ? buildAuth() : options.auth;
|
||||
const general = options.general === undefined ? buildGeneral() : options.general;
|
||||
const requestCommand = vi.fn(async () => ({
|
||||
const requestCommand = vi.fn(async (_command?: unknown) => ({
|
||||
type: 'voteReward' as const,
|
||||
ok: true as const,
|
||||
voteId: 1,
|
||||
@@ -139,6 +141,14 @@ const buildContext = (options: {
|
||||
currentYear: 200,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 3600,
|
||||
...(options.clockTick === undefined
|
||||
? {}
|
||||
: {
|
||||
clockBaseTime: new Date('0200-01-01T00:00:00.000Z'),
|
||||
clockTick: BigInt(options.clockTick),
|
||||
clockMode: 'manual',
|
||||
clockWallAnchor: new Date('2026-07-26T00:00:00.000Z'),
|
||||
}),
|
||||
config: { const: { develCost: 18, allItems: {}, ...(options.configConst ?? {}) } },
|
||||
meta: {
|
||||
...(options.metaDevelCost === undefined ? {} : { develcost: options.metaDevelCost }),
|
||||
@@ -201,6 +211,29 @@ const buildContext = (options: {
|
||||
};
|
||||
|
||||
describe('vote router actor and permission boundaries', () => {
|
||||
it('keeps a poll open at its exact Ref end tick and closes it after that tick', () => {
|
||||
const now = new Date('2026-07-26T00:00:00Z');
|
||||
const time = {
|
||||
now,
|
||||
wallNow: now,
|
||||
tick: 100,
|
||||
mode: 'manual' as const,
|
||||
running: false,
|
||||
startsAt: null,
|
||||
dateToTick: () => 100,
|
||||
};
|
||||
|
||||
expect(hasPollEnded({ closed_at: null, end_at: now, end_tick: 100n }, time)).toBe(false);
|
||||
expect(hasPollEnded({ closed_at: null, end_at: now, end_tick: 99n }, time)).toBe(true);
|
||||
expect(hasPollEnded({ closed_at: null, end_at: now, end_tick: null }, { ...time, tick: null })).toBe(false);
|
||||
expect(
|
||||
hasPollEnded(
|
||||
{ closed_at: null, end_at: now, end_tick: null },
|
||||
{ ...time, now: new Date(now.getTime() + 1), tick: null }
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects unauthenticated survey access', async () => {
|
||||
const fixture = buildContext({ auth: null });
|
||||
|
||||
@@ -211,18 +244,20 @@ describe('vote router actor and permission boundaries', () => {
|
||||
|
||||
it('uses only the general owned by the authenticated user for voting and reward dispatch', async () => {
|
||||
const owned = buildGeneral({ id: 7, userId: 'user-1', name: '유비' });
|
||||
const fixture = buildContext({ general: owned });
|
||||
const fixture = buildContext({ general: owned, clockTick: 100 });
|
||||
|
||||
await expect(
|
||||
appRouter.createCaller(fixture.context).vote.submitVote({ voteId: 1, selection: [0] })
|
||||
).resolves.toEqual({ ok: true, wonLottery: false });
|
||||
expect(fixture.requestCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
expect(fixture.requestCommand).toHaveBeenCalledWith({
|
||||
type: 'voteReward',
|
||||
voteId: 1,
|
||||
generalId: 7,
|
||||
goldReward: 90,
|
||||
})
|
||||
selection: [0],
|
||||
acceptedGameTick: 100,
|
||||
});
|
||||
expect(fixture.queryRaw.mock.calls.some(([query]) => sqlText(query).includes('INSERT INTO vote ('))).toBe(
|
||||
false
|
||||
);
|
||||
expect(fixture.changeJournal.snapshot()).toEqual([{ domain: 'front.general', entityId: 7 }]);
|
||||
expect(fixture.redisIncr).not.toHaveBeenCalled();
|
||||
@@ -230,7 +265,10 @@ describe('vote router actor and permission boundaries', () => {
|
||||
});
|
||||
|
||||
it('publishes a global front-status projection after creating a survey', async () => {
|
||||
const fixture = buildContext({ auth: buildAuth(['admin.survey.open']) });
|
||||
const auth = buildAuth(['admin.survey.open']);
|
||||
auth.user.username = 'admin-account';
|
||||
auth.user.displayName = '관리자 표시명';
|
||||
const fixture = buildContext({ auth, general: buildGeneral({ name: '관리자 장수' }) });
|
||||
|
||||
await expect(
|
||||
appRouter.createCaller(fixture.context).vote.createPoll({
|
||||
@@ -244,19 +282,22 @@ describe('vote router actor and permission boundaries', () => {
|
||||
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
||||
expect(fixture.redisIncr).not.toHaveBeenCalled();
|
||||
expect(fixture.redisPublish).not.toHaveBeenCalled();
|
||||
const insert = fixture.queryRaw.mock.calls
|
||||
.map(([query]) => query)
|
||||
.find((query) => sqlText(query).includes('INSERT INTO vote_poll'));
|
||||
expect(insert?.values).toContain('admin-account');
|
||||
expect(insert?.values).not.toContain('관리자 장수');
|
||||
});
|
||||
|
||||
it('uses the current world develcost for the legacy five-times survey reward', async () => {
|
||||
it('reports the current world develcost as the legacy five-times survey reward', async () => {
|
||||
const fixture = buildContext({ metaDevelCost: 30, configConst: { develCost: 0 } });
|
||||
|
||||
await expect(appRouter.createCaller(fixture.context).vote.getVoteList()).resolves.toMatchObject({
|
||||
voteReward: 150,
|
||||
});
|
||||
await appRouter.createCaller(fixture.context).vote.submitVote({ voteId: 1, selection: [0] });
|
||||
expect(fixture.requestCommand).toHaveBeenCalledWith(expect.objectContaining({ goldReward: 150 }));
|
||||
});
|
||||
|
||||
it('includes active unique auctions in the API-side reward expectation', async () => {
|
||||
it('leaves live reward and unique occupancy calculation to ENGINE', async () => {
|
||||
const fixture = buildContext({
|
||||
configConst: {
|
||||
allItems: { weapon: { che_무기_12_칠성검: 1 } },
|
||||
@@ -270,11 +311,11 @@ describe('vote router actor and permission boundaries', () => {
|
||||
|
||||
await appRouter.createCaller(fixture.context).vote.submitVote({ voteId: 1, selection: [0] });
|
||||
|
||||
expect(fixture.requestCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
unique: { expected: false, itemKey: null },
|
||||
})
|
||||
);
|
||||
expect(fixture.db.general.findMany).not.toHaveBeenCalled();
|
||||
expect(fixture.db.general.count).not.toHaveBeenCalled();
|
||||
expect(fixture.db.auction.findMany).not.toHaveBeenCalled();
|
||||
expect(fixture.requestCommand.mock.calls[0]?.[0]).not.toHaveProperty('goldReward');
|
||||
expect(fixture.requestCommand.mock.calls[0]?.[0]).not.toHaveProperty('unique');
|
||||
});
|
||||
|
||||
it('rejects voting and comments when the authenticated user owns no general', async () => {
|
||||
|
||||
@@ -97,6 +97,9 @@ const buildContext = (
|
||||
hasGeneral?: boolean;
|
||||
worldMeta?: unknown;
|
||||
liveLogs?: { history: string[]; action: string[] };
|
||||
liveNations?: Array<Record<string, unknown>>;
|
||||
liveCities?: Array<Record<string, unknown>>;
|
||||
liveGenerals?: Array<Record<string, unknown>>;
|
||||
} = {}
|
||||
): GameApiContext => {
|
||||
const db = {
|
||||
@@ -104,13 +107,13 @@ const buildContext = (
|
||||
general: {
|
||||
findFirst: async ({ where }: { where: { userId: string } }) =>
|
||||
options.hasGeneral === false ? null : { id: where.userId === 'owner-a' ? 1 : 2, userId: where.userId },
|
||||
findMany: async () => [],
|
||||
findMany: async () => options.liveGenerals ?? [],
|
||||
},
|
||||
city: {
|
||||
findMany: async () => [],
|
||||
findMany: async () => options.liveCities ?? [],
|
||||
},
|
||||
nation: {
|
||||
findMany: async () => [],
|
||||
findMany: async () => options.liveNations ?? [],
|
||||
},
|
||||
logEntry: {
|
||||
findMany: async ({ where }: { where: { category: unknown } }) => {
|
||||
@@ -326,4 +329,82 @@ describe('historical yearbook access from dynasty', () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('uses stored Ref nation projections, canonical neutral values, and descending power in the live month', async () => {
|
||||
const zeroCityStats = {
|
||||
population: 0,
|
||||
agriculture: 0,
|
||||
commerce: 0,
|
||||
security: 0,
|
||||
defence: 0,
|
||||
wall: 0,
|
||||
populationMax: 1,
|
||||
agricultureMax: 1,
|
||||
commerceMax: 1,
|
||||
securityMax: 1,
|
||||
defenceMax: 1,
|
||||
wallMax: 1,
|
||||
};
|
||||
const caller = appRouter.createCaller(
|
||||
buildContext(authFor('owner-a'), {
|
||||
liveNations: [
|
||||
{
|
||||
id: 0,
|
||||
name: '오염된 재야',
|
||||
color: '#ffffff',
|
||||
level: 9,
|
||||
gold: 0,
|
||||
rice: 0,
|
||||
tech: 0,
|
||||
meta: { power: 90, gennum: 90 },
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: '촉',
|
||||
color: '#ff0000',
|
||||
level: 7,
|
||||
gold: 0,
|
||||
rice: 0,
|
||||
tech: 0,
|
||||
meta: { power: 777, gennum: 9 },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '위',
|
||||
color: '#0000ff',
|
||||
level: 7,
|
||||
gold: 0,
|
||||
rice: 0,
|
||||
tech: 0,
|
||||
meta: { power: 0, gennum: 2 },
|
||||
},
|
||||
],
|
||||
liveCities: [
|
||||
{ id: 0, name: '낙양', nationId: 0, ...zeroCityStats },
|
||||
{ id: 1, name: '성도', nationId: 1, ...zeroCityStats },
|
||||
{ id: 2, name: '허창', nationId: 2, ...zeroCityStats },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const result = await caller.yearbook.getHistory({ year: 220, month: 1 });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
notModified: false,
|
||||
data: {
|
||||
nations: [
|
||||
expect.objectContaining({ id: 1, power: 777, generalCount: 9 }),
|
||||
expect.objectContaining({
|
||||
id: 0,
|
||||
name: '재야',
|
||||
color: '#000000',
|
||||
level: 0,
|
||||
power: 1,
|
||||
generalCount: 1,
|
||||
}),
|
||||
expect.objectContaining({ id: 2, power: 0, generalCount: 2 }),
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,30 +812,30 @@ 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());
|
||||
pushLogs(world, logs);
|
||||
await finalizeStatus('FINISHED');
|
||||
|
||||
@@ -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,14 +171,59 @@ 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)
|
||||
const trait =
|
||||
candidate.specialDomestic && isEventDomesticTraitKey(candidate.specialDomestic)
|
||||
? await eventDomesticTraitLoader.load(candidate.specialDomestic)
|
||||
: null;
|
||||
const warTrait =
|
||||
@@ -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,
|
||||
...(!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: '유니크 판정이 일치하지 않습니다.',
|
||||
};
|
||||
|
||||
if (votePersistence === null) {
|
||||
votePersistence = await insertVoteSelection(ctx, command, general, sortedSelection);
|
||||
}
|
||||
} else if (itemKey) {
|
||||
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 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 ?? 0) + (nation.rice ?? 0) + generalStats.goldRice) / 100);
|
||||
const tech = asNumber(asRecord(nation.meta).tech, 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);
|
||||
const power = Math.round((resource + tech + cityPower + generalStats.statPower + expDed) / 10);
|
||||
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);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@sammo-ts/infra', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
createGamePostgresConnector: vi.fn(() => ({
|
||||
connect: vi.fn(async () => undefined),
|
||||
disconnect: vi.fn(async () => undefined),
|
||||
prisma: {},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
buildAuctionOutbidRefundMessage,
|
||||
createAuctionBidder,
|
||||
hasAuctionBidClosePassed,
|
||||
hasAuctionClosePassed,
|
||||
hasEnoughResourceForAuctionBid,
|
||||
MIN_AUCTION_REMAINING_RESOURCE,
|
||||
} from '../src/auction/bidder.js';
|
||||
import { normalizeTurnDaemonCommand } from '../src/turn/commandRegistry.js';
|
||||
import type { TurnGeneral } from '../src/turn/types.js';
|
||||
|
||||
const bidder: TurnGeneral = {
|
||||
id: 7,
|
||||
name: '관우',
|
||||
nationId: 1,
|
||||
cityId: 1,
|
||||
troopId: 0,
|
||||
stats: { leadership: 80, strength: 90, intelligence: 70 },
|
||||
turnTime: new Date('0190-01-01T00:00:00.000Z'),
|
||||
recentWarTime: null,
|
||||
role: {
|
||||
items: { horse: null, weapon: null, book: null, item: null },
|
||||
personality: null,
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
},
|
||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
meta: { killturn: 24 },
|
||||
penalty: {},
|
||||
officerLevel: 1,
|
||||
experience: 0,
|
||||
dedication: 0,
|
||||
injury: 0,
|
||||
gold: 2_000,
|
||||
rice: 2_000,
|
||||
crew: 0,
|
||||
crewTypeId: 0,
|
||||
train: 0,
|
||||
atmos: 0,
|
||||
age: 30,
|
||||
npcState: 0,
|
||||
picture: 'generals/7.png',
|
||||
};
|
||||
|
||||
const runDelayedResourceBid = async (finishImmediately: boolean) => {
|
||||
const acceptedAt = new Date('0190-02-01T00:00:00.000Z');
|
||||
const processingAt = new Date(acceptedAt.getTime() + 30 * 60_000);
|
||||
const general = {
|
||||
...bidder,
|
||||
role: { ...bidder.role, items: { ...bidder.role.items } },
|
||||
meta: { ...bidder.meta },
|
||||
};
|
||||
const world = {
|
||||
getGameNow: () => processingAt,
|
||||
gameTickToDate: (tick: number) => new Date(acceptedAt.getTime() + (tick - 100) * 1_000),
|
||||
dateToGameTick: (date: Date) => 100 + Math.floor((date.getTime() - acceptedAt.getTime()) / 1_000),
|
||||
getState: () => ({ tickSeconds: 600 }),
|
||||
getGeneralById: (id: number) => (id === general.id ? general : null),
|
||||
updateGeneral: (_id: number, patch: Partial<TurnGeneral>) => Object.assign(general, patch),
|
||||
};
|
||||
const executeRaw = vi.fn(async (_query: unknown) => 1);
|
||||
const commandDb = {
|
||||
$queryRaw: vi.fn(async (query: { strings: readonly string[] }) => {
|
||||
const text = query.strings.join(' ');
|
||||
if (text.includes('FROM auction') && !text.includes('auction_bid')) {
|
||||
return [
|
||||
{
|
||||
id: 31,
|
||||
type: 'BUY_RICE',
|
||||
targetCode: '100',
|
||||
hostGeneralId: 88,
|
||||
detail: {
|
||||
title: '쌀 100 경매',
|
||||
amount: 100,
|
||||
isReverse: false,
|
||||
startBidAmount: 100,
|
||||
finishBidAmount: finishImmediately ? 500 : null,
|
||||
},
|
||||
status: 'OPEN',
|
||||
closeAt: new Date(acceptedAt.getTime() + 60_000),
|
||||
closeTick: 160n,
|
||||
latestEventId: 'previous-event',
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
$executeRaw: executeRaw,
|
||||
$executeRawUnsafe: vi.fn(async () => 1),
|
||||
};
|
||||
const auctionBidder = await createAuctionBidder({
|
||||
databaseUrl: 'postgresql://unused',
|
||||
world: world as unknown as Parameters<typeof createAuctionBidder>[0]['world'],
|
||||
});
|
||||
const amount = finishImmediately ? 500 : 200;
|
||||
const result = await auctionBidder.bid(
|
||||
{
|
||||
type: 'auctionBid',
|
||||
auctionId: 31,
|
||||
generalId: general.id,
|
||||
amount,
|
||||
acceptedGameTick: 100,
|
||||
},
|
||||
commandDb as any
|
||||
);
|
||||
await auctionBidder.close();
|
||||
|
||||
const statements = executeRaw.mock.calls.map(
|
||||
([query]) => query as { strings: readonly string[]; values: unknown[] }
|
||||
);
|
||||
const insert = statements.find((query) => query.strings.join(' ').includes('INSERT INTO auction_bid'));
|
||||
const update = statements.find((query) => query.strings.join(' ').includes('UPDATE auction'));
|
||||
return { acceptedAt, processingAt, result, insert, update };
|
||||
};
|
||||
|
||||
describe('resource auction Ref compatibility', () => {
|
||||
it('keeps the auction open through its exact close tick', () => {
|
||||
const closeAt = new Date('0190-02-01T00:00:00.000Z');
|
||||
const auction = { closeAt, closeTick: 72_000_000n };
|
||||
|
||||
expect(hasAuctionClosePassed(auction, closeAt, 72_000_000)).toBe(false);
|
||||
expect(hasAuctionClosePassed(auction, new Date(closeAt.getTime() + 1), 72_000_001)).toBe(true);
|
||||
expect(hasAuctionClosePassed({ closeAt, closeTick: null }, closeAt, null)).toBe(false);
|
||||
expect(hasAuctionClosePassed({ closeAt, closeTick: null }, new Date(closeAt.getTime() + 1), null)).toBe(true);
|
||||
});
|
||||
|
||||
it('uses the durable API acceptance tick when queue processing crosses the close boundary', () => {
|
||||
const closeAt = new Date('0190-02-01T00:00:00.000Z');
|
||||
const auction = { closeAt, closeTick: 72_000_000n };
|
||||
const world = {
|
||||
dateToGameTick: () => 72_000_001,
|
||||
gameTickToDate: (tick: number) => (tick === 72_000_000 ? closeAt : new Date(closeAt.getTime() + 1)),
|
||||
};
|
||||
const processingNow = new Date(closeAt.getTime() + 1);
|
||||
|
||||
expect(hasAuctionBidClosePassed(auction, world, processingNow, 72_000_000)).toBe(false);
|
||||
expect(hasAuctionBidClosePassed(auction, world, processingNow)).toBe(true);
|
||||
expect(
|
||||
normalizeTurnDaemonCommand({
|
||||
requestId: 'auction-bid-accepted-tick',
|
||||
sentAt: '2026-08-23T00:00:00.000Z',
|
||||
command: {
|
||||
type: 'auctionBid',
|
||||
auctionId: 31,
|
||||
generalId: 7,
|
||||
amount: 500,
|
||||
acceptedGameTick: 72_000_000,
|
||||
},
|
||||
})
|
||||
).toMatchObject({ acceptedGameTick: 72_000_000 });
|
||||
});
|
||||
|
||||
it('uses the accepted logical time for delayed extension and persisted bid timestamps', async () => {
|
||||
const { acceptedAt, processingAt, result, insert, update } = await runDelayedResourceBid(false);
|
||||
|
||||
expect(result).toMatchObject({ type: 'auctionBid', ok: true });
|
||||
expect(new Date(String(result && 'closeAt' in result ? result.closeAt : '')).getTime()).toBe(
|
||||
acceptedAt.getTime() + 100_000
|
||||
);
|
||||
expect(insert?.values.filter((value): value is Date => value instanceof Date)).toEqual([acceptedAt]);
|
||||
expect(update?.values.filter((value): value is Date => value instanceof Date)).toEqual([
|
||||
new Date(acceptedAt.getTime() + 100_000),
|
||||
acceptedAt,
|
||||
acceptedAt,
|
||||
]);
|
||||
expect(update?.values).not.toContain(processingAt);
|
||||
});
|
||||
|
||||
it('uses the accepted logical time for a delayed finish-price one-turn close', async () => {
|
||||
const { acceptedAt, result, update } = await runDelayedResourceBid(true);
|
||||
|
||||
expect(result).toMatchObject({ type: 'auctionBid', ok: true });
|
||||
expect(new Date(String(result && 'closeAt' in result ? result.closeAt : '')).getTime()).toBe(
|
||||
acceptedAt.getTime() + 10 * 60_000
|
||||
);
|
||||
expect(update?.values[0]).toEqual(new Date(acceptedAt.getTime() + 10 * 60_000));
|
||||
});
|
||||
|
||||
it('requires the bidder to retain the default 1000 resource', () => {
|
||||
expect(MIN_AUCTION_REMAINING_RESOURCE).toBe(1_000);
|
||||
expect(hasEnoughResourceForAuctionBid(1_500, 500)).toBe(true);
|
||||
expect(hasEnoughResourceForAuctionBid(1_499, 500)).toBe(false);
|
||||
expect(hasEnoughResourceForAuctionBid(2_000, 0)).toBe(false);
|
||||
});
|
||||
|
||||
it('builds the receiver-only system message used by Ref refundBid', () => {
|
||||
const time = new Date('0190-02-01T00:00:00.000Z');
|
||||
const message = buildAuctionOutbidRefundMessage({
|
||||
auctionId: 31,
|
||||
title: '쌀 100 경매',
|
||||
bidder,
|
||||
nation: { name: '촉', color: '#ff0000' },
|
||||
time,
|
||||
});
|
||||
|
||||
expect(message).toMatchObject({
|
||||
msgType: 'private',
|
||||
src: { generalId: 0, nationName: 'System' },
|
||||
dest: { generalId: 7, generalName: '관우', nationId: 1, nationName: '촉' },
|
||||
text: '31번 쌀 100 경매에 상회입찰자가 나타났습니다.',
|
||||
sendDestOnly: true,
|
||||
});
|
||||
expect(message.time).not.toBe(time);
|
||||
expect(message.time).toEqual(time);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,446 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
|
||||
|
||||
vi.mock('@sammo-ts/infra', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
createGamePostgresConnector: vi.fn(() => ({
|
||||
connect: vi.fn(async () => undefined),
|
||||
disconnect: vi.fn(async () => undefined),
|
||||
prisma: {},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
buildAuctionCancellationMessage,
|
||||
buildUniqueAuctionAwardLogs,
|
||||
buildUniqueAuctionInheritanceLogData,
|
||||
createAuctionFinalizer,
|
||||
hasAuctionFinalizeDeadlineArrived,
|
||||
isAuctionFinalizeGenerationCurrent,
|
||||
isUniqueAuctionSupplyExhausted,
|
||||
resolveAuctionResourceAmount,
|
||||
resolveUniqueSupplyRetryCloseAt,
|
||||
} from '../src/auction/finalizer.js';
|
||||
import { buildInitialUniqueAuctionBidMeta, openAuction } from '../src/auction/opener.js';
|
||||
import type { TurnGeneral } from '../src/turn/types.js';
|
||||
|
||||
describe('unique auction inheritance log compatibility', () => {
|
||||
it('keeps the authenticated UUID owner instead of coercing it to a legacy number', () => {
|
||||
const userId = '4c2f2f6d-8a37-4f22-a4f9-1a6f5e4c22ec';
|
||||
|
||||
expect(
|
||||
buildUniqueAuctionInheritanceLogData({
|
||||
userId,
|
||||
year: 193,
|
||||
month: 7,
|
||||
itemName: '논어(+7)',
|
||||
amount: 6_000,
|
||||
})
|
||||
).toEqual({
|
||||
userId,
|
||||
year: 193,
|
||||
month: 7,
|
||||
logType: 'inheritPoint',
|
||||
text: '유니크 논어(+7) 경매로 6000 포인트 사용',
|
||||
});
|
||||
});
|
||||
|
||||
it('tracks the complete opening bid so rollback can restore rank and point escrow', () => {
|
||||
expect(buildInitialUniqueAuctionBidMeta('익명의 수배자', 6_000)).toEqual({
|
||||
obfuscatedName: '익명의 수배자',
|
||||
tryExtendCloseDate: false,
|
||||
inheritSpentTrackedAmount: 6_000,
|
||||
});
|
||||
});
|
||||
|
||||
it('deducts and mirrors the initial unique bid in the same engine transaction', async () => {
|
||||
const general = {
|
||||
id: 7,
|
||||
name: '관우',
|
||||
nationId: 1,
|
||||
role: { items: { horse: null, weapon: null, book: null, item: null } },
|
||||
inheritancePoints: { previous: 7_000 },
|
||||
meta: { killturn: 24, inherit_spent_dyn: 0 },
|
||||
} as unknown as TurnGeneral;
|
||||
const captured: {
|
||||
auction: { bids: { create: { meta: unknown } } } | null;
|
||||
pointUpdate: { data: unknown } | null;
|
||||
} = { auction: null, pointUpdate: null };
|
||||
const executedSql: string[] = [];
|
||||
const db = {
|
||||
auction: {
|
||||
findFirst: async () => null,
|
||||
create: async ({ data }: { data: Record<string, unknown> }) => {
|
||||
captured.auction = data as unknown as NonNullable<typeof captured.auction>;
|
||||
return { id: 31 };
|
||||
},
|
||||
},
|
||||
inheritancePoint: {
|
||||
update: async (args: Record<string, unknown>) => {
|
||||
captured.pointUpdate = args as unknown as NonNullable<typeof captured.pointUpdate>;
|
||||
return args;
|
||||
},
|
||||
},
|
||||
$queryRaw: async (query: { strings: readonly string[] }) => {
|
||||
const text = query.strings.join(' ');
|
||||
if (text.includes('user_id as "userId"')) return [{ userId: 'owner-uuid' }];
|
||||
if (text.includes('FROM inheritance_point')) return [{ value: 7_000 }];
|
||||
return [];
|
||||
},
|
||||
$executeRaw: async (query: { strings: readonly string[] }) => {
|
||||
executedSql.push(query.strings.join(' '));
|
||||
return 1;
|
||||
},
|
||||
};
|
||||
const world = {
|
||||
getGeneralById: (id: number) => (id === general.id ? general : null),
|
||||
getScenarioConfig: () => ({
|
||||
const: {
|
||||
inheritItemUniqueMinPoint: 5_000,
|
||||
allItems: { weapon: { che_무기_12_칠성검: 1 } },
|
||||
},
|
||||
}),
|
||||
listGenerals: () => [general],
|
||||
getState: () => ({
|
||||
id: 1,
|
||||
currentYear: 193,
|
||||
currentMonth: 7,
|
||||
tickSeconds: 600,
|
||||
meta: { initYear: 190, initMonth: 1, hiddenSeed: 'seed' },
|
||||
}),
|
||||
getGameNow: () => new Date('0193-07-01T00:00:00.000Z'),
|
||||
dateToGameTick: (date: Date) => Math.floor(date.getTime() / 1_000),
|
||||
updateGeneral: (_id: number, patch: Partial<TurnGeneral>) => Object.assign(general, patch),
|
||||
pushLog: () => {},
|
||||
};
|
||||
|
||||
const result = await openAuction(
|
||||
{
|
||||
type: 'auctionOpen',
|
||||
auctionType: 'UNIQUE_ITEM',
|
||||
generalId: general.id,
|
||||
amount: 6_000,
|
||||
itemKey: 'che_무기_12_칠성검',
|
||||
},
|
||||
world as unknown as Parameters<typeof openAuction>[1],
|
||||
db as unknown as NonNullable<Parameters<typeof openAuction>[2]>
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ type: 'auctionOpen', ok: true, auctionId: 31 });
|
||||
expect(captured.auction?.bids.create.meta).toEqual(
|
||||
expect.objectContaining({ inheritSpentTrackedAmount: 6_000 })
|
||||
);
|
||||
expect(captured.pointUpdate?.data).toEqual({ value: 1_000 });
|
||||
expect(executedSql.some((text) => text.includes('INSERT INTO rank_data'))).toBe(true);
|
||||
expect(general.inheritancePoints?.previous).toBe(1_000);
|
||||
expect(general.meta.inherit_spent_dyn).toBe(6_000);
|
||||
});
|
||||
|
||||
it('recovers a resource amount from the legacy target field when detail is malformed', () => {
|
||||
expect(resolveAuctionResourceAmount(undefined, '1200')).toBe(1_200);
|
||||
expect(resolveAuctionResourceAmount(900, '1200')).toBe(900);
|
||||
expect(resolveAuctionResourceAmount(undefined, 'invalid')).toBeNull();
|
||||
});
|
||||
|
||||
it('uses the Ref-inclusive close boundary and the logical tick as the deadline generation', () => {
|
||||
const closeAt = new Date('0193-07-01T00:00:00.000Z');
|
||||
const auction = { closeAt, closeTick: 72_000_000n };
|
||||
|
||||
expect(hasAuctionFinalizeDeadlineArrived(auction, closeAt, 71_999_999)).toBe(false);
|
||||
expect(hasAuctionFinalizeDeadlineArrived(auction, closeAt, 72_000_000)).toBe(true);
|
||||
expect(
|
||||
isAuctionFinalizeGenerationCurrent(auction, {
|
||||
expectedCloseAt: new Date(closeAt.getTime() + 60_000).toISOString(),
|
||||
expectedCloseTick: 72_000_000,
|
||||
})
|
||||
).toBe(true);
|
||||
expect(isAuctionFinalizeGenerationCurrent(auction, { expectedCloseTick: 72_000_001 })).toBe(false);
|
||||
});
|
||||
|
||||
it('locks a due OPEN row, owns OPEN to FINALIZING, and settles through the same transaction client', async () => {
|
||||
const closeAt = new Date('0193-07-01T00:00:00.000Z');
|
||||
const queryTexts: string[] = [];
|
||||
const executeTexts: string[] = [];
|
||||
const commandDb = {
|
||||
$queryRaw: vi.fn(async (query: { strings: readonly string[] }) => {
|
||||
const text = query.strings.join(' ');
|
||||
queryTexts.push(text);
|
||||
if (text.includes('FROM auction_bid')) return [];
|
||||
return [
|
||||
{
|
||||
id: 31,
|
||||
type: 'BUY_RICE',
|
||||
targetCode: '100',
|
||||
hostGeneralId: 0,
|
||||
hostName: '(상인)',
|
||||
detail: { amount: 100 },
|
||||
status: 'OPEN',
|
||||
closeAt,
|
||||
closeTick: 72_000_000n,
|
||||
},
|
||||
];
|
||||
}),
|
||||
$executeRaw: vi.fn(async (query: { strings: readonly string[] }) => {
|
||||
executeTexts.push(query.strings.join(' '));
|
||||
return 1;
|
||||
}),
|
||||
};
|
||||
const world = {
|
||||
getGameNow: () => closeAt,
|
||||
dateToGameTick: () => 72_000_000,
|
||||
pushLog: vi.fn(),
|
||||
};
|
||||
const finalizer = await createAuctionFinalizer({
|
||||
databaseUrl: 'postgresql://unused',
|
||||
world: world as unknown as Parameters<typeof createAuctionFinalizer>[0]['world'],
|
||||
});
|
||||
|
||||
await expect(
|
||||
finalizer.finalize(
|
||||
{
|
||||
type: 'auctionFinalize',
|
||||
auctionId: 31,
|
||||
expectedCloseAt: closeAt.toISOString(),
|
||||
expectedCloseTick: 72_000_000,
|
||||
},
|
||||
commandDb as unknown as NonNullable<Parameters<typeof finalizer.finalize>[1]>
|
||||
)
|
||||
).resolves.toEqual({ type: 'auctionFinalize', ok: true, auctionId: 31 });
|
||||
expect(queryTexts[0]).toContain('FOR UPDATE');
|
||||
expect(executeTexts[0]).toContain("SET status = 'FINALIZING'");
|
||||
expect(executeTexts[1]).toContain('SET status =');
|
||||
expect(executeTexts[1]).toContain('finished_at');
|
||||
|
||||
await finalizer.close();
|
||||
});
|
||||
|
||||
it('leaves OPEN unchanged when the event generation is stale or the locked deadline is not due', async () => {
|
||||
const closeAt = new Date('0193-07-01T00:00:00.000Z');
|
||||
const executeRaw = vi.fn(async () => 1);
|
||||
const commandDb = {
|
||||
$queryRaw: vi.fn(async () => [
|
||||
{
|
||||
id: 31,
|
||||
type: 'BUY_RICE',
|
||||
targetCode: '100',
|
||||
hostGeneralId: 0,
|
||||
hostName: '(상인)',
|
||||
detail: { amount: 100 },
|
||||
status: 'OPEN',
|
||||
closeAt,
|
||||
closeTick: 72_000_000n,
|
||||
},
|
||||
]),
|
||||
$executeRaw: executeRaw,
|
||||
};
|
||||
let nowTick = 71_999_999;
|
||||
const world = {
|
||||
getGameNow: () => closeAt,
|
||||
dateToGameTick: () => nowTick,
|
||||
};
|
||||
const finalizer = await createAuctionFinalizer({
|
||||
databaseUrl: 'postgresql://unused',
|
||||
world: world as unknown as Parameters<typeof createAuctionFinalizer>[0]['world'],
|
||||
});
|
||||
const db = commandDb as unknown as NonNullable<Parameters<typeof finalizer.finalize>[1]>;
|
||||
|
||||
await expect(
|
||||
finalizer.finalize({ type: 'auctionFinalize', auctionId: 31, expectedCloseTick: 72_000_000 }, db)
|
||||
).resolves.toMatchObject({ ok: false, reason: '경매 마감 시각이 아직 지나지 않았습니다.' });
|
||||
nowTick = 72_000_000;
|
||||
await expect(
|
||||
finalizer.finalize({ type: 'auctionFinalize', auctionId: 31, expectedCloseTick: 71_999_999 }, db)
|
||||
).resolves.toMatchObject({ ok: false, reason: '경매 마감 세대가 변경되었습니다.' });
|
||||
expect(executeRaw).not.toHaveBeenCalled();
|
||||
|
||||
await finalizer.close();
|
||||
});
|
||||
|
||||
it('fails before settlement when the locked OPEN transition is not applied', async () => {
|
||||
const closeAt = new Date('0193-07-01T00:00:00.000Z');
|
||||
const queryRaw = vi.fn(async (query: { strings: readonly string[] }) => {
|
||||
if (query.strings.join(' ').includes('FROM auction_bid')) {
|
||||
throw new Error('settlement query must not run');
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: 31,
|
||||
type: 'BUY_RICE',
|
||||
targetCode: '100',
|
||||
hostGeneralId: 0,
|
||||
hostName: '(상인)',
|
||||
detail: { amount: 100 },
|
||||
status: 'OPEN',
|
||||
closeAt,
|
||||
closeTick: null,
|
||||
},
|
||||
];
|
||||
});
|
||||
const commandDb = { $queryRaw: queryRaw, $executeRaw: vi.fn(async () => 0) };
|
||||
const world = { getGameNow: () => closeAt, dateToGameTick: () => 72_000_000 };
|
||||
const finalizer = await createAuctionFinalizer({
|
||||
databaseUrl: 'postgresql://unused',
|
||||
world: world as unknown as Parameters<typeof createAuctionFinalizer>[0]['world'],
|
||||
});
|
||||
|
||||
await expect(
|
||||
finalizer.finalize(
|
||||
{ type: 'auctionFinalize', auctionId: 31, expectedCloseAt: closeAt.toISOString() },
|
||||
commandDb as unknown as NonNullable<Parameters<typeof finalizer.finalize>[1]>
|
||||
)
|
||||
).rejects.toThrow('경매 확정 상태 전이에 실패했습니다: 31');
|
||||
expect(queryRaw).toHaveBeenCalledTimes(1);
|
||||
|
||||
await finalizer.close();
|
||||
});
|
||||
|
||||
it('keeps both escrows and FINALIZING when a resource amount cannot be recovered', async () => {
|
||||
const bidder = {
|
||||
id: 7,
|
||||
name: '관우',
|
||||
nationId: 1,
|
||||
gold: 1_000,
|
||||
rice: 1_000,
|
||||
} as TurnGeneral;
|
||||
const host = {
|
||||
id: 8,
|
||||
name: '장비',
|
||||
nationId: 1,
|
||||
gold: 1_000,
|
||||
rice: 1_000,
|
||||
} as TurnGeneral;
|
||||
const updateGeneral = vi.fn();
|
||||
const queueMessage = vi.fn();
|
||||
const world = {
|
||||
getGameNow: () => new Date('0193-07-01T00:00:00.000Z'),
|
||||
getGeneralById: (id: number) => (id === bidder.id ? bidder : id === host.id ? host : null),
|
||||
getNationById: () => ({ name: '촉', color: '#ff0000' }),
|
||||
updateGeneral,
|
||||
queueMessage,
|
||||
};
|
||||
const executeRaw = vi.fn(async () => 1);
|
||||
const commandDb = {
|
||||
$queryRaw: vi.fn(async (query: { strings: readonly string[] }) =>
|
||||
query.strings.join(' ').includes('FROM auction_bid')
|
||||
? [{ id: 41, generalId: bidder.id, amount: 500, meta: {} }]
|
||||
: [
|
||||
{
|
||||
id: 31,
|
||||
type: 'BUY_RICE',
|
||||
targetCode: 'invalid',
|
||||
hostGeneralId: host.id,
|
||||
hostName: host.name,
|
||||
detail: { title: '손상된 경매', isReverse: false },
|
||||
status: 'FINALIZING',
|
||||
closeAt: new Date('0193-07-01T00:00:00.000Z'),
|
||||
closeTick: null,
|
||||
},
|
||||
]
|
||||
),
|
||||
$executeRaw: executeRaw,
|
||||
};
|
||||
const finalizer = await createAuctionFinalizer({
|
||||
databaseUrl: 'postgresql://unused',
|
||||
world: world as unknown as Parameters<typeof createAuctionFinalizer>[0]['world'],
|
||||
});
|
||||
|
||||
await expect(
|
||||
finalizer.finalize(
|
||||
{ type: 'auctionFinalize', auctionId: 31 },
|
||||
commandDb as unknown as NonNullable<Parameters<typeof finalizer.finalize>[1]>
|
||||
)
|
||||
).resolves.toMatchObject({
|
||||
type: 'auctionFinalize',
|
||||
ok: false,
|
||||
auctionId: 31,
|
||||
reason: '경매 거래량 정보가 없습니다.',
|
||||
});
|
||||
expect(executeRaw).not.toHaveBeenCalled();
|
||||
expect(updateGeneral).not.toHaveBeenCalled();
|
||||
expect(queueMessage).not.toHaveBeenCalled();
|
||||
expect(bidder.gold).toBe(1_000);
|
||||
expect(host.rice).toBe(1_000);
|
||||
|
||||
await finalizer.close();
|
||||
});
|
||||
|
||||
it('rejects a final award once the configured unique supply is occupied', () => {
|
||||
expect(isUniqueAuctionSupplyExhausted(2, 1)).toBe(false);
|
||||
expect(isUniqueAuctionSupplyExhausted(2, 2)).toBe(true);
|
||||
expect(resolveUniqueSupplyRetryCloseAt(new Date('0193-07-01T00:00:00.000Z'), 10).toISOString()).toBe(
|
||||
'0193-07-01T00:10:00.000Z'
|
||||
);
|
||||
});
|
||||
|
||||
it('builds all four Ref award logs with the original formats and labels', () => {
|
||||
const bidder = {
|
||||
id: 7,
|
||||
name: '관우',
|
||||
nationId: 1,
|
||||
} as TurnGeneral;
|
||||
|
||||
expect(
|
||||
buildUniqueAuctionAwardLogs({
|
||||
bidder,
|
||||
nationName: '촉',
|
||||
itemName: '칠성검(+12)',
|
||||
itemRawName: '칠성검',
|
||||
})
|
||||
).toEqual([
|
||||
expect.objectContaining({
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.MONTH,
|
||||
generalId: 7,
|
||||
text: '<C>칠성검(+12)</>을 습득했습니다!',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.HISTORY,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
generalId: 7,
|
||||
text: '<C>칠성검(+12)</>을 습득',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
scope: LogScope.SYSTEM,
|
||||
category: LogCategory.SUMMARY,
|
||||
format: LogFormat.MONTH,
|
||||
text: '<Y>관우</>가 <C>칠성검(+12)</>을 습득했습니다!',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
scope: LogScope.SYSTEM,
|
||||
category: LogCategory.HISTORY,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
text: '<C><b>【보물수배】</b></><D><b>촉</b></>의 <Y>관우</>가 <C>칠성검(+12)</>을 습득했습니다!',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds the Ref cancellation refund message for the affected bidder only', () => {
|
||||
const bidder = {
|
||||
id: 7,
|
||||
name: '관우',
|
||||
nationId: 1,
|
||||
picture: 'generals/7.png',
|
||||
} as TurnGeneral;
|
||||
const time = new Date('0193-07-01T00:00:00.000Z');
|
||||
|
||||
expect(
|
||||
buildAuctionCancellationMessage({
|
||||
auctionId: 31,
|
||||
title: '논어 경매',
|
||||
bidder,
|
||||
nation: { name: '촉', color: '#ff0000' },
|
||||
time,
|
||||
})
|
||||
).toMatchObject({
|
||||
msgType: 'private',
|
||||
dest: { generalId: 7, nationId: 1, nationName: '촉' },
|
||||
text: '31번 논어 경매가 취소되었습니다.',
|
||||
sendDestOnly: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||
|
||||
import { loadGeneralPoolEntries } from '../src/scenario/generalPoolLoader.js';
|
||||
import {
|
||||
buildSelectPoolSeed,
|
||||
calculateSelectionCandidateWeight,
|
||||
claimWeightedSelectionCandidates,
|
||||
type SelectPoolCandidateInfo,
|
||||
} from '../src/turn/selectPoolService.js';
|
||||
|
||||
const toCandidate = (info: Record<string, unknown>): SelectPoolCandidateInfo => {
|
||||
const dex = info.dex;
|
||||
if (
|
||||
typeof info.uniqueName !== 'string' ||
|
||||
typeof info.generalName !== 'string' ||
|
||||
typeof info.leadership !== 'number' ||
|
||||
typeof info.strength !== 'number' ||
|
||||
typeof info.intel !== 'number' ||
|
||||
(info.specialDomestic !== null && typeof info.specialDomestic !== 'string') ||
|
||||
!Array.isArray(dex) ||
|
||||
dex.length !== 5 ||
|
||||
dex.some((value) => typeof value !== 'number') ||
|
||||
(info.imgsvr !== 0 && info.imgsvr !== 1) ||
|
||||
typeof info.picture !== 'string'
|
||||
) {
|
||||
throw new Error('invalid SPoolUnderU100 test entry');
|
||||
}
|
||||
return {
|
||||
uniqueName: info.uniqueName,
|
||||
generalName: info.generalName,
|
||||
leadership: info.leadership,
|
||||
strength: info.strength,
|
||||
intel: info.intel,
|
||||
specialDomestic: info.specialDomestic,
|
||||
dex: dex as [number, number, number, number, number],
|
||||
imgsvr: info.imgsvr,
|
||||
picture: info.picture,
|
||||
};
|
||||
};
|
||||
|
||||
describe('SPoolUnderU100 deterministic selection', () => {
|
||||
it('uses the Ref user/NPC weight contract including the zero-dex floor', () => {
|
||||
const candidate = toCandidate({
|
||||
uniqueName: 'A1000001',
|
||||
generalName: 'weight fixture',
|
||||
leadership: 70,
|
||||
strength: 60,
|
||||
intel: 60,
|
||||
specialDomestic: null,
|
||||
dex: [0, 0, 0, 0, 0],
|
||||
imgsvr: 0,
|
||||
picture: '0',
|
||||
});
|
||||
|
||||
expect(calculateSelectionCandidateWeight('SPoolUnderU100', candidate, false)).toBe(100_000);
|
||||
expect(calculateSelectionCandidateWeight('SPoolUnderU100', candidate, true)).toBe(150_000);
|
||||
});
|
||||
|
||||
it('keeps the fixed-seed 14-candidate draw stable', async () => {
|
||||
const entries = await loadGeneralPoolEntries('SPoolUnderU100');
|
||||
const rows = entries.map((entry, index) => ({ id: index + 1, ...entry }));
|
||||
const rng = new RandUtil(new LiteHashDRBG(buildSelectPoolSeed('s100-vector-hidden', 42, 72_000_000)));
|
||||
const draws: string[] = [];
|
||||
const selected = await claimWeightedSelectionCandidates({
|
||||
weighted: rows.map((row) => [
|
||||
row,
|
||||
calculateSelectionCandidateWeight('SPoolUnderU100', toCandidate(row.info), true),
|
||||
]),
|
||||
rng,
|
||||
count: 14,
|
||||
claim: async () => true,
|
||||
onDraw: (candidate) => draws.push(candidate.uniqueName),
|
||||
});
|
||||
|
||||
expect(selected.map((candidate) => candidate.uniqueName)).toEqual([
|
||||
'A1004478',
|
||||
'A1000583',
|
||||
'A1002480',
|
||||
'A1001485',
|
||||
'A1002714',
|
||||
'A1004544',
|
||||
'A1000918',
|
||||
'A1004549',
|
||||
'A1003871',
|
||||
'A1002678',
|
||||
'A1000531',
|
||||
'A1003379',
|
||||
'A1004275',
|
||||
'A1003449',
|
||||
]);
|
||||
expect(draws).toEqual(selected.map((candidate) => candidate.uniqueName));
|
||||
});
|
||||
|
||||
it('keeps duplicate draws in the RNG stream while claiming each row once', async () => {
|
||||
const candidates = [
|
||||
{ id: 1, uniqueName: 'first' },
|
||||
{ id: 2, uniqueName: 'second' },
|
||||
];
|
||||
const draws: string[] = [];
|
||||
const selected = await claimWeightedSelectionCandidates({
|
||||
weighted: [
|
||||
[candidates[0]!, 3],
|
||||
[candidates[1]!, 1],
|
||||
],
|
||||
rng: new RandUtil(new LiteHashDRBG('s100-duplicate-retry-vector')),
|
||||
count: 2,
|
||||
claim: async () => true,
|
||||
onDraw: (candidate) => draws.push(candidate.uniqueName),
|
||||
});
|
||||
|
||||
expect(draws).toEqual(['first', 'first', 'first', 'first', 'first', 'first', 'first', 'second']);
|
||||
expect(selected.map((candidate) => candidate.uniqueName)).toEqual(['first', 'second']);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { City, General, Nation } from '@sammo-ts/logic';
|
||||
import { loadItemModules, type City, type General, type Nation } from '@sammo-ts/logic';
|
||||
import { createRefOrderedActionStack } from '@sammo-ts/logic/actionModules/bundle.js';
|
||||
|
||||
import { GeneralAI, shouldUseNationAi } from '../src/turn/ai/generalAi.js';
|
||||
@@ -712,9 +712,7 @@ describe('legacy NPC user-chief promotion parity', () => {
|
||||
expect(run(2)).toEqual([]);
|
||||
expect(run(3)).toEqual([{ generalId: 2, officerLevel: 11, officerCity: 0, permission: 'ambassador' }]);
|
||||
expect(run(3, 0)).toEqual([]);
|
||||
expect(run(3, 0, 1)).toEqual([
|
||||
{ generalId: 2, officerLevel: 11, officerCity: 0, permission: 'ambassador' },
|
||||
]);
|
||||
expect(run(3, 0, 1)).toEqual([{ generalId: 2, officerLevel: 11, officerCity: 0, permission: 'ambassador' }]);
|
||||
});
|
||||
|
||||
it('keeps user-ruler duties individually disabled until each setting is enabled', () => {
|
||||
@@ -882,6 +880,27 @@ describe('legacy NPC AI final-decision parity', () => {
|
||||
effectiveLeadership: 85,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes scenario time and maximum tech level to year-scaling stat items', async () => {
|
||||
const [leadershipWine] = await loadItemModules(['che_능력치_통솔_보령압주']);
|
||||
expect(leadershipWine).toBeDefined();
|
||||
const modules = singleActionModuleStack(leadershipWine!);
|
||||
const world = {
|
||||
id: 1,
|
||||
currentYear: 200,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
lastTurnTime: new Date('0200-01-01T00:00:00Z'),
|
||||
meta: {},
|
||||
};
|
||||
|
||||
expect(
|
||||
resolveLegacyAiStatsWithModules(baseGeneral(), baseNation(), 100, modules, null, world, 180, 15)
|
||||
).toMatchObject({
|
||||
fullLeadership: 80,
|
||||
effectiveLeadership: 80,
|
||||
});
|
||||
});
|
||||
it.each([
|
||||
['Core scenario name', '강유'],
|
||||
['Ref stored name', 'ⓝ강유'],
|
||||
|
||||
@@ -62,3 +62,34 @@ describe('SPoolUnderU30 resource', () => {
|
||||
await expect(loadGeneralPoolEntries('SPoolUnknown')).rejects.toThrow('Unsupported general pool');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SPoolUnderU100 resource', () => {
|
||||
it('preserves all 4,682 historical candidates and Ref A100 identifiers', async () => {
|
||||
const entries = await loadGeneralPoolEntries('SPoolUnderU100');
|
||||
|
||||
expect(entries).toHaveLength(4_682);
|
||||
expect(new Set(entries.map((entry) => entry.uniqueName)).size).toBe(4_682);
|
||||
expect(entries[0]).toMatchObject({
|
||||
uniqueName: 'A1000001',
|
||||
info: {
|
||||
uniqueName: 'A1000001',
|
||||
generalName: '1·조민',
|
||||
leadership: 85,
|
||||
strength: 69,
|
||||
intel: 12,
|
||||
specialDomestic: 'che_event_무쌍',
|
||||
dex: [54_691, 398_024, 31_027, 89_301, 24_687],
|
||||
sourcePhase: 1,
|
||||
sourceServerId: 'che_180628_z9X4',
|
||||
sourceGeneralNo: 3,
|
||||
event100Growth: true,
|
||||
},
|
||||
});
|
||||
expect(entries.at(-1)).toMatchObject({
|
||||
uniqueName: 'A1004682',
|
||||
info: { generalName: '99·푸른양귀비', sourcePhase: 99 },
|
||||
});
|
||||
expect(entries.filter((entry) => (entry.info.dex as number[]).every((value) => value === 0))).toHaveLength(512);
|
||||
expect(entries.filter((entry) => entry.info.specialDomestic === null)).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ConstantRNG, RandUtil, SequenceRNG } from '@sammo-ts/common';
|
||||
import { parseScenarioGeneralPoolCandidate, readScenarioGeneralPoolClaim, type TurnSchedule } from '@sammo-ts/logic';
|
||||
|
||||
import type { TurnGeneral, TurnGeneralPoolEntry, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
import { createTurnTestHarness } from './helpers/turnTestHarness.js';
|
||||
|
||||
const start = new Date('0200-01-01T00:00:00.000Z');
|
||||
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
|
||||
const map = {
|
||||
id: 'general-pool-same-turn',
|
||||
name: '장수 pool 동일 턴 테스트',
|
||||
cities: [
|
||||
{
|
||||
id: 1,
|
||||
name: '테스트성',
|
||||
level: 1,
|
||||
region: 1,
|
||||
position: { x: 0, y: 0 },
|
||||
connections: [],
|
||||
max: {
|
||||
population: 50_000,
|
||||
agriculture: 1_000,
|
||||
commerce: 1_000,
|
||||
security: 1_000,
|
||||
defence: 1_000,
|
||||
wall: 1_000,
|
||||
},
|
||||
initial: {
|
||||
population: 10_000,
|
||||
agriculture: 500,
|
||||
commerce: 500,
|
||||
security: 500,
|
||||
defence: 500,
|
||||
wall: 500,
|
||||
},
|
||||
},
|
||||
],
|
||||
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
|
||||
};
|
||||
|
||||
const buildRuler = (): TurnGeneral => ({
|
||||
id: 1,
|
||||
userId: 'user-1',
|
||||
name: '군주',
|
||||
nationId: 1,
|
||||
cityId: 1,
|
||||
troopId: 0,
|
||||
stats: { leadership: 80, strength: 70, intelligence: 60 },
|
||||
experience: 0,
|
||||
dedication: 0,
|
||||
officerLevel: 12,
|
||||
role: {
|
||||
personality: null,
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
items: { horse: null, weapon: null, book: null, item: null },
|
||||
},
|
||||
injury: 0,
|
||||
gold: 2_000,
|
||||
rice: 2_000,
|
||||
crew: 0,
|
||||
crewTypeId: 1,
|
||||
train: 40,
|
||||
atmos: 40,
|
||||
age: 30,
|
||||
npcState: 0,
|
||||
bornYear: 170,
|
||||
deadYear: 260,
|
||||
affinity: 50,
|
||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
meta: { killturn: 24 },
|
||||
turnTime: start,
|
||||
});
|
||||
|
||||
const buildPoolEntry = (id: number): TurnGeneralPoolEntry => {
|
||||
const uniqueName = `후보${id}`;
|
||||
return {
|
||||
id,
|
||||
uniqueName,
|
||||
ownerUserId: null,
|
||||
generalId: null,
|
||||
reservedUntil: null,
|
||||
reservedUntilTick: null,
|
||||
candidate: parseScenarioGeneralPoolCandidate({
|
||||
id,
|
||||
uniqueName,
|
||||
info: {
|
||||
generalName: uniqueName,
|
||||
leadership: 70,
|
||||
strength: 70,
|
||||
intel: 10,
|
||||
dex: [10, 10, 10, 10, 10],
|
||||
imgsvr: 0,
|
||||
picture: 'default.jpg',
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const buildSnapshot = (): TurnWorldSnapshot => ({
|
||||
scenarioConfig: {
|
||||
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 },
|
||||
iconPath: '',
|
||||
map: { targetGeneralPool: 'SPoolUnderU30' },
|
||||
const: {
|
||||
develCost: 100,
|
||||
openingPartYear: 3,
|
||||
defaultMaxGeneral: 500,
|
||||
initialNationGenLimit: 10,
|
||||
defaultNpcGold: 1_000,
|
||||
defaultNpcRice: 1_000,
|
||||
defaultCrewTypeId: 1,
|
||||
retirementYear: 80,
|
||||
availablePersonality: ['che_안전'],
|
||||
},
|
||||
environment: { mapName: map.id, unitSet: 'test' },
|
||||
},
|
||||
scenarioMeta: {
|
||||
title: '장수 pool 동일 턴 테스트',
|
||||
startYear: 190,
|
||||
life: null,
|
||||
fiction: 0,
|
||||
history: [],
|
||||
ignoreDefaultEvents: false,
|
||||
},
|
||||
map,
|
||||
unitSet: { id: 'test', name: 'test', crewTypes: [] },
|
||||
nations: [
|
||||
{
|
||||
id: 1,
|
||||
name: '테스트국',
|
||||
color: '#000000',
|
||||
capitalCityId: 1,
|
||||
chiefGeneralId: 1,
|
||||
gold: 10_000,
|
||||
rice: 10_000,
|
||||
power: 0,
|
||||
level: 1,
|
||||
typeCode: 'che_중립',
|
||||
meta: {
|
||||
gennum: 1,
|
||||
tech: 0,
|
||||
strategic_cmd_limit: 0,
|
||||
turn_last_12: { command: '의병모집', arg: {}, term: 2 },
|
||||
},
|
||||
},
|
||||
],
|
||||
cities: [
|
||||
{
|
||||
id: 1,
|
||||
name: '테스트성',
|
||||
nationId: 1,
|
||||
level: 1,
|
||||
state: 0,
|
||||
population: 10_000,
|
||||
populationMax: 50_000,
|
||||
agriculture: 500,
|
||||
agricultureMax: 1_000,
|
||||
commerce: 500,
|
||||
commerceMax: 1_000,
|
||||
security: 500,
|
||||
securityMax: 1_000,
|
||||
supplyState: 1,
|
||||
frontState: 0,
|
||||
defence: 500,
|
||||
defenceMax: 1_000,
|
||||
wall: 500,
|
||||
wallMax: 1_000,
|
||||
meta: { trust: 50, trade: 100, region: 1 },
|
||||
},
|
||||
],
|
||||
generals: [buildRuler()],
|
||||
troops: [],
|
||||
diplomacy: [],
|
||||
events: [],
|
||||
initialEvents: [],
|
||||
generalPoolEntries: [1, 2, 3, 4].map(buildPoolEntry),
|
||||
});
|
||||
|
||||
const buildState = (): TurnWorldState => ({
|
||||
id: 1,
|
||||
currentYear: 200,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
lastTurnTime: start,
|
||||
meta: { killturn: 24, hiddenSeed: 'general-pool-same-turn' },
|
||||
});
|
||||
|
||||
describe('scenario general pool within one reserved turn', () => {
|
||||
it('does not let talent scouting reuse rows claimed earlier by volunteer recruitment', async () => {
|
||||
const harness = await createTurnTestHarness({
|
||||
snapshot: buildSnapshot(),
|
||||
state: buildState(),
|
||||
schedule,
|
||||
map,
|
||||
reservedTurnStoreOptions: { maxGeneralTurns: 10, maxNationTurns: 12 },
|
||||
commandRngFactory: ({ actionKey }) =>
|
||||
actionKey === 'che_의병모집'
|
||||
? new RandUtil(new SequenceRNG([0, 0.26, 0.51, 0.76]))
|
||||
: new RandUtil(new ConstantRNG(0)),
|
||||
});
|
||||
harness.reservedTurnStore.getNationTurns(1, 12)[0] = { action: 'che_의병모집', args: {} };
|
||||
harness.reservedTurnStore.getGeneralTurns(1)[0] = { action: 'che_인재탐색', args: {} };
|
||||
|
||||
await harness.runOneTick();
|
||||
|
||||
const created = harness.world.peekDirtyState().createdGenerals;
|
||||
const claims = created.map((general) => readScenarioGeneralPoolClaim(general.meta));
|
||||
expect(created.map((general) => general.npcState).sort()).toEqual([3, 4, 4, 4]);
|
||||
expect(claims.every(Boolean)).toBe(true);
|
||||
expect(new Set(claims.map((claim) => claim?.poolEntryId))).toEqual(new Set([1, 2, 3, 4]));
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { LEGACY_RANK_DATA_TYPES } from '@sammo-ts/common';
|
||||
import type { TurnSchedule } from '@sammo-ts/logic';
|
||||
import { finalizeLogEntry, type TurnSchedule } from '@sammo-ts/logic';
|
||||
|
||||
import { rankMetaKey } from '../src/turn/rankData.js';
|
||||
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
@@ -159,7 +159,7 @@ const makeState = (meta: Record<string, unknown> = {}): TurnWorldState => ({
|
||||
});
|
||||
|
||||
describe('legacy general turn lifecycle', () => {
|
||||
it('timestamps action logs with the executing general turn instead of the shared flush cursor', async () => {
|
||||
it('timestamps action logs with the executing turn before the same run advances the month', async () => {
|
||||
const flushCursor = new Date('0200-01-01T00:35:00.000Z');
|
||||
const generalTurnTime = new Date('0200-01-01T00:37:43.000Z');
|
||||
const harness = await createTurnTestHarness({
|
||||
@@ -175,7 +175,28 @@ describe('legacy general turn lifecycle', () => {
|
||||
const actionLog = harness.world
|
||||
.peekDirtyState()
|
||||
.logs.find((log) => log.text.includes('아무것도 실행하지 않았습니다.'));
|
||||
expect(actionLog?.occurredAt).toEqual(generalTurnTime);
|
||||
expect(harness.world.getState()).toMatchObject({ currentYear: 200, currentMonth: 2 });
|
||||
expect(actionLog).toMatchObject({
|
||||
year: 200,
|
||||
month: 1,
|
||||
occurredAt: generalTurnTime,
|
||||
});
|
||||
if (!actionLog) {
|
||||
throw new Error('expected the rest action log');
|
||||
}
|
||||
const finalState = harness.world.getState();
|
||||
expect(
|
||||
finalizeLogEntry(actionLog, {
|
||||
year: finalState.currentYear,
|
||||
month: finalState.currentMonth,
|
||||
at: finalState.lastTurnTime,
|
||||
})
|
||||
).toMatchObject({
|
||||
year: 200,
|
||||
month: 1,
|
||||
text: '<C>●</>1월:아무것도 실행하지 않았습니다.',
|
||||
createdAt: generalTurnTime,
|
||||
});
|
||||
});
|
||||
|
||||
it('emits legacy plain logs when command gains cross experience and dedication levels', async () => {
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { GAME_TICKS_PER_TURN } from '@sammo-ts/common';
|
||||
import { buildScenarioGeneralPoolClaimMeta, parseScenarioGeneralPoolCandidate, type City } from '@sammo-ts/logic';
|
||||
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import type { TurnGeneral, TurnGeneralPoolEntry, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
|
||||
const claimedAt = new Date('0200-05-01T00:00:00.000Z');
|
||||
|
||||
const buildCandidateEntry = (
|
||||
id: number,
|
||||
uniqueName: string,
|
||||
patch: Partial<TurnGeneralPoolEntry> = {}
|
||||
): TurnGeneralPoolEntry => ({
|
||||
id,
|
||||
uniqueName,
|
||||
ownerUserId: null,
|
||||
generalId: null,
|
||||
reservedUntil: null,
|
||||
reservedUntilTick: null,
|
||||
candidate: parseScenarioGeneralPoolCandidate({
|
||||
id,
|
||||
uniqueName,
|
||||
info: {
|
||||
generalName: uniqueName,
|
||||
leadership: 70,
|
||||
strength: 80,
|
||||
intel: 10,
|
||||
dex: [10, 20, 30, 40, 50],
|
||||
imgsvr: 0,
|
||||
picture: 'default.jpg',
|
||||
},
|
||||
}),
|
||||
...patch,
|
||||
});
|
||||
|
||||
const buildGeneral = (id: number, name: string, meta: TurnGeneral['meta']): TurnGeneral => ({
|
||||
id,
|
||||
userId: null,
|
||||
name,
|
||||
nationId: 0,
|
||||
cityId: 1,
|
||||
troopId: 0,
|
||||
stats: { leadership: 70, strength: 80, intelligence: 10 },
|
||||
experience: 2_000,
|
||||
dedication: 2_000,
|
||||
officerLevel: 0,
|
||||
role: {
|
||||
personality: null,
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
items: { horse: null, weapon: null, book: null, item: null },
|
||||
},
|
||||
injury: 0,
|
||||
gold: 1_000,
|
||||
rice: 1_000,
|
||||
crew: 0,
|
||||
crewTypeId: 0,
|
||||
train: 0,
|
||||
atmos: 0,
|
||||
age: 20,
|
||||
npcState: 0,
|
||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
turnTime: claimedAt,
|
||||
meta,
|
||||
});
|
||||
|
||||
const city: City = {
|
||||
id: 1,
|
||||
name: '도시',
|
||||
nationId: 0,
|
||||
level: 4,
|
||||
state: 0,
|
||||
population: 10_000,
|
||||
populationMax: 20_000,
|
||||
agriculture: 1_000,
|
||||
agricultureMax: 2_000,
|
||||
commerce: 1_000,
|
||||
commerceMax: 2_000,
|
||||
security: 1_000,
|
||||
securityMax: 2_000,
|
||||
supplyState: 1,
|
||||
frontState: 0,
|
||||
defence: 1_000,
|
||||
defenceMax: 2_000,
|
||||
wall: 1_000,
|
||||
wallMax: 2_000,
|
||||
meta: {},
|
||||
};
|
||||
|
||||
const buildWorld = (
|
||||
generalPoolEntries: TurnGeneralPoolEntry[],
|
||||
generals: TurnGeneral[] = [],
|
||||
stateOverride: Partial<TurnWorldState> = {}
|
||||
): InMemoryTurnWorld => {
|
||||
const state: TurnWorldState = {
|
||||
id: 1,
|
||||
currentYear: 200,
|
||||
currentMonth: 5,
|
||||
tickSeconds: 600,
|
||||
lastTurnTime: claimedAt,
|
||||
meta: {},
|
||||
...stateOverride,
|
||||
};
|
||||
const snapshot: TurnWorldSnapshot = {
|
||||
scenarioConfig: {
|
||||
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 },
|
||||
iconPath: '',
|
||||
map: { targetGeneralPool: 'SPoolUnderU30' },
|
||||
const: {},
|
||||
environment: { mapName: 'test', unitSet: 'default' },
|
||||
},
|
||||
map: {
|
||||
id: 'test',
|
||||
name: 'test',
|
||||
cities: [],
|
||||
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
|
||||
},
|
||||
generals,
|
||||
cities: [city],
|
||||
nations: [],
|
||||
troops: [],
|
||||
diplomacy: [],
|
||||
events: [],
|
||||
initialEvents: [],
|
||||
generalPoolEntries,
|
||||
};
|
||||
return new InMemoryTurnWorld(state, snapshot, {
|
||||
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
|
||||
});
|
||||
};
|
||||
|
||||
describe('in-memory scenario general pool availability', () => {
|
||||
it('uses synchronized reselection rows while excluding live, orphaned, and active reservations', () => {
|
||||
const oldEntry = buildCandidateEntry(1, '이전후보');
|
||||
const currentEntry = buildCandidateEntry(2, '현재후보');
|
||||
const legacyOccupiedEntry = buildCandidateEntry(3, '기존점유', { generalId: 2 });
|
||||
const expiredEntry = buildCandidateEntry(4, '만료예약', {
|
||||
ownerUserId: 'expired-user',
|
||||
reservedUntil: new Date(claimedAt.getTime() + 60_000),
|
||||
reservedUntilTick: -1,
|
||||
});
|
||||
const activeEntry = buildCandidateEntry(5, '활성예약', {
|
||||
ownerUserId: 'active-user',
|
||||
reservedUntil: new Date(claimedAt.getTime() - 60_000),
|
||||
reservedUntilTick: 1,
|
||||
});
|
||||
const orphanedEntry = buildCandidateEntry(6, '고아점유', { generalId: 999 });
|
||||
const exactDeadlineEntry = buildCandidateEntry(7, '동률예약', {
|
||||
ownerUserId: 'exact-user',
|
||||
reservedUntil: new Date(claimedAt.getTime() - 60_000),
|
||||
reservedUntilTick: 0,
|
||||
});
|
||||
const currentClaim = buildScenarioGeneralPoolClaimMeta(currentEntry.candidate, claimedAt);
|
||||
const world = buildWorld(
|
||||
[oldEntry, currentEntry, legacyOccupiedEntry, expiredEntry, activeEntry, orphanedEntry, exactDeadlineEntry],
|
||||
[
|
||||
buildGeneral(1, '현재후보', { killturn: 100, ...currentClaim }),
|
||||
buildGeneral(2, '기존점유', { killturn: 100 }),
|
||||
]
|
||||
);
|
||||
|
||||
expect(world.listGeneralPoolCandidates(claimedAt)?.map((candidate) => candidate.uniqueName)).toEqual([
|
||||
'이전후보',
|
||||
'만료예약',
|
||||
]);
|
||||
});
|
||||
|
||||
it('reuses a linked row only when its general was deleted in the same in-memory batch', () => {
|
||||
const linked = buildCandidateEntry(1, '삭제후보', { generalId: 1 });
|
||||
const world = buildWorld([linked], [buildGeneral(1, '삭제후보', { killturn: 100 })]);
|
||||
|
||||
expect(world.listGeneralPoolCandidates(claimedAt)).toEqual([]);
|
||||
expect(world.removeGeneral(1)).toBe(true);
|
||||
expect(world.listGeneralPoolCandidates(claimedAt)?.map((candidate) => candidate.uniqueName)).toEqual([
|
||||
'삭제후보',
|
||||
]);
|
||||
});
|
||||
|
||||
it('rebases reserved rows with the schedule and restores them on rollback', () => {
|
||||
const reservedUntil = new Date(claimedAt.getTime() + 5 * 60_000);
|
||||
const reserved = buildCandidateEntry(1, '예약후보', {
|
||||
ownerUserId: 'active-user',
|
||||
reservedUntil,
|
||||
reservedUntilTick: GAME_TICKS_PER_TURN / 2,
|
||||
});
|
||||
const unreserved = buildCandidateEntry(2, '미예약후보');
|
||||
const world = buildWorld([reserved, unreserved]);
|
||||
const before = world.captureState();
|
||||
const probeAfterOriginalExpiry = new Date(claimedAt.getTime() + 10 * 60_000);
|
||||
|
||||
world.shiftSchedule(15, claimedAt);
|
||||
|
||||
expect(world.captureState().generalPoolEntries).toMatchObject([
|
||||
{
|
||||
id: 1,
|
||||
reservedUntil: new Date(reservedUntil.getTime() + 15 * 60_000),
|
||||
reservedUntilTick: GAME_TICKS_PER_TURN / 2,
|
||||
},
|
||||
{ id: 2, reservedUntil: null, reservedUntilTick: null },
|
||||
]);
|
||||
expect(
|
||||
world.listGeneralPoolCandidates(probeAfterOriginalExpiry)?.map((candidate) => candidate.uniqueName)
|
||||
).toEqual(['미예약후보']);
|
||||
|
||||
world.restoreState(before);
|
||||
|
||||
expect(world.captureState().generalPoolEntries).toMatchObject([
|
||||
{ id: 1, reservedUntil, reservedUntilTick: GAME_TICKS_PER_TURN / 2 },
|
||||
{ id: 2, reservedUntil: null, reservedUntilTick: null },
|
||||
]);
|
||||
expect(
|
||||
world.listGeneralPoolCandidates(probeAfterOriginalExpiry)?.map((candidate) => candidate.uniqueName)
|
||||
).toEqual(['예약후보', '미예약후보']);
|
||||
});
|
||||
|
||||
it('rebases tick-owned reservations with a long realtime backlog and restores exact expiry semantics', () => {
|
||||
const originalReservedUntilTick = 2 * GAME_TICKS_PER_TURN;
|
||||
const originalReservedUntil = new Date(claimedAt.getTime() + 20 * 60_000);
|
||||
const reserved = buildCandidateEntry(1, '예약후보', {
|
||||
ownerUserId: 'active-user',
|
||||
reservedUntil: originalReservedUntil,
|
||||
reservedUntilTick: originalReservedUntilTick,
|
||||
});
|
||||
const world = buildWorld([reserved], [], {
|
||||
clockBaseTime: claimedAt,
|
||||
clockTick: 0,
|
||||
clockMode: 'realtime',
|
||||
clockWallAnchor: claimedAt,
|
||||
lastTurnTick: 0,
|
||||
});
|
||||
const before = world.captureState();
|
||||
const resumedAt = new Date(claimedAt.getTime() + 40 * 60_000);
|
||||
|
||||
expect(world.rebaseRealtimeBacklog(resumedAt)).toMatchObject({
|
||||
skippedTurns: 4,
|
||||
shiftedTicks: 4 * GAME_TICKS_PER_TURN,
|
||||
});
|
||||
const rebasedReservedUntilTick = 6 * GAME_TICKS_PER_TURN;
|
||||
const rebasedReservedUntil = world.gameTickToDate(rebasedReservedUntilTick);
|
||||
expect(world.captureState().generalPoolEntries).toMatchObject([
|
||||
{
|
||||
id: 1,
|
||||
reservedUntilTick: rebasedReservedUntilTick,
|
||||
reservedUntil: rebasedReservedUntil,
|
||||
},
|
||||
]);
|
||||
expect(world.listGeneralPoolCandidates(resumedAt)).toEqual([]);
|
||||
expect(world.listGeneralPoolCandidates(rebasedReservedUntil)).toEqual([]);
|
||||
expect(
|
||||
world
|
||||
.listGeneralPoolCandidates(new Date(rebasedReservedUntil.getTime() + 1))
|
||||
?.map((candidate) => candidate.uniqueName)
|
||||
).toEqual(['예약후보']);
|
||||
|
||||
world.restoreState(before);
|
||||
|
||||
expect(world.captureState().generalPoolEntries).toMatchObject([
|
||||
{
|
||||
id: 1,
|
||||
reservedUntilTick: originalReservedUntilTick,
|
||||
reservedUntil: originalReservedUntil,
|
||||
},
|
||||
]);
|
||||
expect(world.listGeneralPoolCandidates(originalReservedUntil)).toEqual([]);
|
||||
expect(
|
||||
world
|
||||
.listGeneralPoolCandidates(new Date(originalReservedUntil.getTime() + 1))
|
||||
?.map((candidate) => candidate.uniqueName)
|
||||
).toEqual(['예약후보']);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,12 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { readdir } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
MONTHLY_EVENT_ACTION_CATALOG,
|
||||
type MonthlyEventActionName,
|
||||
} from '../src/turn/monthlyEventHandler.js';
|
||||
import { resolveScenarioDefaultsPath } from '../src/scenario/scenarioLoader.js';
|
||||
import { MONTHLY_EVENT_ACTION_CATALOG, type MonthlyEventActionName } from '../src/turn/monthlyEventHandler.js';
|
||||
import { hasRefSourceRoot, resolveRefSourceRoot } from './refSourceRoot.js';
|
||||
|
||||
interface CatalogSegment {
|
||||
name: string;
|
||||
@@ -34,13 +35,7 @@ const segments = [
|
||||
{
|
||||
name: 'city-economy-boundaries',
|
||||
kind: 'single-boundary',
|
||||
actions: [
|
||||
'RaiseDisaster',
|
||||
'UpdateCitySupply',
|
||||
'UpdateNationLevel',
|
||||
'ProcessSemiAnnual',
|
||||
'ProcessWarIncome',
|
||||
],
|
||||
actions: ['RaiseDisaster', 'UpdateCitySupply', 'UpdateNationLevel', 'ProcessSemiAnnual', 'ProcessWarIncome'],
|
||||
coreEvidence: [
|
||||
'monthlyDisasterPersistence.integration.test.ts',
|
||||
'monthlyCitySupplyPersistence.integration.test.ts',
|
||||
@@ -84,11 +79,7 @@ const segments = [
|
||||
kind: 'multi-month',
|
||||
actions: ['RaiseInvader', 'AutoDeleteInvader', 'InvaderEnding'],
|
||||
coreEvidence: ['monthlyInvaderPersistence.integration.test.ts'],
|
||||
refEvidence: [
|
||||
'monthly_raise_invader.json',
|
||||
'monthly_auto_delete_invader.json',
|
||||
'monthly_invader_ending.json',
|
||||
],
|
||||
refEvidence: ['monthly_raise_invader.json', 'monthly_auto_delete_invader.json', 'monthly_invader_ending.json'],
|
||||
},
|
||||
{
|
||||
name: 'npc-troop-support',
|
||||
@@ -136,17 +127,62 @@ const segments = [
|
||||
coreEvidence: ['monthlyUniqueInheritPersistence.integration.test.ts'],
|
||||
refEvidence: ['monthly_lost_unique_item.json', 'monthly_merge_inherit_point_rank.json'],
|
||||
},
|
||||
{
|
||||
name: 'centennial-all-star-growth',
|
||||
kind: 'multi-month',
|
||||
actions: ['AdvanceCentennialAllStar'],
|
||||
coreEvidence: ['monthlyCentennialAllStarAction.test.ts'],
|
||||
refEvidence: ['CentennialAllStarGrowthTest.php', 'AdvanceCentennialAllStar.php'],
|
||||
},
|
||||
] as const satisfies readonly CatalogSegment[];
|
||||
|
||||
const KNOWN_MISSING_SCENARIO_RESOURCES = [] as const;
|
||||
const KNOWN_MISSING_MONTHLY_ACTIONS = [] as const;
|
||||
|
||||
const listBasenames = async (directory: string, pattern: RegExp): Promise<string[]> =>
|
||||
(await readdir(directory, { withFileTypes: true }))
|
||||
.filter((entry) => entry.isFile() && pattern.test(entry.name))
|
||||
.map((entry) => entry.name)
|
||||
.sort();
|
||||
|
||||
const difference = (left: readonly string[], right: readonly string[]): string[] => {
|
||||
const rightSet = new Set(right);
|
||||
return left.filter((value) => !rightSet.has(value)).sort();
|
||||
};
|
||||
|
||||
const refSourceIt = hasRefSourceRoot() ? it : it.skip;
|
||||
|
||||
describe('monthly event catalog coverage', () => {
|
||||
it('assigns every legacy action to exactly one dependency-safe segment', () => {
|
||||
it('assigns every Core monthly action to exactly one dependency-safe segment', () => {
|
||||
const covered = segments.flatMap((segment) => segment.actions);
|
||||
|
||||
expect(covered).toHaveLength(29);
|
||||
expect(new Set(covered).size).toBe(29);
|
||||
expect(new Set(covered).size).toBe(covered.length);
|
||||
expect(new Set(MONTHLY_EVENT_ACTION_CATALOG).size).toBe(MONTHLY_EVENT_ACTION_CATALOG.length);
|
||||
expect([...covered].sort()).toEqual([...MONTHLY_EVENT_ACTION_CATALOG].sort());
|
||||
});
|
||||
|
||||
refSourceIt('keeps the Core and Ref scenario resource catalogs complete', async () => {
|
||||
const refScenarioDirectory = path.join(resolveRefSourceRoot(), 'hwe', 'scenario');
|
||||
const coreScenarioDirectory = path.dirname(resolveScenarioDefaultsPath());
|
||||
const [refScenarios, coreScenarios] = await Promise.all([
|
||||
listBasenames(refScenarioDirectory, /^scenario_\d+\.json$/),
|
||||
listBasenames(coreScenarioDirectory, /^scenario_\d+\.json$/),
|
||||
]);
|
||||
|
||||
expect(difference(refScenarios, coreScenarios)).toEqual([...KNOWN_MISSING_SCENARIO_RESOURCES]);
|
||||
expect(difference(coreScenarios, refScenarios)).toEqual([]);
|
||||
});
|
||||
|
||||
refSourceIt('keeps the Core and Ref monthly action catalogs complete', async () => {
|
||||
const refActionDirectory = path.join(resolveRefSourceRoot(), 'hwe', 'sammo', 'Event', 'Action');
|
||||
const refActions = (await listBasenames(refActionDirectory, /\.php$/)).map((fileName) =>
|
||||
fileName.replace(/\.php$/, '')
|
||||
);
|
||||
|
||||
expect(difference(refActions, MONTHLY_EVENT_ACTION_CATALOG)).toEqual([...KNOWN_MISSING_MONTHLY_ACTIONS]);
|
||||
expect(difference(MONTHLY_EVENT_ACTION_CATALOG, refActions)).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps every core evidence file executable in this suite', () => {
|
||||
const testDirectory = fileURLToPath(new URL('.', import.meta.url));
|
||||
const evidenceFiles = new Set(segments.flatMap((segment) => segment.coreEvidence));
|
||||
@@ -170,6 +206,7 @@ describe('monthly event catalog coverage', () => {
|
||||
'InvaderEnding',
|
||||
'OpenNationBetting',
|
||||
'FinishNationBetting',
|
||||
'AdvanceCentennialAllStar',
|
||||
]);
|
||||
expect(specialDispositions).toEqual(['CreateAdminNPC', 'UnblockScoutAction']);
|
||||
expect(segments.every((segment) => segment.refEvidence.length > 0)).toBe(true);
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
CENTENNIAL_ALL_STAR_AUX_KEY,
|
||||
LogCategory,
|
||||
LogFormat,
|
||||
LogScope,
|
||||
calculateCentennialUserInitialStats,
|
||||
initialCentennialAllStarAux,
|
||||
type CentennialAllStarRules,
|
||||
type CentennialAllStarTarget,
|
||||
} from '@sammo-ts/logic';
|
||||
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import { createAdvanceCentennialAllStarHandler } from '../src/turn/monthlyCentennialAllStarAction.js';
|
||||
import type { TurnEvent, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
|
||||
const event: TurnEvent = {
|
||||
id: 1,
|
||||
targetCode: 'month',
|
||||
priority: 8_000,
|
||||
condition: true,
|
||||
action: ['AdvanceCentennialAllStar'],
|
||||
meta: {},
|
||||
};
|
||||
|
||||
const rules: CentennialAllStarRules = {
|
||||
defaultStatMin: 15,
|
||||
defaultStatMax: 80,
|
||||
defaultStatTotal: 165,
|
||||
maxStatLevel: 255,
|
||||
defaultSpecialDomestic: 'None',
|
||||
dexLimit: 1_000_000,
|
||||
};
|
||||
|
||||
const target: CentennialAllStarTarget = {
|
||||
uniqueName: 'A1000001',
|
||||
generalName: '1·조민',
|
||||
leadership: 100,
|
||||
strength: 80,
|
||||
intel: 10,
|
||||
dex: [900_000, 800_000, 700_000, 600_000, 500_000],
|
||||
specialDomestic: 'che_event_무쌍',
|
||||
};
|
||||
|
||||
const withTargetMeta = (
|
||||
userInitialStats: ReturnType<typeof calculateCentennialUserInitialStats> | null = null
|
||||
): TurnGeneral['meta'] => {
|
||||
const meta: TurnGeneral['meta'] = {
|
||||
killturn: 5,
|
||||
dex1: 0,
|
||||
dex2: 0,
|
||||
dex3: 0,
|
||||
dex4: 0,
|
||||
dex5: 0,
|
||||
};
|
||||
const mutable: Record<string, unknown> = meta;
|
||||
mutable[CENTENNIAL_ALL_STAR_AUX_KEY] = initialCentennialAllStarAux(target, rules, userInitialStats);
|
||||
return meta;
|
||||
};
|
||||
|
||||
const buildGeneral = (options: {
|
||||
id: number;
|
||||
npcState: number;
|
||||
stats: TurnGeneral['stats'];
|
||||
meta?: TurnGeneral['meta'];
|
||||
}): TurnGeneral => ({
|
||||
id: options.id,
|
||||
userId: options.npcState === 0 ? `user-${options.id}` : null,
|
||||
name: `장수${options.id}`,
|
||||
nationId: 0,
|
||||
cityId: 1,
|
||||
troopId: 0,
|
||||
stats: options.stats,
|
||||
experience: 0,
|
||||
dedication: 0,
|
||||
officerLevel: 0,
|
||||
role: {
|
||||
personality: 'che_안전',
|
||||
specialDomestic: 'None',
|
||||
specialWar: 'None',
|
||||
items: { horse: null, weapon: null, book: null, item: null },
|
||||
},
|
||||
injury: 0,
|
||||
gold: 1_000,
|
||||
rice: 1_000,
|
||||
crew: 0,
|
||||
crewTypeId: 1100,
|
||||
train: 0,
|
||||
atmos: 0,
|
||||
age: 20,
|
||||
startAge: 20,
|
||||
npcState: options.npcState,
|
||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
meta: options.meta ?? { killturn: 5 },
|
||||
lastTurn: { command: '휴식' },
|
||||
turnTime: new Date('0186-01-01T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
const buildWorld = (generals: TurnGeneral[]): InMemoryTurnWorld => {
|
||||
const state: TurnWorldState = {
|
||||
id: 1,
|
||||
currentYear: 186,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
lastTurnTime: new Date('0186-01-01T00:00:00.000Z'),
|
||||
meta: { hiddenSeed: 'monthly-centennial-fixture' },
|
||||
};
|
||||
const scenarioConfig: TurnWorldSnapshot['scenarioConfig'] = {
|
||||
stat: { total: 165, min: 15, max: 80, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 },
|
||||
iconPath: '.',
|
||||
map: { targetGeneralPool: 'SPoolUnderU100', centennialNpcDexTargetRatio: 0.4 },
|
||||
const: {
|
||||
maxLevel: 255,
|
||||
defaultSpecialDomestic: 'None',
|
||||
dexLimit: 1_000_000,
|
||||
},
|
||||
environment: { mapName: 'test', unitSet: 'default' },
|
||||
};
|
||||
return new InMemoryTurnWorld(
|
||||
state,
|
||||
{
|
||||
scenarioConfig,
|
||||
map: { id: 'test', name: 'test', cities: [] },
|
||||
generals,
|
||||
cities: [],
|
||||
nations: [],
|
||||
troops: [],
|
||||
diplomacy: [],
|
||||
events: [event],
|
||||
initialEvents: [],
|
||||
},
|
||||
{ schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] } }
|
||||
);
|
||||
};
|
||||
|
||||
describe('AdvanceCentennialAllStar monthly action', () => {
|
||||
it('advances a user target, unlocks its trait, persists aux, and emits one milestone pair', async () => {
|
||||
const initial = calculateCentennialUserInitialStats(target, rules);
|
||||
const world = buildWorld([
|
||||
buildGeneral({
|
||||
id: 1,
|
||||
npcState: 0,
|
||||
stats: {
|
||||
leadership: initial.leadership,
|
||||
strength: initial.strength,
|
||||
intelligence: initial.intel,
|
||||
},
|
||||
meta: withTargetMeta(initial),
|
||||
}),
|
||||
]);
|
||||
const environment = {
|
||||
year: 186,
|
||||
month: 1,
|
||||
startyear: 180,
|
||||
currentEventID: 1,
|
||||
turnTime: new Date('0186-01-01T00:00:00.000Z'),
|
||||
};
|
||||
|
||||
await createAdvanceCentennialAllStarHandler({ getWorld: () => world })([], environment, event);
|
||||
|
||||
const updated = world.getGeneralById(1)!;
|
||||
expect(updated.role.specialDomestic).toBe('che_event_무쌍');
|
||||
expect([updated.meta.dex1, updated.meta.dex2, updated.meta.dex3, updated.meta.dex4, updated.meta.dex5]).toEqual(
|
||||
[144_000, 128_000, 112_000, 96_000, 80_000]
|
||||
);
|
||||
expect(world.peekDirtyState().generals).toHaveLength(1);
|
||||
expect(world.peekDirtyState().logs).toEqual([
|
||||
expect.objectContaining({
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.ACTION,
|
||||
generalId: 1,
|
||||
text: '<L>올스타 동조율</>이 <C>40%</>에 도달했습니다!',
|
||||
format: LogFormat.PLAIN,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.HISTORY,
|
||||
generalId: 1,
|
||||
text: '<L>올스타 동조율 40% 달성</>',
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the Ref .9 stat progress and .4 dex target only for generated NPCs', async () => {
|
||||
const world = buildWorld([
|
||||
buildGeneral({
|
||||
id: 2,
|
||||
npcState: 3,
|
||||
stats: { leadership: 15, strength: 15, intelligence: 10 },
|
||||
meta: withTargetMeta(),
|
||||
}),
|
||||
buildGeneral({
|
||||
id: 3,
|
||||
npcState: 2,
|
||||
stats: { leadership: 50, strength: 50, intelligence: 50 },
|
||||
}),
|
||||
buildGeneral({
|
||||
id: 4,
|
||||
npcState: 6,
|
||||
stats: { leadership: 15, strength: 15, intelligence: 10 },
|
||||
meta: withTargetMeta(),
|
||||
}),
|
||||
]);
|
||||
const environment = {
|
||||
year: 195,
|
||||
month: 1,
|
||||
startyear: 180,
|
||||
currentEventID: 1,
|
||||
turnTime: new Date('0195-01-01T00:00:00.000Z'),
|
||||
};
|
||||
|
||||
await createAdvanceCentennialAllStarHandler({ getWorld: () => world })([], environment, event);
|
||||
|
||||
const npc = world.getGeneralById(2)!;
|
||||
expect(npc.stats).toEqual({ leadership: 91, strength: 73, intelligence: 10 });
|
||||
expect([npc.meta.dex1, npc.meta.dex2, npc.meta.dex3, npc.meta.dex4, npc.meta.dex5]).toEqual([
|
||||
360_000, 320_000, 280_000, 240_000, 200_000,
|
||||
]);
|
||||
expect(world.getGeneralById(3)?.stats).toEqual({ leadership: 50, strength: 50, intelligence: 50 });
|
||||
const nationNpc = world.getGeneralById(4)!;
|
||||
expect(nationNpc.stats).toEqual({ leadership: 100, strength: 80, intelligence: 10 });
|
||||
expect([
|
||||
nationNpc.meta.dex1,
|
||||
nationNpc.meta.dex2,
|
||||
nationNpc.meta.dex3,
|
||||
nationNpc.meta.dex4,
|
||||
nationNpc.meta.dex5,
|
||||
]).toEqual([900_000, 800_000, 700_000, 600_000, 500_000]);
|
||||
expect(world.peekDirtyState().generals.map((entry) => entry.id)).toEqual([2, 4]);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,16 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { LEGACY_RANDOM_GENERAL_FIRST_NAMES, LEGACY_RANDOM_GENERAL_LAST_NAMES, type City } from '@sammo-ts/logic';
|
||||
import {
|
||||
LEGACY_RANDOM_GENERAL_FIRST_NAMES,
|
||||
LEGACY_RANDOM_GENERAL_LAST_NAMES,
|
||||
parseScenarioGeneralPoolCandidate,
|
||||
type City,
|
||||
} from '@sammo-ts/logic';
|
||||
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import { createCreateManyNpcHandler } from '../src/turn/monthlyCreateManyNpcAction.js';
|
||||
import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.js';
|
||||
import { buildCommandEnv } from '../src/turn/reservedTurnCommands.js';
|
||||
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
import type { TurnGeneral, TurnGeneralPoolEntry, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
|
||||
const buildCity = (id: number): City => ({
|
||||
id,
|
||||
@@ -64,7 +69,12 @@ const buildGeneral = (id: number, patch: Partial<TurnGeneral> = {}): TurnGeneral
|
||||
...patch,
|
||||
});
|
||||
|
||||
const buildHarness = (generals: TurnGeneral[] = [], cityCount = 2) => {
|
||||
const buildHarness = (
|
||||
generals: TurnGeneral[] = [],
|
||||
cityCount = 2,
|
||||
generalPoolEntries?: TurnGeneralPoolEntry[],
|
||||
poolName = 'SPoolUnderU30'
|
||||
) => {
|
||||
const state: TurnWorldState = {
|
||||
id: 1,
|
||||
currentYear: 200,
|
||||
@@ -75,9 +85,17 @@ const buildHarness = (generals: TurnGeneral[] = [], cityCount = 2) => {
|
||||
};
|
||||
const snapshot: TurnWorldSnapshot = {
|
||||
scenarioConfig: {
|
||||
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
|
||||
stat:
|
||||
poolName === 'SPoolUnderU100'
|
||||
? { total: 165, min: 15, max: 80, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }
|
||||
: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
|
||||
iconPath: '',
|
||||
map: {},
|
||||
map: generalPoolEntries
|
||||
? {
|
||||
targetGeneralPool: poolName,
|
||||
...(poolName === 'SPoolUnderU100' ? { centennialNpcDexTargetRatio: 0.4 } : {}),
|
||||
}
|
||||
: {},
|
||||
const: {
|
||||
defaultStatNPCTotal: 150,
|
||||
defaultStatNPCMin: 10,
|
||||
@@ -103,6 +121,7 @@ const buildHarness = (generals: TurnGeneral[] = [], cityCount = 2) => {
|
||||
diplomacy: [],
|
||||
events: [],
|
||||
initialEvents: [],
|
||||
...(generalPoolEntries ? { generalPoolEntries } : {}),
|
||||
};
|
||||
const world = new InMemoryTurnWorld(state, snapshot, {
|
||||
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
|
||||
@@ -132,6 +151,119 @@ const buildHarness = (generals: TurnGeneral[] = [], cityCount = 2) => {
|
||||
};
|
||||
|
||||
describe('CreateManyNPC monthly action', () => {
|
||||
it('uses and consumes an available U30 candidate without random-name or random-stat fallback', async () => {
|
||||
const info = {
|
||||
generalName: '풀장수',
|
||||
leadership: 69,
|
||||
strength: 12,
|
||||
intel: 80,
|
||||
specialDomestic: 'che_event_징병',
|
||||
dex: [10, 20, 30, 40, 50],
|
||||
imgsvr: 1,
|
||||
picture: 'pool.gif',
|
||||
};
|
||||
const entry: TurnGeneralPoolEntry = {
|
||||
id: 31,
|
||||
uniqueName: info.generalName,
|
||||
ownerUserId: null,
|
||||
generalId: null,
|
||||
reservedUntil: null,
|
||||
reservedUntilTick: null,
|
||||
candidate: parseScenarioGeneralPoolCandidate({ id: 31, uniqueName: info.generalName, info }),
|
||||
};
|
||||
const { world, handler, environment } = buildHarness([buildGeneral(1)], 2, [entry]);
|
||||
|
||||
await handler([1, 0], environment, {
|
||||
id: 1,
|
||||
targetCode: 'month',
|
||||
priority: 1,
|
||||
condition: true,
|
||||
action: [],
|
||||
meta: {},
|
||||
});
|
||||
|
||||
const created = world.peekDirtyState().createdGenerals[0]!;
|
||||
expect(created).toMatchObject({
|
||||
name: 'ⓜ풀장수',
|
||||
stats: { leadership: 69, strength: 12, intelligence: 80 },
|
||||
picture: 'pool.gif',
|
||||
imageServer: 1,
|
||||
role: {
|
||||
specialDomestic: 'che_event_징병',
|
||||
},
|
||||
meta: {
|
||||
dex1: 10,
|
||||
dex2: 20,
|
||||
dex3: 30,
|
||||
dex4: 40,
|
||||
dex5: 50,
|
||||
scenarioGeneralPoolClaim: {
|
||||
poolEntryId: 31,
|
||||
uniqueName: '풀장수',
|
||||
claimedAt: environment.turnTime.toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(world.listGeneralPoolCandidates(environment.turnTime)).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps the ordinary NPC RNG path before applying the S100 .9/.4 target', async () => {
|
||||
const info = {
|
||||
generalName: '100기후보',
|
||||
leadership: 100,
|
||||
strength: 80,
|
||||
intel: 10,
|
||||
specialDomestic: 'che_event_징병',
|
||||
dex: [900_000, 800_000, 700_000, 600_000, 500_000],
|
||||
imgsvr: 1,
|
||||
picture: 'centennial.gif',
|
||||
event100Growth: true,
|
||||
};
|
||||
const entry: TurnGeneralPoolEntry = {
|
||||
id: 100,
|
||||
uniqueName: 'A1000100',
|
||||
ownerUserId: null,
|
||||
generalId: null,
|
||||
reservedUntil: null,
|
||||
reservedUntilTick: null,
|
||||
candidate: parseScenarioGeneralPoolCandidate({ id: 100, uniqueName: 'A1000100', info }),
|
||||
};
|
||||
const { world, handler, environment } = buildHarness([buildGeneral(1)], 2, [entry], 'SPoolUnderU100');
|
||||
const currentEnvironment = { ...environment, year: 195, month: 1, startyear: 180 };
|
||||
|
||||
await handler([1, 0], currentEnvironment, {
|
||||
id: 1,
|
||||
targetCode: 'month',
|
||||
priority: 1,
|
||||
condition: true,
|
||||
action: [],
|
||||
meta: {},
|
||||
});
|
||||
|
||||
const created = world.peekDirtyState().createdGenerals[0]!;
|
||||
expect(created).toMatchObject({
|
||||
name: 'ⓜ100기후보',
|
||||
stats: { leadership: 93, strength: 73 },
|
||||
picture: 'centennial.gif',
|
||||
role: { specialDomestic: 'che_event_징병' },
|
||||
meta: {
|
||||
dex1: 360_000,
|
||||
dex2: 320_000,
|
||||
dex3: 280_000,
|
||||
dex4: 240_000,
|
||||
dex5: 200_000,
|
||||
scenarioGeneralPoolClaim: { poolEntryId: 100, uniqueName: 'A1000100' },
|
||||
event100_allstar: {
|
||||
targetId: 'A1000100',
|
||||
milestone: 4,
|
||||
dexTargetRatio: 0.4,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(created.stats.intelligence).toBeGreaterThanOrEqual(10);
|
||||
expect(created.stats).not.toEqual({ leadership: 100, strength: 80, intelligence: 10 });
|
||||
});
|
||||
|
||||
it('creates the legacy random-name NPC state and initializes all 30 reserved turns', async () => {
|
||||
const { world, reservedTurns, handler, environment } = buildHarness([buildGeneral(1)]);
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ const sourceEvent: TurnEvent = {
|
||||
meta: {},
|
||||
};
|
||||
|
||||
const buildWorld = () => {
|
||||
const buildWorld = (options: { promotedNeutral?: boolean; generals?: TurnGeneral[]; nations?: Nation[] } = {}) => {
|
||||
const state: TurnWorldState = {
|
||||
id: 1,
|
||||
currentYear: 200,
|
||||
@@ -111,9 +111,13 @@ const buildWorld = () => {
|
||||
{
|
||||
scenarioConfig,
|
||||
map: { id: 'test', name: 'test', cities: [] },
|
||||
generals: [buildGeneral(1), buildGeneral(2)],
|
||||
generals: options.generals ?? [buildGeneral(1), buildGeneral(2)],
|
||||
cities: [buildCity(1), buildCity(2)],
|
||||
nations: [buildNation(1, 100), buildNation(2, 300)],
|
||||
nations: options.nations ?? [
|
||||
...(options.promotedNeutral ? [{ ...buildNation(0, 0), name: '재야', level: 1 }] : []),
|
||||
buildNation(1, 100),
|
||||
buildNation(2, 300),
|
||||
],
|
||||
troops: [],
|
||||
diplomacy: [],
|
||||
events: [sourceEvent],
|
||||
@@ -146,25 +150,18 @@ describe('nation betting monthly actions', () => {
|
||||
closeYearMonth: 2_424,
|
||||
bonusPoint: 500,
|
||||
});
|
||||
expect(dirty.pendingNationBettingOpens[0]?.candidates.map((candidate) => candidate.aux.nation)).toEqual([
|
||||
2, 1,
|
||||
]);
|
||||
expect(dirty.pendingNationBettingOpens[0]?.candidates.map((candidate) => candidate.aux.nation)).toEqual([2, 1]);
|
||||
expect(dirty.createdEvents).toEqual([
|
||||
expect.objectContaining({
|
||||
targetCode: 'DESTROY_NATION',
|
||||
priority: 1_000,
|
||||
condition: ['RemainNation', '<=', 1],
|
||||
action: [
|
||||
['FinishNationBetting', 5],
|
||||
['DeleteEvent'],
|
||||
],
|
||||
action: [['FinishNationBetting', 5], ['DeleteEvent']],
|
||||
}),
|
||||
]);
|
||||
expect(dirty.logs).toHaveLength(1);
|
||||
expect(dirty.messages).toHaveLength(2);
|
||||
expect(dirty.messages[0]?.text).toBe(
|
||||
'새로운 천통국 내기가 열렸습니다. 천통국 베팅란을 확인해주세요.'
|
||||
);
|
||||
expect(dirty.messages[0]?.text).toBe('새로운 천통국 내기가 열렸습니다. 천통국 베팅란을 확인해주세요.');
|
||||
|
||||
await createFinishNationBettingHandler({ getWorld: () => world })([5], environment, sourceEvent);
|
||||
expect(world.peekDirtyState().pendingNationBettingFinishes).toEqual([
|
||||
@@ -177,4 +174,55 @@ describe('nation betting monthly actions', () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('never treats the synthetic neutral row as a nation-betting winner', async () => {
|
||||
const world = buildWorld({ promotedNeutral: true });
|
||||
const environment = {
|
||||
year: 200,
|
||||
month: 1,
|
||||
startyear: 190,
|
||||
currentEventID: 7,
|
||||
turnTime: new Date('0200-01-01T00:00:00.000Z'),
|
||||
};
|
||||
|
||||
await createFinishNationBettingHandler({ getWorld: () => world })([5], environment, sourceEvent);
|
||||
|
||||
expect(world.peekDirtyState().pendingNationBettingFinishes).toEqual([
|
||||
expect.objectContaining({ winnerNationIds: [1, 2] }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses stored gennum and excludes npc state 5 from the fallback candidate count', async () => {
|
||||
const nationOne = buildNation(1, 100);
|
||||
nationOne.meta = { ...nationOne.meta, gennum: 0 };
|
||||
const nationTwo = buildNation(2, 300);
|
||||
const nationTwoMeta = { ...nationTwo.meta };
|
||||
delete nationTwoMeta.gennum;
|
||||
nationTwo.meta = nationTwoMeta;
|
||||
const npcFiveNationOne = { ...buildGeneral(3), nationId: 1, npcState: 5 };
|
||||
const npcFiveNationTwo = { ...buildGeneral(4), nationId: 2, npcState: 5 };
|
||||
const world = buildWorld({
|
||||
generals: [buildGeneral(1), buildGeneral(2), npcFiveNationOne, npcFiveNationTwo],
|
||||
nations: [nationOne, nationTwo],
|
||||
});
|
||||
const environment = {
|
||||
year: 200,
|
||||
month: 1,
|
||||
startyear: 190,
|
||||
currentEventID: 7,
|
||||
turnTime: new Date('0200-01-01T00:00:00.000Z'),
|
||||
};
|
||||
|
||||
await createOpenNationBettingHandler({ getWorld: () => world })([1, 500], environment, sourceEvent);
|
||||
|
||||
const candidates = world.peekDirtyState().pendingNationBettingOpens[0]?.candidates;
|
||||
expect(candidates?.find((candidate) => candidate.aux.nation === 1)).toMatchObject({
|
||||
info: '국력: 100<br>장수 수: 0<br>도시 수: 1',
|
||||
aux: { gennum: 0 },
|
||||
});
|
||||
expect(candidates?.find((candidate) => candidate.aux.nation === 2)).toMatchObject({
|
||||
info: '국력: 300<br>장수 수: 1<br>도시 수: 1',
|
||||
aux: { gennum: 1 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { PERSONALITY_TRAIT_KEYS, type City, type MapDefinition, type Nation } from '@sammo-ts/logic';
|
||||
import {
|
||||
parseScenarioGeneralPoolCandidate,
|
||||
PERSONALITY_TRAIT_KEYS,
|
||||
type City,
|
||||
type MapDefinition,
|
||||
type Nation,
|
||||
} from '@sammo-ts/logic';
|
||||
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import { createRaiseNpcNationHandler } from '../src/turn/monthlyRaiseNpcNationAction.js';
|
||||
import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.js';
|
||||
import { buildCommandEnv } from '../src/turn/reservedTurnCommands.js';
|
||||
import type { TurnEvent, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
import type {
|
||||
TurnEvent,
|
||||
TurnGeneral,
|
||||
TurnGeneralPoolEntry,
|
||||
TurnWorldSnapshot,
|
||||
TurnWorldState,
|
||||
} from '../src/turn/types.js';
|
||||
|
||||
const buildCity = (id: number, nationId: number, level = 5): City => ({
|
||||
id,
|
||||
@@ -119,7 +131,13 @@ const event: TurnEvent = {
|
||||
meta: {},
|
||||
};
|
||||
|
||||
const buildHarness = (archivedNationMaxId = 0, hiddenSeed = 'raise-npc-nation-fixture') => {
|
||||
const buildHarness = (
|
||||
archivedNationMaxId = 0,
|
||||
hiddenSeed = 'raise-npc-nation-fixture',
|
||||
generalPoolEntries?: TurnGeneralPoolEntry[],
|
||||
additionalGeneralCount = 0,
|
||||
poolName = 'SPoolUnderU30'
|
||||
) => {
|
||||
const state: TurnWorldState = {
|
||||
id: 1,
|
||||
currentYear: 200,
|
||||
@@ -132,7 +150,12 @@ const buildHarness = (archivedNationMaxId = 0, hiddenSeed = 'raise-npc-nation-fi
|
||||
scenarioConfig: {
|
||||
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 },
|
||||
iconPath: '.',
|
||||
map: {},
|
||||
map: generalPoolEntries
|
||||
? {
|
||||
targetGeneralPool: poolName,
|
||||
...(poolName === 'SPoolUnderU100' ? { centennialNpcDexTargetRatio: 0.4 } : {}),
|
||||
}
|
||||
: {},
|
||||
const: {
|
||||
retirementYear: 80,
|
||||
availablePersonality: ['che_안전'],
|
||||
@@ -143,19 +166,22 @@ const buildHarness = (archivedNationMaxId = 0, hiddenSeed = 'raise-npc-nation-fi
|
||||
environment: { mapName: 'test', unitSet: 'default' },
|
||||
},
|
||||
map,
|
||||
generals: [buildGeneral()],
|
||||
cities: [
|
||||
buildCity(1, 1),
|
||||
buildCity(2, 0),
|
||||
buildCity(3, 0, 4),
|
||||
buildCity(4, 0),
|
||||
buildCity(5, 0, 4),
|
||||
generals: [
|
||||
buildGeneral(),
|
||||
...Array.from({ length: additionalGeneralCount }, (_, index) => ({
|
||||
...buildGeneral(),
|
||||
id: index + 2,
|
||||
name: `장수${index + 2}`,
|
||||
officerLevel: 1,
|
||||
})),
|
||||
],
|
||||
cities: [buildCity(1, 1), buildCity(2, 0), buildCity(3, 0, 4), buildCity(4, 0), buildCity(5, 0, 4)],
|
||||
nations: [buildNation(1)],
|
||||
troops: [],
|
||||
diplomacy: [],
|
||||
events: [event],
|
||||
initialEvents: [],
|
||||
...(generalPoolEntries ? { generalPoolEntries } : {}),
|
||||
};
|
||||
const world = new InMemoryTurnWorld(state, snapshot, {
|
||||
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
|
||||
@@ -202,6 +228,94 @@ describe('RaiseNPCNation monthly action', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('uses a U30 subordinate name/dex/special while preserving RaiseNPCNation random stats', async () => {
|
||||
const info = {
|
||||
generalName: '부장후보',
|
||||
leadership: 99,
|
||||
strength: 1,
|
||||
intel: 1,
|
||||
specialDomestic: 'che_event_징병',
|
||||
dex: [10, 20, 30, 40, 50],
|
||||
imgsvr: 1,
|
||||
picture: 'subordinate.gif',
|
||||
};
|
||||
const entry: TurnGeneralPoolEntry = {
|
||||
id: 41,
|
||||
uniqueName: info.generalName,
|
||||
ownerUserId: null,
|
||||
generalId: null,
|
||||
reservedUntil: null,
|
||||
reservedUntilTick: null,
|
||||
candidate: parseScenarioGeneralPoolCandidate({ id: 41, uniqueName: info.generalName, info }),
|
||||
};
|
||||
const { world, handler, environment } = buildHarness(0, 'raise-pool-fixture', [entry], 1);
|
||||
|
||||
await handler([], environment, event);
|
||||
|
||||
const subordinate = world.peekDirtyState().createdGenerals.find((general) => general.name === 'ⓤ부장후보');
|
||||
expect(subordinate).toMatchObject({
|
||||
name: 'ⓤ부장후보',
|
||||
picture: 'subordinate.gif',
|
||||
imageServer: 1,
|
||||
role: { specialDomestic: 'che_event_징병' },
|
||||
meta: {
|
||||
dex1: 10,
|
||||
dex2: 20,
|
||||
dex3: 30,
|
||||
dex4: 40,
|
||||
dex5: 50,
|
||||
scenarioGeneralPoolClaim: {
|
||||
poolEntryId: 41,
|
||||
uniqueName: '부장후보',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(subordinate?.stats).not.toEqual({ leadership: 99, strength: 1, intelligence: 1 });
|
||||
});
|
||||
|
||||
it('attaches the S100 target to a type-6 subordinate without applying current growth', async () => {
|
||||
const info = {
|
||||
generalName: '100기건국',
|
||||
leadership: 100,
|
||||
strength: 80,
|
||||
intel: 10,
|
||||
specialDomestic: 'che_event_징병',
|
||||
dex: [900_000, 800_000, 700_000, 600_000, 500_000],
|
||||
imgsvr: 1,
|
||||
picture: 'centennial-ruler.gif',
|
||||
event100Growth: true,
|
||||
};
|
||||
const entry: TurnGeneralPoolEntry = {
|
||||
id: 101,
|
||||
uniqueName: 'A1000101',
|
||||
ownerUserId: null,
|
||||
generalId: null,
|
||||
reservedUntil: null,
|
||||
reservedUntilTick: null,
|
||||
candidate: parseScenarioGeneralPoolCandidate({ id: 101, uniqueName: 'A1000101', info }),
|
||||
};
|
||||
const { world, handler, environment } = buildHarness(0, 'raise-s100-fixture', [entry], 1, 'SPoolUnderU100');
|
||||
|
||||
await handler([], environment, event);
|
||||
|
||||
const subordinate = world.peekDirtyState().createdGenerals.find((general) => general.name === 'ⓤ100기건국')!;
|
||||
expect(subordinate.stats).not.toEqual({ leadership: 100, strength: 80, intelligence: 10 });
|
||||
expect(subordinate.role.specialDomestic).toBeNull();
|
||||
expect(subordinate.meta).toMatchObject({
|
||||
dex1: 0,
|
||||
dex2: 0,
|
||||
dex3: 0,
|
||||
dex4: 0,
|
||||
dex5: 0,
|
||||
scenarioGeneralPoolClaim: { poolEntryId: 101, uniqueName: 'A1000101' },
|
||||
event100_allstar: {
|
||||
targetId: 'A1000101',
|
||||
progressMonth: -1,
|
||||
milestone: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('creates only distance-qualified NPC nations and initializes their ruler and turns', async () => {
|
||||
const { world, reservedTurns, handler, environment } = buildHarness();
|
||||
|
||||
@@ -265,12 +379,7 @@ describe('RaiseNPCNation monthly action', () => {
|
||||
});
|
||||
expect(world.getCityById(5)?.nationId).toBe(0);
|
||||
expect(reservedTurns.getGeneralTurns(2)).toHaveLength(30);
|
||||
expect(reservedTurns.peekDirtyState().nationInitializationKeys).toEqual([
|
||||
'2:12',
|
||||
'2:11',
|
||||
'2:10',
|
||||
'2:9',
|
||||
]);
|
||||
expect(reservedTurns.peekDirtyState().nationInitializationKeys).toEqual(['2:12', '2:11', '2:10', '2:9']);
|
||||
expect(dirty.logs).toEqual([
|
||||
expect.objectContaining({
|
||||
category: 'HISTORY',
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const CORE_REPOSITORY_ROOT = fileURLToPath(new URL('../../../', import.meta.url));
|
||||
|
||||
const refSourceCandidates = (): string[] => {
|
||||
if (process.env.SAMMO_REF_ROOT) {
|
||||
return [path.resolve(process.env.SAMMO_REF_ROOT)];
|
||||
}
|
||||
|
||||
const candidates = [path.resolve(CORE_REPOSITORY_ROOT, '..', 'ref', 'sam')];
|
||||
try {
|
||||
const commonDirectory = execFileSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], {
|
||||
cwd: CORE_REPOSITORY_ROOT,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
}).trim();
|
||||
candidates.push(path.resolve(path.dirname(commonDirectory), '..', 'ref', 'sam'));
|
||||
} catch {
|
||||
// The ordinary sibling checkout remains the fallback outside a Git worktree.
|
||||
}
|
||||
|
||||
return [...new Set(candidates)];
|
||||
};
|
||||
|
||||
export const hasRefSourceRoot = (): boolean => {
|
||||
if (process.env.SAMMO_REQUIRE_REF_SOURCE === '1' || process.env.SAMMO_REF_ROOT) {
|
||||
return true;
|
||||
}
|
||||
return refSourceCandidates().some((candidate) => existsSync(candidate));
|
||||
};
|
||||
|
||||
export const resolveRefSourceRoot = (): string => {
|
||||
const candidates = refSourceCandidates();
|
||||
const resolvedRoot = candidates.find((candidate) => existsSync(candidate));
|
||||
if (!resolvedRoot) {
|
||||
if (process.env.SAMMO_REF_ROOT) {
|
||||
throw new Error(`SAMMO_REF_ROOT does not exist: ${candidates[0]}`);
|
||||
}
|
||||
throw new Error(`Ref source checkout was not found. Checked: ${candidates.join(', ')}`);
|
||||
}
|
||||
return resolvedRoot;
|
||||
};
|
||||
@@ -21,6 +21,7 @@ const runtimeSettingsRequestId = 'integration:engine:runtime-game-settings';
|
||||
const runtimeSettingsActionId = 'c9f68480-dba9-4e03-a62b-499e6234f18a';
|
||||
const generalIds = [990_301, 990_302, 990_303, 990_304] as const;
|
||||
const runtimeSettingsLogText = 'runtime-settings-existing-log';
|
||||
const backlogPoolUniqueName = 'rebase-pool-990304';
|
||||
|
||||
const buildGeneral = (id: number, turnTime: Date): TurnGeneral =>
|
||||
({
|
||||
@@ -86,6 +87,7 @@ integration('runtime clock shift persistence', () => {
|
||||
await db.message.deleteMany({ where: { mailbox: generalIds[2] } });
|
||||
await db.logEntry.deleteMany({ where: { text: runtimeSettingsLogText } });
|
||||
await db.auction.deleteMany({ where: { hostGeneralId: { in: [...generalIds] } } });
|
||||
await db.selectPoolEntry.deleteMany({ where: { uniqueName: backlogPoolUniqueName } });
|
||||
await db.general.deleteMany({ where: { id: { in: [...generalIds] } } });
|
||||
await db.worldState.deleteMany({
|
||||
where: {
|
||||
@@ -100,6 +102,7 @@ integration('runtime clock shift persistence', () => {
|
||||
await db.message.deleteMany({ where: { mailbox: generalIds[2] } });
|
||||
await db.logEntry.deleteMany({ where: { text: runtimeSettingsLogText } });
|
||||
await db.auction.deleteMany({ where: { hostGeneralId: { in: [...generalIds] } } });
|
||||
await db.selectPoolEntry.deleteMany({ where: { uniqueName: backlogPoolUniqueName } });
|
||||
await db.general.deleteMany({ where: { id: { in: [...generalIds] } } });
|
||||
await db.worldState.deleteMany({
|
||||
where: {
|
||||
@@ -351,6 +354,26 @@ integration('runtime clock shift persistence', () => {
|
||||
})
|
||||
)
|
||||
);
|
||||
const poolEntry = await db.selectPoolEntry.create({
|
||||
data: {
|
||||
uniqueName: backlogPoolUniqueName,
|
||||
ownerUserId: 'rebase-pool-user',
|
||||
generalId: null,
|
||||
reservedUntil: new Date('2099-09-01T00:10:00.000Z'),
|
||||
reservedUntilTick: BigInt(2 * GAME_TICKS_PER_TURN),
|
||||
info: {
|
||||
uniqueName: backlogPoolUniqueName,
|
||||
generalName: '재개예약후보',
|
||||
leadership: 70,
|
||||
strength: 70,
|
||||
intel: 10,
|
||||
specialDomestic: null,
|
||||
dex: [10, 10, 10, 10, 10],
|
||||
imgsvr: 0,
|
||||
picture: 'default.jpg',
|
||||
} as GamePrisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
const world = new InMemoryTurnWorld(
|
||||
{
|
||||
id: row.id,
|
||||
@@ -422,8 +445,15 @@ integration('runtime clock shift persistence', () => {
|
||||
closeTick: BigInt(2 * GAME_TICKS_PER_TURN),
|
||||
closeAt: new Date('2099-09-01T00:10:00.000Z'),
|
||||
});
|
||||
expect(await db.selectPoolEntry.findUniqueOrThrow({ where: { id: poolEntry.id } })).toMatchObject({
|
||||
ownerUserId: 'rebase-pool-user',
|
||||
generalId: null,
|
||||
reservedUntilTick: BigInt(9 * GAME_TICKS_PER_TURN),
|
||||
reservedUntil: new Date('2099-09-01T00:45:00.000Z'),
|
||||
});
|
||||
|
||||
await db.auction.deleteMany({ where: { id: { in: [openAuction.id, finishedAuction.id] } } });
|
||||
await db.selectPoolEntry.delete({ where: { id: poolEntry.id } });
|
||||
await db.general.delete({ where: { id: general.id } });
|
||||
await db.worldState.delete({ where: { id: row.id } });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { parseScenarioGeneralPoolCandidate, readScenarioGeneralPoolClaim, type City } from '@sammo-ts/logic';
|
||||
|
||||
import { loadGeneralPoolEntries } from '../src/scenario/generalPoolLoader.js';
|
||||
import { loadScenarioDefinitionById } from '../src/scenario/scenarioLoader.js';
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import { createCreateManyNpcHandler } from '../src/turn/monthlyCreateManyNpcAction.js';
|
||||
import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.js';
|
||||
import { buildCommandEnv } from '../src/turn/reservedTurnCommands.js';
|
||||
import type { TurnGeneralPoolEntry, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
|
||||
const turnTime = new Date('0180-12-01T00:00:00.000Z');
|
||||
|
||||
const city: City = {
|
||||
id: 1,
|
||||
name: '테스트성',
|
||||
nationId: 0,
|
||||
level: 4,
|
||||
state: 0,
|
||||
population: 10_000,
|
||||
populationMax: 20_000,
|
||||
agriculture: 1_000,
|
||||
agricultureMax: 2_000,
|
||||
commerce: 1_000,
|
||||
commerceMax: 2_000,
|
||||
security: 1_000,
|
||||
securityMax: 2_000,
|
||||
supplyState: 1,
|
||||
frontState: 0,
|
||||
defence: 1_000,
|
||||
defenceMax: 2_000,
|
||||
wall: 1_000,
|
||||
wallMax: 2_000,
|
||||
meta: {},
|
||||
};
|
||||
|
||||
describe('scenario 903 general-pool composition', () => {
|
||||
it('feeds the tracked U30 pool into the first 100-NPC event without losing candidate fields', async () => {
|
||||
const [scenario, seeds] = await Promise.all([
|
||||
loadScenarioDefinitionById(903),
|
||||
loadGeneralPoolEntries('SPoolUnderU30'),
|
||||
]);
|
||||
expect(scenario.config.map.targetGeneralPool).toBe('SPoolUnderU30');
|
||||
const createEvent = scenario.events.find(
|
||||
(event): event is unknown[] =>
|
||||
Array.isArray(event) &&
|
||||
event[0] === 'month' &&
|
||||
event.some((action) => Array.isArray(action) && action[0] === 'CreateManyNPC')
|
||||
);
|
||||
expect(createEvent).toEqual([
|
||||
'month',
|
||||
1_000,
|
||||
['Date', '==', null, 12],
|
||||
['CreateManyNPC', 100, 0],
|
||||
['DeleteEvent'],
|
||||
]);
|
||||
const createAction = createEvent?.find(
|
||||
(action): action is unknown[] => Array.isArray(action) && action[0] === 'CreateManyNPC'
|
||||
);
|
||||
expect(createAction).toBeDefined();
|
||||
|
||||
const generalPoolEntries: TurnGeneralPoolEntry[] = seeds.map((seed, index) => ({
|
||||
id: index + 1,
|
||||
uniqueName: seed.uniqueName,
|
||||
ownerUserId: null,
|
||||
generalId: null,
|
||||
reservedUntil: null,
|
||||
reservedUntilTick: null,
|
||||
candidate: parseScenarioGeneralPoolCandidate({ id: index + 1, ...seed }),
|
||||
}));
|
||||
const map = {
|
||||
id: 'scenario-903-pool-composition',
|
||||
name: 'scenario 903 pool composition',
|
||||
cities: [],
|
||||
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
|
||||
};
|
||||
const state: TurnWorldState = {
|
||||
id: 1,
|
||||
currentYear: 180,
|
||||
currentMonth: 12,
|
||||
tickSeconds: 600,
|
||||
lastTurnTime: turnTime,
|
||||
meta: { hiddenSeed: 'scenario-903-pool-composition' },
|
||||
};
|
||||
const snapshot: TurnWorldSnapshot = {
|
||||
scenarioConfig: scenario.config,
|
||||
scenarioMeta: {
|
||||
title: scenario.title,
|
||||
startYear: scenario.startYear,
|
||||
life: scenario.life,
|
||||
fiction: scenario.fiction,
|
||||
history: scenario.history,
|
||||
ignoreDefaultEvents: scenario.ignoreDefaultEvents,
|
||||
},
|
||||
map,
|
||||
unitSet: { id: 'test', name: 'test', crewTypes: [] },
|
||||
generals: [],
|
||||
cities: [city],
|
||||
nations: [],
|
||||
troops: [],
|
||||
diplomacy: [],
|
||||
events: [],
|
||||
initialEvents: [],
|
||||
generalPoolEntries,
|
||||
};
|
||||
const world = new InMemoryTurnWorld(state, snapshot, {
|
||||
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
|
||||
});
|
||||
const reservedTurns = new InMemoryReservedTurnStore(
|
||||
{
|
||||
generalTurn: { findMany: vi.fn(), deleteMany: vi.fn(), createMany: vi.fn() },
|
||||
nationTurn: { findMany: vi.fn(), deleteMany: vi.fn(), createMany: vi.fn() },
|
||||
} as never,
|
||||
{ maxGeneralTurns: 30, maxNationTurns: 12 }
|
||||
);
|
||||
const handler = createCreateManyNpcHandler({
|
||||
getWorld: () => world,
|
||||
reservedTurns,
|
||||
env: buildCommandEnv(scenario.config),
|
||||
});
|
||||
|
||||
await handler(
|
||||
createAction!.slice(1),
|
||||
{
|
||||
year: 180,
|
||||
month: 12,
|
||||
startyear: 180,
|
||||
currentEventID: 1,
|
||||
turnTime,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
targetCode: 'month',
|
||||
priority: 1_000,
|
||||
condition: true,
|
||||
action: [],
|
||||
meta: {},
|
||||
}
|
||||
);
|
||||
|
||||
const candidatesById = new Map(generalPoolEntries.map((entry) => [entry.id, entry.candidate]));
|
||||
const created = world.peekDirtyState().createdGenerals;
|
||||
expect(created).toHaveLength(100);
|
||||
const claims = created.map((general) => readScenarioGeneralPoolClaim(general.meta));
|
||||
expect(new Set(claims.map((claim) => claim?.poolEntryId)).size).toBe(100);
|
||||
for (const [index, general] of created.entries()) {
|
||||
const claim = claims[index];
|
||||
expect(claim).not.toBeNull();
|
||||
const candidate = candidatesById.get(claim!.poolEntryId)!;
|
||||
expect(general).toMatchObject({
|
||||
name: `ⓜ${candidate.name}`,
|
||||
stats: candidate.stats,
|
||||
picture: candidate.picture,
|
||||
imageServer: candidate.imageServer,
|
||||
role: { specialDomestic: candidate.specialDomestic },
|
||||
meta: {
|
||||
dex1: candidate.dex?.[0],
|
||||
dex2: candidate.dex?.[1],
|
||||
dex3: candidate.dex?.[2],
|
||||
dex4: candidate.dex?.[3],
|
||||
dex5: candidate.dex?.[4],
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -5,9 +5,31 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { loadScenarioDefinitionById, resolveScenarioDefaultsPath } from '../src/scenario/scenarioLoader.js';
|
||||
import { buildCommandEnv } from '../src/turn/reservedTurnCommands.js';
|
||||
import { hasRefSourceRoot, resolveRefSourceRoot } from './refSourceRoot.js';
|
||||
|
||||
type LoadedScenario = Awaited<ReturnType<typeof loadScenarioDefinitionById>>;
|
||||
|
||||
interface ReferenceScenario914 {
|
||||
title: string;
|
||||
startYear: number;
|
||||
map: Record<string, unknown>;
|
||||
history: string[];
|
||||
const: {
|
||||
allItems: Record<string, Record<string, number>>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
events: unknown[];
|
||||
}
|
||||
|
||||
interface ReferenceScenario915 {
|
||||
title: string;
|
||||
startYear: number;
|
||||
map: Record<string, unknown>;
|
||||
history: string[];
|
||||
const: Record<string, unknown>;
|
||||
events: unknown[];
|
||||
}
|
||||
|
||||
const readItemSlot = (scenario: LoadedScenario, slot: string): Record<string, number> => {
|
||||
const allItems = scenario.config.const.allItems as Record<string, Record<string, number>> | undefined;
|
||||
return allItems?.[slot] ?? {};
|
||||
@@ -16,6 +38,8 @@ const readItemSlot = (scenario: LoadedScenario, slot: string): Record<string, nu
|
||||
const readAvailableSpecialWar = (scenario: LoadedScenario): string[] =>
|
||||
(scenario.config.const.availableSpecialWar as string[] | undefined) ?? [];
|
||||
|
||||
const refSourceIt = hasRefSourceRoot() ? it : it.skip;
|
||||
|
||||
describe('tracked scenario resources', () => {
|
||||
it('loads every scenario through its composed resource graph', async () => {
|
||||
const scenarioRoot = path.dirname(resolveScenarioDefaultsPath());
|
||||
@@ -26,11 +50,88 @@ describe('tracked scenario resources', () => {
|
||||
.map((match) => Number(match[1]))
|
||||
.sort((left, right) => left - right);
|
||||
|
||||
expect(scenarioIds).toHaveLength(80);
|
||||
expect(scenarioIds).toContain(914);
|
||||
expect(scenarioIds).toContain(915);
|
||||
const scenarios = await Promise.all(scenarioIds.map((scenarioId) => loadScenarioDefinitionById(scenarioId)));
|
||||
expect(scenarios.every((scenario) => scenario.title.length > 0)).toBe(true);
|
||||
});
|
||||
|
||||
refSourceIt('preserves the Ref scenario 915 S100 pool and event order exactly', async () => {
|
||||
const referencePath = path.join(resolveRefSourceRoot(), 'hwe', 'scenario', 'scenario_915.json');
|
||||
const [scenario, referenceSource] = await Promise.all([
|
||||
loadScenarioDefinitionById(915),
|
||||
fs.readFile(referencePath, 'utf8').then((raw) => JSON.parse(raw) as ReferenceScenario915),
|
||||
]);
|
||||
|
||||
expect(scenario.title).toBe(referenceSource.title);
|
||||
expect(scenario.startYear).toBe(referenceSource.startYear);
|
||||
expect(scenario.config.map).toEqual(referenceSource.map);
|
||||
expect(scenario.history).toEqual(referenceSource.history);
|
||||
expect(scenario.config.const).toEqual(referenceSource.const);
|
||||
expect(scenario.events).toEqual(referenceSource.events);
|
||||
expect(
|
||||
scenario.events
|
||||
.filter((entry): entry is unknown[] => Array.isArray(entry) && entry[0] === 'month')
|
||||
.map((entry) => ({ priority: entry[1], condition: entry[2], actions: entry.slice(3) }))
|
||||
).toEqual([
|
||||
{ priority: 8_000, condition: true, actions: [['AdvanceCentennialAllStar']] },
|
||||
{
|
||||
priority: 1_000,
|
||||
condition: ['Date', '==', null, 12],
|
||||
actions: [['CreateManyNPC', 100, 0], ['DeleteEvent']],
|
||||
},
|
||||
{
|
||||
priority: 1_000,
|
||||
condition: ['Date', '==', 181, 1],
|
||||
actions: [['RaiseNPCNation'], ['DeleteEvent']],
|
||||
},
|
||||
{
|
||||
priority: 999,
|
||||
condition: ['Date', '==', 181, 1],
|
||||
actions: [['OpenNationBetting', 4, 5_000], ['OpenNationBetting', 1, 2_000], ['DeleteEvent']],
|
||||
},
|
||||
{
|
||||
priority: 999,
|
||||
condition: ['and', ['Date', '>=', 183, 1], ['RemainNation', '<=', 8]],
|
||||
actions: [['OpenNationBetting', 1, 1_000], ['DeleteEvent']],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
refSourceIt('preserves the Ref scenario 914 item pool, monthly action order, and deletion markers', async () => {
|
||||
const referencePath = path.join(resolveRefSourceRoot(), 'hwe', 'scenario', 'scenario_914.json');
|
||||
const [scenario, referenceSource] = await Promise.all([
|
||||
loadScenarioDefinitionById(914),
|
||||
fs.readFile(referencePath, 'utf8').then((raw) => JSON.parse(raw) as ReferenceScenario914),
|
||||
]);
|
||||
|
||||
expect(scenario.title).toBe(referenceSource.title);
|
||||
expect(scenario.startYear).toBe(referenceSource.startYear);
|
||||
expect(scenario.config.map).toEqual(referenceSource.map);
|
||||
expect(scenario.history).toEqual(referenceSource.history);
|
||||
expect(scenario.config.const).toEqual(referenceSource.const);
|
||||
expect(scenario.config.const.allItems).toEqual(referenceSource.const.allItems);
|
||||
for (const [slot, items] of Object.entries(referenceSource.const.allItems)) {
|
||||
expect(Object.keys(readItemSlot(scenario, slot))).toEqual(Object.keys(items));
|
||||
}
|
||||
expect(scenario.events).toEqual(referenceSource.events);
|
||||
|
||||
const monthlyActionNames = scenario.events
|
||||
.filter((event): event is unknown[] => Array.isArray(event) && event[0] === 'month')
|
||||
.map((event) =>
|
||||
event
|
||||
.slice(3)
|
||||
.map((action) => (Array.isArray(action) && typeof action[0] === 'string' ? action[0] : null))
|
||||
);
|
||||
expect(monthlyActionNames).toEqual([
|
||||
['CreateManyNPC', 'DeleteEvent'],
|
||||
['RaiseNPCNation', 'DeleteEvent'],
|
||||
['OpenNationBetting', 'OpenNationBetting', 'DeleteEvent'],
|
||||
['ChangeCity'],
|
||||
['ChangeCity'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('opens nation betting in the first playable year of every scenario 29 variant', async () => {
|
||||
const scenarioIds = [2900, 2901, 2903, 2904];
|
||||
const scenarios = await Promise.all(scenarioIds.map((scenarioId) => loadScenarioDefinitionById(scenarioId)));
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
createGamePostgresConnector,
|
||||
type GamePrisma,
|
||||
type GamePrismaClient,
|
||||
} from '@sammo-ts/infra';
|
||||
import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
import { buildScenarioGeneralPoolClaimMeta } from '@sammo-ts/logic';
|
||||
|
||||
import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js';
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
@@ -13,6 +10,10 @@ import { loadTurnWorldFromDatabase } from '../src/turn/worldLoader.js';
|
||||
const databaseUrl = process.env.SELECT_POOL_DATABASE_URL;
|
||||
const integration = describe.skipIf(!databaseUrl);
|
||||
const generalId = 990_904;
|
||||
const claimedGeneralId = 990_905;
|
||||
const conflictedGeneralId = 990_906;
|
||||
const protectedGeneralId = 990_907;
|
||||
const laterGeneralId = 990_908;
|
||||
const cityId = 990_904;
|
||||
const scenarioCode = 'select-pool-release-integration';
|
||||
|
||||
@@ -36,9 +37,23 @@ integration('select pool release during general deletion', () => {
|
||||
|
||||
await db.$executeRawUnsafe('DROP TABLE IF EXISTS "select_pool_delete_blocker"');
|
||||
await db.selectPoolEntry.deleteMany({
|
||||
where: { uniqueName: 'release-candidate' },
|
||||
where: {
|
||||
uniqueName: {
|
||||
in: [
|
||||
'release-candidate',
|
||||
'claim-candidate',
|
||||
'conflict-candidate',
|
||||
'early-protected-candidate',
|
||||
'later-free-candidate',
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
await db.general.deleteMany({
|
||||
where: {
|
||||
id: { in: [generalId, claimedGeneralId, conflictedGeneralId, protectedGeneralId, laterGeneralId] },
|
||||
},
|
||||
});
|
||||
await db.general.deleteMany({ where: { id: generalId } });
|
||||
await db.city.deleteMany({ where: { id: cityId } });
|
||||
await db.worldState.deleteMany({ where: { scenarioCode } });
|
||||
|
||||
@@ -154,6 +169,25 @@ integration('select pool release during general deletion', () => {
|
||||
} as GamePrisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
await db.selectPoolEntry.createMany({
|
||||
data: ['claim-candidate', 'conflict-candidate'].map((uniqueName) => ({
|
||||
uniqueName,
|
||||
ownerUserId: null,
|
||||
generalId: null,
|
||||
reservedUntil: null,
|
||||
info: {
|
||||
uniqueName,
|
||||
generalName: uniqueName === 'claim-candidate' ? '점유후보' : '충돌후보',
|
||||
leadership: 70,
|
||||
strength: 80,
|
||||
intel: 10,
|
||||
specialDomestic: 'che_event_징병',
|
||||
dex: [10, 20, 30, 40, 50],
|
||||
imgsvr: 0,
|
||||
picture: 'default.jpg',
|
||||
} as GamePrisma.InputJsonValue,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -163,9 +197,23 @@ integration('select pool release during general deletion', () => {
|
||||
}
|
||||
await db.$executeRawUnsafe('DROP TABLE IF EXISTS "select_pool_delete_blocker"');
|
||||
await db.selectPoolEntry.deleteMany({
|
||||
where: { uniqueName: 'release-candidate' },
|
||||
where: {
|
||||
uniqueName: {
|
||||
in: [
|
||||
'release-candidate',
|
||||
'claim-candidate',
|
||||
'conflict-candidate',
|
||||
'early-protected-candidate',
|
||||
'later-free-candidate',
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
await db.general.deleteMany({
|
||||
where: {
|
||||
id: { in: [generalId, claimedGeneralId, conflictedGeneralId, protectedGeneralId, laterGeneralId] },
|
||||
},
|
||||
});
|
||||
await db.general.deleteMany({ where: { id: generalId } });
|
||||
await db.city.deleteMany({ where: { id: cityId } });
|
||||
await db.worldState.deleteMany({ where: { scenarioCode } });
|
||||
await closeDb?.();
|
||||
@@ -201,14 +249,222 @@ integration('select pool release during general deletion', () => {
|
||||
special2Code: 'che_무쌍',
|
||||
});
|
||||
const reloaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
|
||||
expect(
|
||||
reloaded.snapshot.generals.find((general) => general.id === generalId)?.role
|
||||
).toMatchObject({
|
||||
expect(reloaded.snapshot.generals.find((general) => general.id === generalId)?.role).toMatchObject({
|
||||
specialDomestic: 'che_event_신산',
|
||||
specialWar: 'che_무쌍',
|
||||
});
|
||||
});
|
||||
|
||||
it('claims a pool row in the same fenced transaction that creates the NPC', async () => {
|
||||
const loaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
|
||||
const world = new InMemoryTurnWorld(loaded.state, loaded.snapshot, {
|
||||
schedule: { entries: [{ startMinute: 0, tickMinutes: 5 }] },
|
||||
});
|
||||
const candidate = world
|
||||
.listGeneralPoolCandidates(loaded.state.lastTurnTime)
|
||||
?.find((entry) => entry.uniqueName === 'claim-candidate');
|
||||
expect(candidate).toBeDefined();
|
||||
const template = world.getGeneralById(generalId)!;
|
||||
expect(
|
||||
world.addGeneral({
|
||||
...structuredClone(template),
|
||||
id: claimedGeneralId,
|
||||
userId: null,
|
||||
name: 'ⓜ점유후보',
|
||||
npcState: 3,
|
||||
officerLevel: 0,
|
||||
meta: {
|
||||
...template.meta,
|
||||
...buildScenarioGeneralPoolClaimMeta(candidate!, loaded.state.lastTurnTime),
|
||||
},
|
||||
})
|
||||
).toBe(true);
|
||||
|
||||
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
|
||||
try {
|
||||
await hooks.hooks.flushChanges?.({
|
||||
lastTurnTime: loaded.state.lastTurnTime.toISOString(),
|
||||
processedGenerals: 0,
|
||||
processedTurns: 0,
|
||||
durationMs: 0,
|
||||
partial: false,
|
||||
});
|
||||
} finally {
|
||||
await hooks.close();
|
||||
}
|
||||
|
||||
await expect(db.general.findUnique({ where: { id: claimedGeneralId } })).resolves.not.toBeNull();
|
||||
await expect(
|
||||
db.selectPoolEntry.findUniqueOrThrow({ where: { uniqueName: 'claim-candidate' } })
|
||||
).resolves.toMatchObject({
|
||||
generalId: claimedGeneralId,
|
||||
ownerUserId: null,
|
||||
reservedUntil: null,
|
||||
reservedUntilTick: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('rolls back the NPC and pool mutation when a concurrent user reservation wins', async () => {
|
||||
const loaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
|
||||
const world = new InMemoryTurnWorld(loaded.state, loaded.snapshot, {
|
||||
schedule: { entries: [{ startMinute: 0, tickMinutes: 5 }] },
|
||||
});
|
||||
const candidate = world
|
||||
.listGeneralPoolCandidates(loaded.state.lastTurnTime)
|
||||
?.find((entry) => entry.uniqueName === 'conflict-candidate');
|
||||
expect(candidate).toBeDefined();
|
||||
const template = world.getGeneralById(generalId)!;
|
||||
expect(
|
||||
world.addGeneral({
|
||||
...structuredClone(template),
|
||||
id: conflictedGeneralId,
|
||||
userId: null,
|
||||
name: 'ⓜ충돌후보',
|
||||
npcState: 3,
|
||||
officerLevel: 0,
|
||||
meta: {
|
||||
...template.meta,
|
||||
...buildScenarioGeneralPoolClaimMeta(candidate!, loaded.state.lastTurnTime),
|
||||
},
|
||||
})
|
||||
).toBe(true);
|
||||
const reservedUntil = new Date(loaded.state.lastTurnTime.getTime() + 60_000);
|
||||
await db.selectPoolEntry.update({
|
||||
where: { uniqueName: 'conflict-candidate' },
|
||||
data: { ownerUserId: 'concurrent-user', reservedUntil },
|
||||
});
|
||||
|
||||
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
|
||||
try {
|
||||
await expect(
|
||||
hooks.hooks.flushChanges?.({
|
||||
lastTurnTime: loaded.state.lastTurnTime.toISOString(),
|
||||
processedGenerals: 0,
|
||||
processedTurns: 0,
|
||||
durationMs: 0,
|
||||
partial: false,
|
||||
})
|
||||
).rejects.toThrow('select_pool 후보를 점유하지 못했습니다: conflict-candidate');
|
||||
} finally {
|
||||
await hooks.close();
|
||||
}
|
||||
|
||||
await expect(db.general.findUnique({ where: { id: conflictedGeneralId } })).resolves.toBeNull();
|
||||
await expect(
|
||||
db.selectPoolEntry.findUniqueOrThrow({ where: { uniqueName: 'conflict-candidate' } })
|
||||
).resolves.toMatchObject({
|
||||
generalId: null,
|
||||
ownerUserId: 'concurrent-user',
|
||||
reservedUntil,
|
||||
});
|
||||
});
|
||||
|
||||
it('checks every NPC claim at its own claimedAt without clearing a later-expiring user reservation', async () => {
|
||||
const earlyClaimedAt = new Date('2026-07-30T12:00:00.000Z');
|
||||
const reservedUntil = new Date('2026-07-30T12:05:00.000Z');
|
||||
const laterClaimedAt = new Date('2026-07-30T12:10:00.000Z');
|
||||
await db.selectPoolEntry.createMany({
|
||||
data: [
|
||||
{
|
||||
uniqueName: 'early-protected-candidate',
|
||||
ownerUserId: 'protected-user',
|
||||
generalId: null,
|
||||
reservedUntil,
|
||||
info: {
|
||||
uniqueName: 'early-protected-candidate',
|
||||
generalName: '보호후보',
|
||||
leadership: 70,
|
||||
strength: 80,
|
||||
intel: 10,
|
||||
specialDomestic: 'che_event_징병',
|
||||
dex: [10, 20, 30, 40, 50],
|
||||
imgsvr: 0,
|
||||
picture: 'default.jpg',
|
||||
} as GamePrisma.InputJsonValue,
|
||||
},
|
||||
{
|
||||
uniqueName: 'later-free-candidate',
|
||||
ownerUserId: null,
|
||||
generalId: null,
|
||||
reservedUntil: null,
|
||||
info: {
|
||||
uniqueName: 'later-free-candidate',
|
||||
generalName: '후행후보',
|
||||
leadership: 70,
|
||||
strength: 80,
|
||||
intel: 10,
|
||||
specialDomestic: 'che_event_징병',
|
||||
dex: [10, 20, 30, 40, 50],
|
||||
imgsvr: 0,
|
||||
picture: 'default.jpg',
|
||||
} as GamePrisma.InputJsonValue,
|
||||
},
|
||||
],
|
||||
});
|
||||
const loaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
|
||||
const world = new InMemoryTurnWorld(loaded.state, loaded.snapshot, {
|
||||
schedule: { entries: [{ startMinute: 0, tickMinutes: 5 }] },
|
||||
});
|
||||
const entries = world.listGeneralPoolEntries()!;
|
||||
const protectedCandidate = entries.find((entry) => entry.uniqueName === 'early-protected-candidate')!.candidate;
|
||||
const laterCandidate = entries.find((entry) => entry.uniqueName === 'later-free-candidate')!.candidate;
|
||||
const template = world.getGeneralById(generalId)!;
|
||||
expect(
|
||||
world.addGeneral({
|
||||
...structuredClone(template),
|
||||
id: protectedGeneralId,
|
||||
userId: null,
|
||||
name: 'ⓜ보호후보',
|
||||
npcState: 3,
|
||||
meta: {
|
||||
...template.meta,
|
||||
...buildScenarioGeneralPoolClaimMeta(protectedCandidate, earlyClaimedAt),
|
||||
},
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
world.addGeneral({
|
||||
...structuredClone(template),
|
||||
id: laterGeneralId,
|
||||
userId: null,
|
||||
name: 'ⓜ후행후보',
|
||||
npcState: 3,
|
||||
meta: {
|
||||
...template.meta,
|
||||
...buildScenarioGeneralPoolClaimMeta(laterCandidate, laterClaimedAt),
|
||||
},
|
||||
})
|
||||
).toBe(true);
|
||||
|
||||
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
|
||||
try {
|
||||
await expect(
|
||||
hooks.hooks.flushChanges?.({
|
||||
lastTurnTime: loaded.state.lastTurnTime.toISOString(),
|
||||
processedGenerals: 0,
|
||||
processedTurns: 0,
|
||||
durationMs: 0,
|
||||
partial: false,
|
||||
})
|
||||
).rejects.toThrow('select_pool 후보를 점유하지 못했습니다: early-protected-candidate');
|
||||
} finally {
|
||||
await hooks.close();
|
||||
}
|
||||
|
||||
await expect(db.general.findUnique({ where: { id: protectedGeneralId } })).resolves.toBeNull();
|
||||
await expect(db.general.findUnique({ where: { id: laterGeneralId } })).resolves.toBeNull();
|
||||
await expect(
|
||||
db.selectPoolEntry.findUniqueOrThrow({ where: { uniqueName: 'early-protected-candidate' } })
|
||||
).resolves.toMatchObject({
|
||||
generalId: null,
|
||||
ownerUserId: 'protected-user',
|
||||
reservedUntil,
|
||||
});
|
||||
await expect(
|
||||
db.selectPoolEntry.findUniqueOrThrow({ where: { uniqueName: 'later-free-candidate' } })
|
||||
).resolves.toMatchObject({ generalId: null, ownerUserId: null, reservedUntil: null });
|
||||
});
|
||||
|
||||
it('rolls back a failed flush, then releases all Ref fields before deleting the general', async () => {
|
||||
const loaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
|
||||
const world = new InMemoryTurnWorld(loaded.state, loaded.snapshot, {
|
||||
@@ -224,9 +480,7 @@ integration('select pool release during general deletion', () => {
|
||||
REFERENCES "general"("id") ON DELETE RESTRICT
|
||||
)
|
||||
`);
|
||||
await db.$executeRawUnsafe(
|
||||
`INSERT INTO "select_pool_delete_blocker" ("general_id") VALUES (${generalId})`
|
||||
);
|
||||
await db.$executeRawUnsafe(`INSERT INTO "select_pool_delete_blocker" ("general_id") VALUES (${generalId})`);
|
||||
|
||||
await expect(
|
||||
hooks.hooks.flushChanges?.({
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { GAME_TICKS_PER_TURN } from '@sammo-ts/common';
|
||||
import { parseScenarioGeneralPoolCandidate } from '@sammo-ts/logic';
|
||||
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import { reserveSelectionPool } from '../src/turn/selectPoolService.js';
|
||||
import type { TurnGeneralPoolEntry, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
|
||||
interface TestPoolRow {
|
||||
id: number;
|
||||
uniqueName: string;
|
||||
ownerUserId: string | null;
|
||||
generalId: number | null;
|
||||
reservedUntil: Date | null;
|
||||
reservedUntilTick: bigint | null;
|
||||
info: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface PoolWhere {
|
||||
id?: { in: number[] };
|
||||
ownerUserId?: string | null;
|
||||
generalId?: number | null;
|
||||
reservedUntil?: null | { lt?: Date; gte?: Date };
|
||||
reservedUntilTick?: null | { lt?: bigint; gte?: bigint };
|
||||
OR?: PoolWhere[];
|
||||
}
|
||||
|
||||
const acceptedAt = new Date('0200-05-01T00:00:00.000Z');
|
||||
|
||||
const buildRows = (): TestPoolRow[] =>
|
||||
Array.from({ length: 29 }, (_, index) => {
|
||||
const uniqueName = `P${String(index + 1).padStart(2, '0')}`;
|
||||
return {
|
||||
id: index + 1,
|
||||
uniqueName,
|
||||
ownerUserId: index === 0 ? 'existing-user' : null,
|
||||
generalId: null,
|
||||
reservedUntil: index === 0 ? new Date(acceptedAt.getTime() + 30 * 60_000) : null,
|
||||
reservedUntilTick: index === 0 ? 3_000_000n : null,
|
||||
info: {
|
||||
uniqueName,
|
||||
generalName: uniqueName,
|
||||
leadership: 70,
|
||||
strength: 80,
|
||||
intel: 10,
|
||||
specialDomestic: null,
|
||||
dex: [100 + index, 0, 0, 0, 0],
|
||||
imgsvr: 0,
|
||||
picture: 'default.jpg',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const buildWorld = (rows: TestPoolRow[]): InMemoryTurnWorld => {
|
||||
const generalPoolEntries: TurnGeneralPoolEntry[] = rows.map((row) => ({
|
||||
id: row.id,
|
||||
uniqueName: row.uniqueName,
|
||||
ownerUserId: row.ownerUserId,
|
||||
generalId: row.generalId,
|
||||
reservedUntil: row.reservedUntil,
|
||||
reservedUntilTick: row.reservedUntilTick === null ? null : Number(row.reservedUntilTick),
|
||||
candidate: parseScenarioGeneralPoolCandidate(row),
|
||||
}));
|
||||
const state: TurnWorldState = {
|
||||
id: 1,
|
||||
currentYear: 200,
|
||||
currentMonth: 5,
|
||||
tickSeconds: 300,
|
||||
lastTurnTime: acceptedAt,
|
||||
meta: { hiddenSeed: 'selection-reservation-test' },
|
||||
};
|
||||
const snapshot: TurnWorldSnapshot = {
|
||||
scenarioConfig: {
|
||||
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 },
|
||||
iconPath: '',
|
||||
map: { targetGeneralPool: 'SPoolUnderU30' },
|
||||
const: {},
|
||||
environment: { mapName: 'test', unitSet: 'default' },
|
||||
},
|
||||
map: {
|
||||
id: 'test',
|
||||
name: 'test',
|
||||
cities: [],
|
||||
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
|
||||
},
|
||||
generals: [],
|
||||
cities: [],
|
||||
nations: [],
|
||||
troops: [],
|
||||
diplomacy: [],
|
||||
events: [],
|
||||
initialEvents: [],
|
||||
generalPoolEntries,
|
||||
};
|
||||
return new InMemoryTurnWorld(state, snapshot, {
|
||||
schedule: { entries: [{ startMinute: 0, tickMinutes: 5 }] },
|
||||
});
|
||||
};
|
||||
|
||||
const matchesPoolWhere = (row: TestPoolRow, where: PoolWhere): boolean => {
|
||||
if (where.id && !where.id.in.includes(row.id)) {
|
||||
return false;
|
||||
}
|
||||
if (where.ownerUserId !== undefined && row.ownerUserId !== where.ownerUserId) {
|
||||
return false;
|
||||
}
|
||||
if (where.generalId !== undefined && row.generalId !== where.generalId) {
|
||||
return false;
|
||||
}
|
||||
if (where.reservedUntil !== undefined) {
|
||||
if (where.reservedUntil === null) {
|
||||
if (row.reservedUntil !== null) {
|
||||
return false;
|
||||
}
|
||||
} else if (
|
||||
row.reservedUntil === null ||
|
||||
(where.reservedUntil.lt !== undefined && row.reservedUntil >= where.reservedUntil.lt) ||
|
||||
(where.reservedUntil.gte !== undefined && row.reservedUntil < where.reservedUntil.gte)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (where.reservedUntilTick !== undefined) {
|
||||
if (where.reservedUntilTick === null) {
|
||||
if (row.reservedUntilTick !== null) {
|
||||
return false;
|
||||
}
|
||||
} else if (
|
||||
row.reservedUntilTick === null ||
|
||||
(where.reservedUntilTick.lt !== undefined && row.reservedUntilTick >= where.reservedUntilTick.lt) ||
|
||||
(where.reservedUntilTick.gte !== undefined && row.reservedUntilTick < where.reservedUntilTick.gte)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return where.OR === undefined || where.OR.some((alternative) => matchesPoolWhere(row, alternative));
|
||||
};
|
||||
|
||||
const buildDb = (rows: TestPoolRow[]) => ({
|
||||
$executeRaw: async () => 0,
|
||||
general: {
|
||||
findFirst: async () => null,
|
||||
},
|
||||
selectPoolEntry: {
|
||||
findMany: async () => structuredClone(rows),
|
||||
updateMany: async (input: { where: PoolWhere; data: Partial<TestPoolRow> }) => {
|
||||
let count = 0;
|
||||
for (const row of rows) {
|
||||
if (!matchesPoolWhere(row, input.where)) {
|
||||
continue;
|
||||
}
|
||||
Object.assign(row, input.data);
|
||||
count += 1;
|
||||
}
|
||||
return { count };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const worldState = {
|
||||
currentYear: 200,
|
||||
currentMonth: 5,
|
||||
tickSeconds: 300,
|
||||
config: {
|
||||
npcMode: 2,
|
||||
turnTermMinutes: 5,
|
||||
map: { targetGeneralPool: 'SPoolUnderU30' },
|
||||
},
|
||||
meta: { hiddenSeed: 'selection-reservation-test' },
|
||||
};
|
||||
|
||||
describe('selection-pool reservation command state', () => {
|
||||
it('excludes current reservations and keeps serialized users disjoint in DB and memory', async () => {
|
||||
const rows = buildRows();
|
||||
const world = buildWorld(rows);
|
||||
const db = buildDb(rows);
|
||||
const reserve = (userId: string, acceptedGameTick: number) =>
|
||||
reserveSelectionPool({
|
||||
db: db as never,
|
||||
world,
|
||||
worldState: worldState as never,
|
||||
userId,
|
||||
seedOwnerIdentity: userId,
|
||||
now: acceptedAt,
|
||||
acceptedGameTick,
|
||||
});
|
||||
|
||||
const first = await reserve('first-user', 0);
|
||||
const retried = await reserve('first-user', 1);
|
||||
const second = await reserve('second-user', 2);
|
||||
|
||||
expect(retried).toEqual(first);
|
||||
expect(first.candidates).toHaveLength(14);
|
||||
expect(second.candidates).toHaveLength(14);
|
||||
expect(new Set(first.candidates.map((candidate) => candidate.uniqueName))).not.toContain('P01');
|
||||
expect(
|
||||
first.candidates.some((candidate) =>
|
||||
second.candidates.some((other) => other.uniqueName === candidate.uniqueName)
|
||||
)
|
||||
).toBe(false);
|
||||
expect(rows.filter((row) => row.ownerUserId === 'first-user')).toHaveLength(14);
|
||||
expect(rows.filter((row) => row.ownerUserId === 'second-user')).toHaveLength(14);
|
||||
expect(rows.find((row) => row.ownerUserId === 'first-user')?.reservedUntilTick).toBe(
|
||||
BigInt(2 * GAME_TICKS_PER_TURN)
|
||||
);
|
||||
expect(first.validUntil).toBe(world.gameTickToDate(2 * GAME_TICKS_PER_TURN).toISOString());
|
||||
expect(world.listGeneralPoolCandidates(acceptedAt)).toEqual([]);
|
||||
});
|
||||
|
||||
it('uses Ref nowTick equality and resynchronizes DB expiry into the in-memory pool', async () => {
|
||||
const rows = buildRows();
|
||||
rows[0]!.ownerUserId = 'stale-user';
|
||||
rows[0]!.reservedUntil = new Date(acceptedAt.getTime() + 60 * 60_000);
|
||||
rows[0]!.reservedUntilTick = -1n;
|
||||
rows[1]!.ownerUserId = 'exact-user';
|
||||
rows[1]!.reservedUntil = new Date(acceptedAt.getTime() - 60_000);
|
||||
rows[1]!.reservedUntilTick = 0n;
|
||||
const world = buildWorld(rows);
|
||||
const db = buildDb(rows);
|
||||
const reserve = (userId: string, acceptedGameTick: number) =>
|
||||
reserveSelectionPool({
|
||||
db: db as never,
|
||||
world,
|
||||
worldState: worldState as never,
|
||||
userId,
|
||||
seedOwnerIdentity: userId,
|
||||
now: acceptedAt,
|
||||
acceptedGameTick,
|
||||
});
|
||||
|
||||
const first = await reserve('first-user', 0);
|
||||
|
||||
expect(rows[0]!.ownerUserId).not.toBe('stale-user');
|
||||
expect(rows[1]).toMatchObject({ ownerUserId: 'exact-user', reservedUntilTick: 0n });
|
||||
expect(first.candidates.map((candidate) => candidate.uniqueName)).not.toContain('P02');
|
||||
expect(first.validUntil).toBe(world.gameTickToDate(2 * GAME_TICKS_PER_TURN).toISOString());
|
||||
|
||||
await reserve('second-user', 1);
|
||||
|
||||
expect(rows[1]!.ownerUserId).not.toBe('exact-user');
|
||||
const synchronizedById = new Map(world.listGeneralPoolEntries()?.map((entry) => [entry.id, entry]));
|
||||
for (const row of rows) {
|
||||
expect(synchronizedById.get(row.id)).toMatchObject({
|
||||
ownerUserId: row.ownerUserId,
|
||||
generalId: row.generalId,
|
||||
reservedUntil: row.reservedUntil,
|
||||
reservedUntilTick: row.reservedUntilTick === null ? null : Number(row.reservedUntilTick),
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import { buildPersistedRankRows } from '../src/turn/rankData.js';
|
||||
|
||||
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
|
||||
|
||||
const buildGeneral = (id: number, meta: Record<string, number> = {}): TurnGeneral => ({
|
||||
const buildGeneral = (id: number, meta: Record<string, number> = {}, npcState = 0): TurnGeneral => ({
|
||||
id,
|
||||
name: `장수${id}`,
|
||||
nationId: 1,
|
||||
@@ -39,7 +39,7 @@ const buildGeneral = (id: number, meta: Record<string, number> = {}): TurnGenera
|
||||
train: 0,
|
||||
atmos: 0,
|
||||
age: 30,
|
||||
npcState: 0,
|
||||
npcState,
|
||||
});
|
||||
|
||||
const buildWorld = (
|
||||
@@ -106,10 +106,7 @@ describe('tournament world commands', () => {
|
||||
});
|
||||
|
||||
it('updates the persisted tt rank keys for a tournament match', async () => {
|
||||
const world = buildWorld([
|
||||
buildGeneral(1, { ttg: 10, ttw: 2 }),
|
||||
buildGeneral(2, { ttg: 5, ttl: 1 }),
|
||||
]);
|
||||
const world = buildWorld([buildGeneral(1, { ttg: 10, ttw: 2 }), buildGeneral(2, { ttg: 5, ttl: 1 })]);
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
|
||||
await expect(
|
||||
@@ -135,6 +132,7 @@ describe('tournament world commands', () => {
|
||||
handler.handle({
|
||||
type: 'tournamentBettingPayout',
|
||||
bettingId: 1,
|
||||
tournamentType: 0,
|
||||
payouts: [{ generalId: 1, amount: 500 }],
|
||||
})
|
||||
).resolves.toMatchObject({ ok: true, totalPayout: 500 });
|
||||
@@ -144,6 +142,48 @@ describe('tournament world commands', () => {
|
||||
meta: { betwin: 3, betwingold: 600 },
|
||||
});
|
||||
expect(world.getGeneralById(1)?.meta).not.toHaveProperty('rank_betwin');
|
||||
expect(world.peekDirtyState().logs).toContainEqual(
|
||||
expect.objectContaining({
|
||||
generalId: 1,
|
||||
category: 'ACTION',
|
||||
text: '<C>전력전</>의 베팅 당첨 보상으로 <C>500</>의 <S>금</> 획득!',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('pays every winner but records betting ranks only for Ref-eligible generals', async () => {
|
||||
const world = buildWorld([
|
||||
buildGeneral(1, {}, 0),
|
||||
buildGeneral(2, { betgold: 100 }, 1),
|
||||
buildGeneral(3, { betgold: 100 }, 2),
|
||||
]);
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
|
||||
await expect(
|
||||
handler.handle({
|
||||
type: 'tournamentBettingPayout',
|
||||
bettingId: 1,
|
||||
tournamentType: 3,
|
||||
payouts: [
|
||||
{ generalId: 1, amount: 2_000 },
|
||||
{ generalId: 2, amount: 2_000 },
|
||||
{ generalId: 3, amount: 2_000 },
|
||||
],
|
||||
})
|
||||
).resolves.toMatchObject({ ok: true, processed: 3, totalPayout: 6_000 });
|
||||
|
||||
expect(world.getGeneralById(1)).toMatchObject({ gold: 3_000, meta: { betwin: 1, betwingold: 2_000 } });
|
||||
expect(world.getGeneralById(2)).toMatchObject({
|
||||
gold: 3_000,
|
||||
meta: { betgold: 100, betwin: 1, betwingold: 2_000 },
|
||||
});
|
||||
expect(world.getGeneralById(3)).toMatchObject({ gold: 3_000, meta: { betgold: 100 } });
|
||||
expect(world.getGeneralById(3)?.meta).not.toHaveProperty('betwin');
|
||||
expect(world.peekDirtyState().logs.map((entry) => entry.text)).toEqual([
|
||||
'<C>설전</>의 베팅 당첨 보상으로 <C>2,000</>의 <S>금</> 획득!',
|
||||
'<C>설전</>의 베팅 당첨 보상으로 <C>2,000</>의 <S>금</> 획득!',
|
||||
'<C>설전</>의 베팅 당첨 보상으로 <C>2,000</>의 <S>금</> 획득!',
|
||||
]);
|
||||
});
|
||||
|
||||
it('records all four tournament types and NPC betting for at least ten generals', async () => {
|
||||
@@ -174,6 +214,7 @@ describe('tournament world commands', () => {
|
||||
await handler.handle({
|
||||
type: 'tournamentBettingPayout',
|
||||
bettingId: 1,
|
||||
tournamentType: 0,
|
||||
payouts: generals.map((general) => ({ generalId: general.id, amount: 2_000 })),
|
||||
});
|
||||
|
||||
|
||||
@@ -241,6 +241,49 @@ describe('TurnDaemonLifecycle', () => {
|
||||
expect(observedTargets[0]?.toISOString()).toBe('2042-01-01T02:59:59.999Z');
|
||||
});
|
||||
|
||||
it('limits an explicit manual run target to the next monthly boundary', async () => {
|
||||
const lastTurnTime = new Date('2042-01-01T00:00:00.000Z');
|
||||
const requestedTarget = addMinutes(lastTurnTime, 180);
|
||||
const queue = new InMemoryControlQueue();
|
||||
const processor: TurnProcessor = {
|
||||
run: vi.fn(async (target): Promise<TurnRunResult> => {
|
||||
queue.enqueue({ type: 'shutdown', reason: 'verified' });
|
||||
return {
|
||||
lastTurnTime: target.toISOString(),
|
||||
processedGenerals: 1,
|
||||
processedTurns: 1,
|
||||
durationMs: 0,
|
||||
partial: false,
|
||||
};
|
||||
}),
|
||||
};
|
||||
const lifecycle = new TurnDaemonLifecycle(
|
||||
{
|
||||
clock: new ManualClock(lastTurnTime.getTime()),
|
||||
controlQueue: queue,
|
||||
getNextTickTime: (value) => addMinutes(value, 60),
|
||||
stateStore: {
|
||||
loadLastTurnTime: async () => lastTurnTime,
|
||||
loadNextGeneralTurnTime: async () => addMinutes(lastTurnTime, 30),
|
||||
saveLastTurnTime: async () => {},
|
||||
loadCheckpoint: async () => undefined,
|
||||
saveCheckpoint: async () => {},
|
||||
},
|
||||
processor,
|
||||
},
|
||||
{
|
||||
profile: 'manual-explicit-boundary',
|
||||
defaultBudget: { budgetMs: 100, maxGenerals: 10, catchUpCap: 1 },
|
||||
}
|
||||
);
|
||||
|
||||
lifecycle.requestRun('manual', requestedTarget);
|
||||
await lifecycle.start();
|
||||
|
||||
expect(processor.run).toHaveBeenCalledOnce();
|
||||
expect((processor.run as ReturnType<typeof vi.fn>).mock.calls[0]?.[0]).toEqual(addMinutes(lastTurnTime, 60));
|
||||
});
|
||||
|
||||
it('produces the same command, RNG, and resource state in realtime and manual modes', async () => {
|
||||
const start = new Date('2042-01-01T00:00:00.000Z');
|
||||
const runMode = async (mode: 'realtime' | 'manual') => {
|
||||
|
||||
@@ -193,7 +193,16 @@ describe('unification handler', () => {
|
||||
await world.advanceMonth(new Date('0190-07-01T00:00:00.000Z'));
|
||||
|
||||
expect(observed).toEqual([{ isUnited: undefined, unifier: 2007, previous: 150, spent: 20 }]);
|
||||
expect(world.getState().meta).toMatchObject({ isUnited: 2, isunited: 2, refreshLimit: 200 });
|
||||
expect(world.getState().meta).toMatchObject({
|
||||
isUnited: 2,
|
||||
isunited: 2,
|
||||
refreshLimit: 200,
|
||||
dynastyStatistics: {
|
||||
maxNationCount: 1,
|
||||
maxGeneralCount: 1,
|
||||
currentGeneralCount: 1,
|
||||
},
|
||||
});
|
||||
expect(world.getGeneralById(1)).toMatchObject({
|
||||
inheritancePoints: { previous: 150, unifier: 2007, tournament: 11 },
|
||||
meta: { inherit_earned_dyn: 2162.1, inherit_earned: 2167.1, inherit_spent: 20 },
|
||||
|
||||
@@ -12,8 +12,9 @@ import {
|
||||
} from '@sammo-ts/logic';
|
||||
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import { normalizeTurnDaemonCommand } from '../src/turn/commandRegistry.js';
|
||||
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
|
||||
import { createTurnDaemonCommandHandler, hasVotePollDeadlinePassed } from '../src/turn/worldCommandHandler.js';
|
||||
|
||||
const buildGeneral = (id: number): TurnGeneral => ({
|
||||
id,
|
||||
@@ -46,6 +47,39 @@ const buildGeneral = (id: number): TurnGeneral => ({
|
||||
});
|
||||
|
||||
describe('voteReward command', () => {
|
||||
it('keeps the wall-time fallback open at exact deadline equality', () => {
|
||||
const deadline = new Date('0180-01-01T00:00:00.000Z');
|
||||
|
||||
expect(hasVotePollDeadlinePassed({ endAt: deadline, endTick: null, closedAt: null }, deadline, 0)).toBe(false);
|
||||
expect(
|
||||
hasVotePollDeadlinePassed(
|
||||
{ endAt: deadline, endTick: null, closedAt: null },
|
||||
new Date(deadline.getTime() + 1),
|
||||
0
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves the server-accepted game tick through durable command normalization', () => {
|
||||
expect(
|
||||
normalizeTurnDaemonCommand({
|
||||
requestId: 'vote-accepted-tick',
|
||||
sentAt: '2026-08-23T00:00:00.000Z',
|
||||
command: {
|
||||
type: 'voteReward',
|
||||
voteId: 1,
|
||||
generalId: 1,
|
||||
selection: [0],
|
||||
acceptedGameTick: 100,
|
||||
},
|
||||
})
|
||||
).toMatchObject({
|
||||
type: 'voteReward',
|
||||
requestId: 'vote-accepted-tick',
|
||||
acceptedGameTick: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it('applies gold, unique item, logs, and idempotency', async () => {
|
||||
const generals = [buildGeneral(1)];
|
||||
const snapshot: TurnWorldSnapshot = {
|
||||
@@ -117,6 +151,7 @@ describe('voteReward command', () => {
|
||||
iconPath: '',
|
||||
map: {},
|
||||
const: {
|
||||
develCost: 100,
|
||||
allItems: {
|
||||
weapon: {
|
||||
che_무기_12_칠성검: 1,
|
||||
@@ -141,12 +176,18 @@ describe('voteReward command', () => {
|
||||
currentMonth: 1,
|
||||
tickSeconds: 3600,
|
||||
lastTurnTime: new Date('0180-01-01T00:00:00Z'),
|
||||
clockBaseTime: new Date('0180-01-01T00:00:00Z'),
|
||||
clockTick: 10_000,
|
||||
clockMode: 'manual',
|
||||
clockWallAnchor: new Date('2026-08-23T00:00:00Z'),
|
||||
meta: {
|
||||
hiddenSeed: 'seed',
|
||||
scenarioId: 200,
|
||||
initYear: 180,
|
||||
initMonth: 1,
|
||||
scenarioMeta: { startYear: 180 },
|
||||
// Simulate ENGINE processing after the yearly develcost update.
|
||||
develcost: 120,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -179,19 +220,42 @@ describe('voteReward command', () => {
|
||||
|
||||
expect(itemKey).toBe('che_무기_12_칠성검');
|
||||
|
||||
let voteInserted = false;
|
||||
let voteQueryCount = 0;
|
||||
const commandDb = {
|
||||
auction: {
|
||||
findMany: async () => [],
|
||||
},
|
||||
$queryRaw: async (query: { strings: readonly string[] }) => {
|
||||
voteQueryCount += 1;
|
||||
if (query.strings.join(' ').includes('SELECT options')) {
|
||||
return [
|
||||
{
|
||||
options: ['찬성'],
|
||||
multipleOptions: 1,
|
||||
endAt: null,
|
||||
endTick: 0n,
|
||||
closedAt: null,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (voteInserted) return [];
|
||||
voteInserted = true;
|
||||
return [{ id: 11 }];
|
||||
},
|
||||
};
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
const command = {
|
||||
type: 'voteReward' as const,
|
||||
voteId: 1,
|
||||
generalId: 1,
|
||||
goldReward: 500,
|
||||
unique: {
|
||||
expected: true,
|
||||
itemKey,
|
||||
},
|
||||
selection: [0],
|
||||
// Ref accepts the request at exact equality. Engine processing may
|
||||
// occur after the logical clock has advanced beyond the deadline.
|
||||
acceptedGameTick: 0,
|
||||
};
|
||||
|
||||
const result = await handler.handle(command);
|
||||
const result = await handler.handle(command, { db: commandDb as any });
|
||||
expect(result && result.type).toBe('voteReward');
|
||||
if (!result || result.type !== 'voteReward' || !result.ok) {
|
||||
throw new Error('voteReward result missing');
|
||||
@@ -199,7 +263,9 @@ describe('voteReward command', () => {
|
||||
expect(result.awardedUnique).toBe(true);
|
||||
|
||||
const updated = world.getGeneralById(1);
|
||||
expect(updated?.gold).toBe(1500);
|
||||
// ENGINE is the single reward linearization point, so it uses the
|
||||
// processing world's develcost (120 * 5), not an API projection.
|
||||
expect(updated?.gold).toBe(1600);
|
||||
expect(updated?.role.items.weapon).toBe('che_무기_12_칠성검');
|
||||
const meta = updated?.meta as Record<string, unknown>;
|
||||
expect(meta.voteRewards).toMatchObject({
|
||||
@@ -213,7 +279,7 @@ describe('voteReward command', () => {
|
||||
const logTexts = diff.logs.map((entry) => entry.text);
|
||||
expect(logTexts.some((text) => text.includes('【설문조사】'))).toBe(true);
|
||||
|
||||
const second = await handler.handle(command);
|
||||
const second = await handler.handle(command, { db: commandDb as any });
|
||||
expect(second && second.type).toBe('voteReward');
|
||||
if (!second || second.type !== 'voteReward' || !second.ok) {
|
||||
throw new Error('voteReward second result missing');
|
||||
@@ -221,7 +287,108 @@ describe('voteReward command', () => {
|
||||
expect(second.alreadyApplied).toBe(true);
|
||||
expect(second.itemKey).toBe('che_무기_12_칠성검');
|
||||
const afterSecond = world.getGeneralById(1);
|
||||
expect(afterSecond?.gold).toBe(1500);
|
||||
expect(afterSecond?.gold).toBe(1600);
|
||||
expect(voteQueryCount).toBe(2);
|
||||
|
||||
const duplicateWorld = new InMemoryTurnWorld(
|
||||
{ ...state, meta: { ...state.meta } },
|
||||
{ ...snapshot, generals: [buildGeneral(1)] as any },
|
||||
{ schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] } }
|
||||
);
|
||||
const duplicateHandler = createTurnDaemonCommandHandler({ world: duplicateWorld });
|
||||
const duplicateResult = await duplicateHandler.handle(command, {
|
||||
db: {
|
||||
auction: { findMany: async () => [] },
|
||||
$queryRaw: async (query: { strings: readonly string[] }) => {
|
||||
const text = query.strings.join(' ');
|
||||
if (text.includes('SELECT options')) {
|
||||
return [
|
||||
{
|
||||
options: ['찬성'],
|
||||
multipleOptions: 1,
|
||||
endAt: null,
|
||||
endTick: 0n,
|
||||
closedAt: null,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (text.includes('SELECT selection')) return [{ selection: [0] }];
|
||||
return [];
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
expect(duplicateResult).toMatchObject({
|
||||
type: 'voteReward',
|
||||
ok: true,
|
||||
awardedUnique: true,
|
||||
});
|
||||
expect(duplicateWorld.getGeneralById(1)?.gold).toBe(1600);
|
||||
expect(duplicateWorld.getGeneralById(1)?.role.items.weapon).toBe('che_무기_12_칠성검');
|
||||
|
||||
const mismatchWorld = new InMemoryTurnWorld(
|
||||
{ ...state, meta: { ...state.meta } },
|
||||
{ ...snapshot, generals: [buildGeneral(1)] as any },
|
||||
{ schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] } }
|
||||
);
|
||||
const mismatchHandler = createTurnDaemonCommandHandler({ world: mismatchWorld });
|
||||
const mismatchResult = await mismatchHandler.handle(command, {
|
||||
db: {
|
||||
auction: { findMany: async () => [] },
|
||||
$queryRaw: async (query: { strings: readonly string[] }) => {
|
||||
const text = query.strings.join(' ');
|
||||
if (text.includes('SELECT options')) {
|
||||
return [
|
||||
{
|
||||
options: ['찬성'],
|
||||
multipleOptions: 1,
|
||||
endAt: null,
|
||||
endTick: 0n,
|
||||
closedAt: null,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (text.includes('SELECT selection')) return [{ selection: [1] }];
|
||||
return [];
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
expect(mismatchResult).toMatchObject({
|
||||
type: 'voteReward',
|
||||
ok: false,
|
||||
reason: '이미 설문조사를 완료하였습니다.',
|
||||
});
|
||||
expect(mismatchWorld.getGeneralById(1)?.gold).toBe(1000);
|
||||
expect(mismatchWorld.getGeneralById(1)?.role.items.weapon).toBeNull();
|
||||
expect(mismatchWorld.consumeDirtyState().logs).toEqual([]);
|
||||
|
||||
const legacyLateWorld = new InMemoryTurnWorld(
|
||||
{ ...state, meta: { ...state.meta } },
|
||||
{ ...snapshot, generals: [buildGeneral(1)] as any },
|
||||
{ schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] } }
|
||||
);
|
||||
const legacyLateHandler = createTurnDaemonCommandHandler({ world: legacyLateWorld });
|
||||
const { acceptedGameTick: _acceptedGameTick, ...legacyLateCommand } = command;
|
||||
const legacyLateResult = await legacyLateHandler.handle(legacyLateCommand, {
|
||||
db: {
|
||||
$queryRaw: async (query: { strings: readonly string[] }) =>
|
||||
query.strings.join(' ').includes('SELECT options')
|
||||
? [
|
||||
{
|
||||
options: ['찬성'],
|
||||
multipleOptions: 1,
|
||||
endAt: null,
|
||||
endTick: 0n,
|
||||
closedAt: null,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
} as any,
|
||||
});
|
||||
expect(legacyLateResult).toMatchObject({
|
||||
type: 'voteReward',
|
||||
ok: false,
|
||||
reason: '설문조사가 종료되었습니다.',
|
||||
});
|
||||
});
|
||||
|
||||
it('treats an active unique auction as occupied when revalidating the lottery', async () => {
|
||||
@@ -245,6 +412,7 @@ describe('voteReward command', () => {
|
||||
iconPath: '',
|
||||
map: {},
|
||||
const: {
|
||||
develCost: 100,
|
||||
allItems: { weapon: { che_무기_12_칠성검: 1 } },
|
||||
maxUniqueItemLimit: [[-1, 1]],
|
||||
uniqueTrialCoef: 10,
|
||||
@@ -278,6 +446,18 @@ describe('voteReward command', () => {
|
||||
auction: {
|
||||
findMany: async () => [{ targetCode: 'che_무기_12_칠성검' }],
|
||||
},
|
||||
$queryRaw: async (query: { strings: readonly string[] }) =>
|
||||
query.strings.join(' ').includes('SELECT options')
|
||||
? [
|
||||
{
|
||||
options: ['찬성'],
|
||||
multipleOptions: 1,
|
||||
endAt: null,
|
||||
endTick: 0n,
|
||||
closedAt: null,
|
||||
},
|
||||
]
|
||||
: [{ id: 12 }],
|
||||
};
|
||||
|
||||
const result = await handler.handle(
|
||||
@@ -285,8 +465,7 @@ describe('voteReward command', () => {
|
||||
type: 'voteReward',
|
||||
voteId: 1,
|
||||
generalId: 1,
|
||||
goldReward: 500,
|
||||
unique: { expected: false, itemKey: null },
|
||||
selection: [0],
|
||||
},
|
||||
{ db: commandDb as any }
|
||||
);
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { City, Nation } from '@sammo-ts/logic';
|
||||
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import { createDynastyStatisticsHandler, queueYearbookSnapshot } from '../src/turn/yearbookHandler.js';
|
||||
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
|
||||
const turnTime = new Date('0200-01-01T00:00:00.000Z');
|
||||
|
||||
const buildGeneral = (id: number, nationId: number): TurnGeneral => ({
|
||||
id,
|
||||
name: `장수${id}`,
|
||||
nationId,
|
||||
cityId: nationId,
|
||||
troopId: 0,
|
||||
stats: { leadership: 80, strength: 70, intelligence: 60 },
|
||||
experience: 1_000,
|
||||
dedication: 900,
|
||||
officerLevel: 1,
|
||||
role: {
|
||||
personality: null,
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
items: { horse: null, weapon: null, book: null, item: null },
|
||||
},
|
||||
injury: 0,
|
||||
gold: 2_000,
|
||||
rice: 2_000,
|
||||
crew: 0,
|
||||
crewTypeId: 0,
|
||||
train: 0,
|
||||
atmos: 0,
|
||||
age: 30,
|
||||
npcState: nationId === 0 ? 2 : 0,
|
||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
meta: { killturn: 24 },
|
||||
turnTime,
|
||||
});
|
||||
|
||||
const buildCity = (id: number, nationId: number): City => ({
|
||||
id,
|
||||
name: `도시${id}`,
|
||||
nationId,
|
||||
level: 1,
|
||||
state: 0,
|
||||
population: 10_000,
|
||||
populationMax: 20_000,
|
||||
agriculture: 1_000,
|
||||
agricultureMax: 2_000,
|
||||
commerce: 1_000,
|
||||
commerceMax: 2_000,
|
||||
security: 1_000,
|
||||
securityMax: 2_000,
|
||||
supplyState: 1,
|
||||
frontState: 0,
|
||||
defence: 1_000,
|
||||
defenceMax: 2_000,
|
||||
wall: 1_000,
|
||||
wallMax: 2_000,
|
||||
meta: {},
|
||||
});
|
||||
|
||||
const buildNation = (id: number, power: number, meta: Nation['meta']): Nation => ({
|
||||
id,
|
||||
name: id === 0 ? '재야' : `국가${id}`,
|
||||
color: '#777777',
|
||||
capitalCityId: id === 0 ? null : id,
|
||||
chiefGeneralId: null,
|
||||
gold: 10_000,
|
||||
rice: 20_000,
|
||||
power,
|
||||
level: id === 0 ? 0 : 1,
|
||||
typeCode: 'che_중립',
|
||||
meta,
|
||||
});
|
||||
|
||||
type YearbookNationProjection = {
|
||||
id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
level: number;
|
||||
power: number;
|
||||
generalCount: number;
|
||||
};
|
||||
|
||||
describe('yearbook nation projection', () => {
|
||||
it('archives stored nation power/count, preserves zero, and fixes the synthetic neutral values', async () => {
|
||||
const state: TurnWorldState = {
|
||||
id: 1,
|
||||
currentYear: 200,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
lastTurnTime: turnTime,
|
||||
meta: { serverId: 'yearbook-projection-test' },
|
||||
};
|
||||
const snapshot: TurnWorldSnapshot = {
|
||||
scenarioConfig: {
|
||||
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
|
||||
iconPath: '',
|
||||
map: {},
|
||||
const: {},
|
||||
environment: { mapName: 'test', unitSet: 'test' },
|
||||
},
|
||||
scenarioMeta: {
|
||||
title: '연감 테스트',
|
||||
startYear: 200,
|
||||
life: null,
|
||||
fiction: 0,
|
||||
history: [],
|
||||
ignoreDefaultEvents: false,
|
||||
},
|
||||
map: { id: 'test', name: 'test', cities: [] },
|
||||
nations: [
|
||||
{
|
||||
...buildNation(0, 90, { gennum: 90, tech: 90 }),
|
||||
name: '오염된 재야',
|
||||
color: '#ffffff',
|
||||
level: 9,
|
||||
},
|
||||
buildNation(1, 777, { gennum: 9, tech: 100 }),
|
||||
buildNation(2, 0, { tech: 100 }),
|
||||
],
|
||||
cities: [buildCity(0, 0), buildCity(1, 1), buildCity(2, 2)],
|
||||
generals: [
|
||||
buildGeneral(1, 0),
|
||||
buildGeneral(2, 0),
|
||||
buildGeneral(3, 1),
|
||||
buildGeneral(4, 2),
|
||||
buildGeneral(5, 2),
|
||||
],
|
||||
troops: [],
|
||||
diplomacy: [],
|
||||
events: [],
|
||||
initialEvents: [],
|
||||
};
|
||||
const world = new InMemoryTurnWorld(state, snapshot, {
|
||||
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
|
||||
});
|
||||
|
||||
queueYearbookSnapshot(world, 'che', 200, 1);
|
||||
|
||||
const pending = world.peekDirtyState().pendingYearbookSnapshots[0];
|
||||
if (!pending) {
|
||||
throw new Error('expected a queued yearbook snapshot');
|
||||
}
|
||||
const nations = pending.nations as YearbookNationProjection[];
|
||||
expect(nations.map((nation) => nation.id)).toEqual([1, 0, 2]);
|
||||
expect(nations).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 0,
|
||||
name: '재야',
|
||||
color: '#000000',
|
||||
level: 0,
|
||||
power: 1,
|
||||
generalCount: 1,
|
||||
}),
|
||||
expect.objectContaining({ id: 1, power: 777, generalCount: 9 }),
|
||||
expect.objectContaining({ id: 2, power: 0, generalCount: 2 }),
|
||||
])
|
||||
);
|
||||
|
||||
// The contamination above exists only to exercise the archive
|
||||
// projection. Runtime nation zero is normally level zero.
|
||||
world.updateNation(0, { level: 0 });
|
||||
|
||||
const dynastyHandler = createDynastyStatisticsHandler({ getWorld: () => world }).handler;
|
||||
await dynastyHandler.onMonthChanged?.({
|
||||
previousYear: 200,
|
||||
previousMonth: 1,
|
||||
currentYear: 200,
|
||||
currentMonth: 2,
|
||||
turnTime,
|
||||
});
|
||||
expect(world.getState().meta.dynastyStatistics).toBeUndefined();
|
||||
|
||||
await dynastyHandler.onMonthChanged?.({
|
||||
previousYear: 200,
|
||||
previousMonth: 12,
|
||||
currentYear: 201,
|
||||
currentMonth: 1,
|
||||
turnTime,
|
||||
});
|
||||
expect(world.getState().meta.dynastyStatistics).toMatchObject({
|
||||
maxNationCount: 2,
|
||||
maxGeneralCount: 5,
|
||||
currentGeneralCount: 5,
|
||||
userGeneralCount: 3,
|
||||
npcGeneralCount: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@
|
||||
"sync:image": "node tools/sync-image-repository.mjs",
|
||||
"check:legacy:nation": "node tools/compare-command-constraints.mjs --include '^Nation/' --check && node tools/compare-command-logs.mjs --include '^Nation/' --mode action --check",
|
||||
"check:legacy:general": "node tools/compare-command-constraints.mjs --include '^General/' --check && node tools/compare-command-logs.mjs --include '^General/' --mode action --check && node tools/compare-general-turn-contracts.mjs --check",
|
||||
"check:legacy:scenario": "SAMMO_REQUIRE_REF_SOURCE=1 pnpm --filter @sammo-ts/game-engine test monthlyCatalogCoverage.test.ts scenarioLoader.test.ts scenarioComposition.test.ts",
|
||||
"check:ref-compat-markers": "node tools/check-ref-compat-markers.mjs",
|
||||
"check:architecture": "node tools/check-package-boundaries.mjs",
|
||||
"check:typescript-toolchain": "node tools/check-typescript-toolchain.mjs",
|
||||
|
||||
@@ -54,6 +54,31 @@ export interface RuntimeGameSettingsPatch {
|
||||
autorunUser?: RuntimeAutorunUserSettings | null;
|
||||
}
|
||||
|
||||
export interface TurnDaemonSelectPoolCandidate {
|
||||
uniqueName: string;
|
||||
generalName: string;
|
||||
leadership: number;
|
||||
strength: number;
|
||||
intel: number;
|
||||
specialDomestic: string | null;
|
||||
specialDomesticName: string | null;
|
||||
specialDomesticInfo: string;
|
||||
specialWar: string | null;
|
||||
specialWarName: string | null;
|
||||
specialWarInfo: string;
|
||||
ego: string | null;
|
||||
dex: [number, number, number, number, number];
|
||||
imageServer: 0 | 1;
|
||||
picture: string;
|
||||
}
|
||||
|
||||
export interface TurnDaemonSelectPoolReservation {
|
||||
poolName: string;
|
||||
hasGeneral: boolean;
|
||||
validUntil: string;
|
||||
candidates: TurnDaemonSelectPoolCandidate[];
|
||||
}
|
||||
|
||||
export type TurnDaemonCommand =
|
||||
| {
|
||||
type: 'run';
|
||||
@@ -118,7 +143,13 @@ export type TurnDaemonCommand =
|
||||
};
|
||||
}
|
||||
| { type: 'dropItem'; requestId?: string; generalId: number; itemType: string }
|
||||
| { type: 'auctionFinalize'; requestId?: string; auctionId: number }
|
||||
| {
|
||||
type: 'auctionFinalize';
|
||||
requestId?: string;
|
||||
auctionId: number;
|
||||
expectedCloseAt?: string;
|
||||
expectedCloseTick?: number;
|
||||
}
|
||||
| {
|
||||
type: 'auctionOpen';
|
||||
requestId?: string;
|
||||
@@ -160,6 +191,7 @@ export type TurnDaemonCommand =
|
||||
type: 'tournamentBettingPayout';
|
||||
requestId?: string;
|
||||
bettingId?: number;
|
||||
tournamentType?: number;
|
||||
reason?: string;
|
||||
payouts: Array<{
|
||||
generalId: number;
|
||||
@@ -181,11 +213,8 @@ export type TurnDaemonCommand =
|
||||
requestId?: string;
|
||||
voteId: number;
|
||||
generalId: number;
|
||||
goldReward: number;
|
||||
unique?: {
|
||||
expected: boolean;
|
||||
itemKey?: string | null;
|
||||
};
|
||||
selection: number[];
|
||||
acceptedGameTick?: number;
|
||||
}
|
||||
| {
|
||||
type: 'setNationMeta';
|
||||
@@ -280,6 +309,14 @@ export type TurnDaemonCommand =
|
||||
tokenNonce: number;
|
||||
acceptedGameAt?: string;
|
||||
}
|
||||
| {
|
||||
type: 'selectPoolReserve';
|
||||
requestId?: string;
|
||||
userId: string;
|
||||
seedOwnerIdentity: string | number;
|
||||
acceptedGameAt: string;
|
||||
acceptedGameTick?: number;
|
||||
}
|
||||
| {
|
||||
type: 'selectPoolCreate';
|
||||
requestId?: string;
|
||||
@@ -291,6 +328,8 @@ export type TurnDaemonCommand =
|
||||
ownerPicture?: string;
|
||||
ownerImageServer?: number;
|
||||
ownerIconRevision?: string;
|
||||
acceptedGameAt?: string;
|
||||
acceptedGameTick?: number;
|
||||
}
|
||||
| {
|
||||
type: 'selectPoolReselect';
|
||||
@@ -298,6 +337,8 @@ export type TurnDaemonCommand =
|
||||
userId: string;
|
||||
ownerDisplayName: string;
|
||||
uniqueName: string;
|
||||
acceptedGameAt?: string;
|
||||
acceptedGameTick?: number;
|
||||
}
|
||||
| {
|
||||
type: 'auctionBid';
|
||||
@@ -305,6 +346,7 @@ export type TurnDaemonCommand =
|
||||
auctionId: number;
|
||||
generalId: number;
|
||||
amount: number;
|
||||
acceptedGameTick?: number;
|
||||
tryExtendCloseDate?: boolean;
|
||||
};
|
||||
|
||||
@@ -628,6 +670,17 @@ export type TurnDaemonCommandResult =
|
||||
code: 'BAD_REQUEST' | 'NOT_FOUND' | 'PRECONDITION_FAILED' | 'CONFLICT' | 'INTERNAL_SERVER_ERROR';
|
||||
reason: string;
|
||||
}
|
||||
| {
|
||||
type: 'selectPoolReserve';
|
||||
ok: true;
|
||||
reservation: TurnDaemonSelectPoolReservation;
|
||||
}
|
||||
| {
|
||||
type: 'selectPoolReserve';
|
||||
ok: false;
|
||||
code: 'BAD_REQUEST' | 'PRECONDITION_FAILED' | 'CONFLICT' | 'INTERNAL_SERVER_ERROR';
|
||||
reason: string;
|
||||
}
|
||||
| {
|
||||
type: 'selectPoolCreate';
|
||||
ok: true;
|
||||
|
||||
@@ -147,6 +147,16 @@ export interface TurnEngineEventRow {
|
||||
meta: JsonValue;
|
||||
}
|
||||
|
||||
export interface TurnEngineSelectPoolEntryRow {
|
||||
id: number;
|
||||
uniqueName: string;
|
||||
ownerUserId: string | null;
|
||||
generalId: number | null;
|
||||
reservedUntil: Date | null;
|
||||
reservedUntilTick: bigint | null;
|
||||
info: JsonValue;
|
||||
}
|
||||
|
||||
export interface TurnEngineGeneralTurnRow {
|
||||
generalId: number;
|
||||
turnIdx: number;
|
||||
@@ -460,6 +470,9 @@ export interface TurnEngineDatabaseClient {
|
||||
createMany(args: { data: TurnEngineEventCreateManyInput[] }): Promise<unknown>;
|
||||
deleteMany(args?: unknown): Promise<unknown>;
|
||||
};
|
||||
selectPoolEntry: {
|
||||
findMany(args?: unknown): Promise<TurnEngineSelectPoolEntryRow[]>;
|
||||
};
|
||||
logEntry: {
|
||||
createMany(args: { data: TurnEngineLogEntryCreateManyInput[] }): Promise<unknown>;
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ export * from './definition.js';
|
||||
export * from './engine.js';
|
||||
export * from './turn/commandEnv.js';
|
||||
export * from './turn/actionContext.js';
|
||||
export * from './turn/generalPool.js';
|
||||
export * from './turn/commandModule.js';
|
||||
export * from './turn/commandProfile.js';
|
||||
export * from './turn/general/index.js';
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ScenarioConfig } from '@sammo-ts/logic/scenario/types.js';
|
||||
import type { ScenarioMeta } from '@sammo-ts/logic/world/types.js';
|
||||
import type { MapDefinition, UnitSetDefinition } from '@sammo-ts/logic/world/types.js';
|
||||
import type { GeneralWorldView } from '@sammo-ts/logic/triggers/general.js';
|
||||
import type { ScenarioGeneralPoolCandidate } from '@sammo-ts/logic/actions/turn/generalPool.js';
|
||||
|
||||
export interface ActionRandomSource {
|
||||
nextFloat1(): number;
|
||||
@@ -27,6 +28,7 @@ export type ActionContextBase = {
|
||||
month: number;
|
||||
startYear: number;
|
||||
};
|
||||
maxTechLevel?: number;
|
||||
};
|
||||
|
||||
export type ActionResolveContext = ActionContextBase & Record<string, unknown>;
|
||||
@@ -50,6 +52,7 @@ export interface ActionContextWorldRef {
|
||||
toNationId: number;
|
||||
state: number;
|
||||
}>;
|
||||
listGeneralPoolCandidates?(claimedAt: Date): ScenarioGeneralPoolCandidate[] | undefined;
|
||||
getDiplomacyEntry(
|
||||
fromNationId: number,
|
||||
toNationId: number
|
||||
|
||||
@@ -209,6 +209,7 @@ export const buildWarConfig = (scenarioConfig: ScenarioConfig, unitSet: UnitSetD
|
||||
maxAtmosByCommand: resolveNumber(constValues, ['maxAtmosByCommand'], DEFAULT_WAR_CONFIG.maxAtmosByCommand),
|
||||
maxTrainByWar: resolveNumber(constValues, ['maxTrainByWar'], DEFAULT_WAR_CONFIG.maxTrainByWar),
|
||||
maxAtmosByWar: resolveNumber(constValues, ['maxAtmosByWar'], DEFAULT_WAR_CONFIG.maxAtmosByWar),
|
||||
maxTechLevel: resolveNumber(constValues, ['maxTechLevel'], DEFAULT_AFTER_CONFIG.maxTechLevel),
|
||||
maxGeneralStat: resolveNumber(constValues, ['maxLevel'], LEGACY_DEFAULT_MAX_LEVEL),
|
||||
statUpgradeLimit: resolveNumber(constValues, ['upgradeLimit'], 30),
|
||||
castleCrewTypeId,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { JosaUtil } from '@sammo-ts/common';
|
||||
import { getMetaNumber, setMetaNumber, increaseMetaNumber } from '@sammo-ts/logic/war/utils.js';
|
||||
import { z } from 'zod';
|
||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||
import { reconcileCentennialDexConversion } from '@sammo-ts/logic/scenario/centennialAllStar.js';
|
||||
|
||||
export interface DexTransferContext<
|
||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||
@@ -82,11 +83,24 @@ export class ActionDefinition<
|
||||
const srcKey = `dex${args.srcArmType}`;
|
||||
const destKey = `dex${args.destArmType}`;
|
||||
const srcDex = getMetaNumber(general.meta, srcKey, 0);
|
||||
const destDex = getMetaNumber(general.meta, destKey, 0);
|
||||
const cutDex = Math.trunc(srcDex * DECREASE_COEFF);
|
||||
const addDex = Math.trunc(cutDex * CONVERT_COEFF);
|
||||
|
||||
setMetaNumber(general.meta, srcKey, srcDex - cutDex);
|
||||
setMetaNumber(general.meta, destKey, getMetaNumber(general.meta, destKey, 0) + addDex);
|
||||
setMetaNumber(general.meta, destKey, destDex + addDex);
|
||||
if (args.srcArmType <= 5 && args.destArmType <= 5) {
|
||||
general.meta = reconcileCentennialDexConversion(
|
||||
general.meta,
|
||||
srcKey as `dex${1 | 2 | 3 | 4 | 5}`,
|
||||
destKey as `dex${1 | 2 | 3 | 4 | 5}`,
|
||||
srcDex,
|
||||
srcDex - cutDex,
|
||||
destDex,
|
||||
destDex + addDex,
|
||||
CONVERT_COEFF
|
||||
);
|
||||
}
|
||||
|
||||
const srcName = resolveArmTypeName(context.unitSet, args.srcArmType);
|
||||
const destName = resolveArmTypeName(context.unitSet, args.destArmType);
|
||||
|
||||
@@ -20,22 +20,44 @@ import { createGeneralAddEffect } from '@sammo-ts/logic/actions/engine.js';
|
||||
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
|
||||
import { buildRecruitmentGeneral } from './recruitment.js';
|
||||
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
||||
import { buildWorldSummary } from '@sammo-ts/logic/actions/turn/actionContextHelpers.js';
|
||||
import { buildWorldSummary, resolveStartYear } from '@sammo-ts/logic/actions/turn/actionContextHelpers.js';
|
||||
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||
import type { GeneralTurnCommandSpec } from './index.js';
|
||||
import {
|
||||
buildScenarioGeneralPoolClaimMeta,
|
||||
pickUniqueScenarioGeneralPoolCandidates,
|
||||
resolveLegacyNpcStatTypeFromFixedStats,
|
||||
type ScenarioGeneralPoolCandidate,
|
||||
} from '@sammo-ts/logic/actions/turn/generalPool.js';
|
||||
import {
|
||||
CENTENNIAL_ALL_STAR_NPC_PROGRESS_MULTIPLIER,
|
||||
applyCentennialAllStarTarget,
|
||||
initializeCentennialGeneratedNpc,
|
||||
readCentennialAllStarPoolTarget,
|
||||
resolveCentennialAllStarRules,
|
||||
resolveCentennialNpcDexTargetRatio,
|
||||
type CentennialAllStarRules,
|
||||
} from '@sammo-ts/logic/scenario/centennialAllStar.js';
|
||||
|
||||
export interface TalentScoutArgs {}
|
||||
|
||||
export interface TalentScoutCandidate {
|
||||
name: string;
|
||||
poolEntryId?: number;
|
||||
uniqueName?: string;
|
||||
stats?: Partial<StatBlock>;
|
||||
dex?: [number, number, number, number, number];
|
||||
personality?: string | null;
|
||||
affinity?: number | null;
|
||||
specialDomestic?: string | null;
|
||||
specialWar?: string | null;
|
||||
picture?: number | string | null;
|
||||
imageServer?: number;
|
||||
text?: string | null;
|
||||
experience?: number;
|
||||
dedication?: number;
|
||||
sourceInfo?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TalentScoutWorldSummary {
|
||||
@@ -50,9 +72,12 @@ export interface TalentScoutResolveContext<
|
||||
> extends GeneralActionResolveContext<TriggerState> {
|
||||
currentYear: number;
|
||||
currentMonth: number;
|
||||
startYear: number;
|
||||
retirementYear: number;
|
||||
centennialRules: CentennialAllStarRules;
|
||||
centennialNpcDexTargetRatio: number;
|
||||
worldSummary: TalentScoutWorldSummary;
|
||||
generalPool?: TalentScoutCandidate[];
|
||||
generalPool?: ScenarioGeneralPoolCandidate[];
|
||||
cityPool?: City[];
|
||||
existingGeneralNames: string[];
|
||||
createGeneralId: () => number;
|
||||
@@ -228,11 +253,10 @@ const resolveCandidate = (
|
||||
return env.pickCandidate(context, rng);
|
||||
}
|
||||
const pool = context.generalPool ?? [];
|
||||
if (pool.length === 0) {
|
||||
if (context.generalPool === undefined) {
|
||||
return null;
|
||||
}
|
||||
const idx = legacyChoiceIndex(rng, pool.length);
|
||||
return pool[idx] ?? null;
|
||||
return pickUniqueScenarioGeneralPoolCandidates(rng, pool, 1)[0] ?? null;
|
||||
};
|
||||
|
||||
const resolveSpawnCityId = (
|
||||
@@ -371,10 +395,19 @@ export class ActionResolver<
|
||||
this.env.maxDeathYears ?? DEFAULT_DEATH_MAX
|
||||
);
|
||||
const candidate = resolveCandidate(context, context.rng, this.env);
|
||||
const centennialTarget =
|
||||
candidate?.sourceInfo && candidate.uniqueName
|
||||
? readCentennialAllStarPoolTarget({
|
||||
uniqueName: candidate.uniqueName,
|
||||
name: candidate.name,
|
||||
sourceInfo: candidate.sourceInfo,
|
||||
})
|
||||
: null;
|
||||
const firstNames = this.env.randomGeneralFirstNames ?? ['가'];
|
||||
const middleNames = this.env.randomGeneralMiddleNames ?? [''];
|
||||
const lastNames = this.env.randomGeneralLastNames ?? ['가'];
|
||||
let generatedName: string;
|
||||
let generatedName: string | null = null;
|
||||
if (!candidate) {
|
||||
let duplicateLoopCount = 0;
|
||||
while (true) {
|
||||
generatedName = `${legacyChoice(context.rng, firstNames)}${legacyChoice(
|
||||
@@ -391,31 +424,37 @@ export class ActionResolver<
|
||||
}
|
||||
duplicateLoopCount += 1;
|
||||
}
|
||||
}
|
||||
const newGeneralId = context.createGeneralId();
|
||||
const resolvedCandidate: TalentScoutCandidate = candidate ?? { name: generatedName };
|
||||
const resolvedCandidate: TalentScoutCandidate = candidate ?? { name: generatedName! };
|
||||
const affinity = randomRangeInt(context.rng, 1, 150);
|
||||
const npcStatTotal = this.env.npcStatTotal ?? 150;
|
||||
const npcStatMin = this.env.npcStatMin ?? 10;
|
||||
const npcStatMax = this.env.npcStatMax ?? 50;
|
||||
const pickType = pickByWeight(context.rng, { 무: 6, 지: 6, 무지: 3 });
|
||||
let pickType: '무' | '지' | '무지';
|
||||
let stats: StatBlock;
|
||||
if (candidate?.stats && !centennialTarget) {
|
||||
stats = resolveStats(context, context.rng, this.env, resolvedCandidate);
|
||||
pickType = resolveLegacyNpcStatTypeFromFixedStats(context.rng, stats);
|
||||
} else {
|
||||
pickType = pickByWeight(context.rng, { 무: 6, 지: 6, 무지: 3 });
|
||||
const mainStat = npcStatMax - randomRangeInt(context.rng, 0, npcStatMin);
|
||||
const otherStat = npcStatMin + randomRangeInt(context.rng, 0, Math.trunc(npcStatMin / 2));
|
||||
const subStat = npcStatTotal - mainStat - otherStat;
|
||||
let generatedStats: StatBlock;
|
||||
if (pickType === '무') {
|
||||
generatedStats = { leadership: subStat, strength: mainStat, intelligence: otherStat };
|
||||
stats = { leadership: subStat, strength: mainStat, intelligence: otherStat };
|
||||
} else if (pickType === '지') {
|
||||
generatedStats = { leadership: subStat, strength: otherStat, intelligence: mainStat };
|
||||
stats = { leadership: subStat, strength: otherStat, intelligence: mainStat };
|
||||
} else {
|
||||
generatedStats = { leadership: otherStat, strength: subStat, intelligence: mainStat };
|
||||
stats = { leadership: otherStat, strength: subStat, intelligence: mainStat };
|
||||
}
|
||||
}
|
||||
const stats = candidate?.stats
|
||||
? resolveStats(context, context.rng, this.env, resolvedCandidate)
|
||||
: generatedStats;
|
||||
const averageDex = context.worldSummary.averageDex ?? [0, 0, 0, 0, 0];
|
||||
const dexTotal = averageDex[0] + averageDex[1] + averageDex[2] + averageDex[3];
|
||||
let dex: [number, number, number, number, number];
|
||||
if (pickType === '무') {
|
||||
if (candidate?.dex?.[0] && !centennialTarget) {
|
||||
dex = candidate.dex;
|
||||
} else if (pickType === '무') {
|
||||
const distributions = [
|
||||
[(dexTotal * 5) / 8, dexTotal / 8, dexTotal / 8, dexTotal / 8],
|
||||
[dexTotal / 8, (dexTotal * 5) / 8, dexTotal / 8, dexTotal / 8],
|
||||
@@ -469,11 +508,14 @@ export class ActionResolver<
|
||||
dex5: dex[4],
|
||||
turnSecond,
|
||||
turnFraction,
|
||||
...(candidate && candidate.poolEntryId !== undefined && candidate.uniqueName
|
||||
? buildScenarioGeneralPoolClaimMeta(candidate as ScenarioGeneralPoolCandidate, context.turnTimeBase)
|
||||
: {}),
|
||||
};
|
||||
addMetaValue(meta, 'picture', resolvedCandidate.picture ?? null);
|
||||
addMetaValue(meta, 'text', resolvedCandidate.text ?? null);
|
||||
|
||||
const newGeneral = {
|
||||
let newGeneral = {
|
||||
...buildRecruitmentGeneral<TriggerState>({
|
||||
id: newGeneralId,
|
||||
name,
|
||||
@@ -485,8 +527,8 @@ export class ActionResolver<
|
||||
npcState: NPC_TYPE,
|
||||
gold: this.env.defaultNpcGold,
|
||||
rice: this.env.defaultNpcRice,
|
||||
experience: age * 100,
|
||||
dedication: age * 100,
|
||||
experience: resolvedCandidate.experience || age * 100,
|
||||
dedication: resolvedCandidate.dedication || age * 100,
|
||||
crewTypeId: this.env.defaultCrewTypeId,
|
||||
role: {
|
||||
personality,
|
||||
@@ -500,7 +542,25 @@ export class ActionResolver<
|
||||
bornYear: birthYear,
|
||||
deadYear: deathYear,
|
||||
affinity,
|
||||
imageServer: resolvedCandidate.imageServer ?? 0,
|
||||
picture: resolvedCandidate.picture ?? 'default.jpg',
|
||||
};
|
||||
if (centennialTarget) {
|
||||
const initialized = initializeCentennialGeneratedNpc(newGeneral, centennialTarget, context.centennialRules);
|
||||
const growth = applyCentennialAllStarTarget(
|
||||
{ ...newGeneral, ...initialized },
|
||||
centennialTarget,
|
||||
{
|
||||
startYear: context.startYear,
|
||||
year: context.currentYear,
|
||||
month: context.currentMonth,
|
||||
},
|
||||
context.centennialRules,
|
||||
CENTENNIAL_ALL_STAR_NPC_PROGRESS_MULTIPLIER,
|
||||
context.centennialNpcDexTargetRatio
|
||||
);
|
||||
newGeneral = { ...newGeneral, stats: growth.stats, role: growth.role, meta: growth.meta };
|
||||
}
|
||||
|
||||
const recruitVerb = '발견';
|
||||
const nameRa = JosaUtil.pick(name, '라');
|
||||
@@ -578,10 +638,13 @@ export const actionContextBuilder: ActionContextBuilder = (base, options) => ({
|
||||
...base,
|
||||
currentYear: options.world.currentYear,
|
||||
currentMonth: options.world.currentMonth,
|
||||
startYear: resolveStartYear(options.world, options.scenarioMeta),
|
||||
retirementYear:
|
||||
typeof options.scenarioConfig.const.retirementYear === 'number'
|
||||
? options.scenarioConfig.const.retirementYear
|
||||
: 80,
|
||||
centennialRules: resolveCentennialAllStarRules(options.scenarioConfig),
|
||||
centennialNpcDexTargetRatio: resolveCentennialNpcDexTargetRatio(options.scenarioConfig),
|
||||
worldSummary: {
|
||||
...buildWorldSummary(options.worldRef),
|
||||
averageDex: (() => {
|
||||
@@ -605,6 +668,11 @@ export const actionContextBuilder: ActionContextBuilder = (base, options) => ({
|
||||
// AbsGeneralPool::checkDuplicatedCnt semantics; the duplicated ⓝ prefix in
|
||||
// GeneralBuilder::$prefixList intentionally counts NPC matches twice.
|
||||
existingGeneralNames: options.worldRef?.listGenerals().map(restoreLegacyStoredName) ?? [],
|
||||
...(() => {
|
||||
const claimedAt = options.world.lastTurnTime ?? base.general.turnTime;
|
||||
const generalPool = options.worldRef?.listGeneralPoolCandidates?.(claimedAt);
|
||||
return generalPool === undefined ? {} : { generalPool };
|
||||
})(),
|
||||
createGeneralId: options.createGeneralId,
|
||||
turnTermMinutes: Math.max(1, Math.round(options.world.tickSeconds / 60)),
|
||||
// GeneralBuilder::build() derives a new NPC turn from gameStor.turntime,
|
||||
|
||||
@@ -68,7 +68,7 @@ export class ActionDefinition<
|
||||
? (view.get({ kind: 'nation', id: ctx.nationId }) as Nation | null)
|
||||
: null;
|
||||
const crew = typeof general?.crew === 'number' ? general.crew : 0;
|
||||
const techCost = getTechCost(readNationTech(nation));
|
||||
const techCost = getTechCost(readNationTech(nation), this.env.maxTechLevel);
|
||||
return Math.round((crew / 100) * 3 * techCost);
|
||||
}, nationRequirement),
|
||||
reqGeneralRice(() => 0),
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface RecruitEnvironment {
|
||||
defaultAtmos?: number;
|
||||
minAvailableRecruitPop?: number;
|
||||
defaultTrust?: number;
|
||||
maxTechLevel?: number;
|
||||
actionName?: '징병' | '모병';
|
||||
}
|
||||
|
||||
@@ -191,6 +192,12 @@ type RecruitCalcContext<TriggerState extends GeneralTriggerState = GeneralTrigge
|
||||
general: General<TriggerState>;
|
||||
city?: City;
|
||||
nation?: Nation | null;
|
||||
time?: {
|
||||
year: number;
|
||||
month: number;
|
||||
startYear: number;
|
||||
};
|
||||
maxTechLevel?: number;
|
||||
};
|
||||
|
||||
const buildCalcContext = <TriggerState extends GeneralTriggerState>(
|
||||
@@ -216,6 +223,25 @@ const buildCalcContext = <TriggerState extends GeneralTriggerState>(
|
||||
if (nation !== undefined) {
|
||||
result.nation = nation;
|
||||
}
|
||||
const year =
|
||||
typeof ctx.env.currentYear === 'number'
|
||||
? ctx.env.currentYear
|
||||
: typeof ctx.env.year === 'number'
|
||||
? ctx.env.year
|
||||
: undefined;
|
||||
const month =
|
||||
typeof ctx.env.currentMonth === 'number'
|
||||
? ctx.env.currentMonth
|
||||
: typeof ctx.env.month === 'number'
|
||||
? ctx.env.month
|
||||
: undefined;
|
||||
const startYear = typeof ctx.env.startYear === 'number' ? ctx.env.startYear : undefined;
|
||||
if (year !== undefined && month !== undefined && startYear !== undefined) {
|
||||
result.time = { year, month, startYear };
|
||||
}
|
||||
if (typeof ctx.env.maxTechLevel === 'number') {
|
||||
result.maxTechLevel = ctx.env.maxTechLevel;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -247,7 +273,10 @@ export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralT
|
||||
context: RecruitCalcContext<TriggerState>,
|
||||
crewType: { armType: number; cost: number; rice: number }
|
||||
): { gold: number; rice: number } {
|
||||
const techCost = getTechCost(readNationTech(context.nation ?? null));
|
||||
const techCost = getTechCost(
|
||||
readNationTech(context.nation ?? null),
|
||||
context.maxTechLevel ?? this.env.maxTechLevel
|
||||
);
|
||||
return {
|
||||
gold: this.pipeline.onCalcDomestic(context, this.actionName, 'cost', crewType.cost * techCost, {
|
||||
armType: crewType.armType,
|
||||
@@ -282,7 +311,9 @@ export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralT
|
||||
const plan = this.resolveCrewPlan(context, crewTypeId, amount);
|
||||
const tech = readNationTech(context.nation ?? null);
|
||||
const costOffset = this.env.costOffset ?? DEFAULT_COST_OFFSET;
|
||||
const baseGold = crewType ? (crewType.cost * getTechCost(tech) * plan.applied) / 100 : 0;
|
||||
const baseGold = crewType
|
||||
? (crewType.cost * getTechCost(tech, context.maxTechLevel ?? this.env.maxTechLevel) * plan.applied) / 100
|
||||
: 0;
|
||||
const adjustedGold = this.pipeline.onCalcDomestic(
|
||||
context,
|
||||
this.actionName,
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { asRecord, type RandomGenerator } from '@sammo-ts/common';
|
||||
|
||||
import type { GeneralMeta, StatBlock } from '@sammo-ts/logic/domain/entities.js';
|
||||
|
||||
export interface ScenarioGeneralPoolCandidate {
|
||||
poolEntryId: number;
|
||||
uniqueName: string;
|
||||
name: string;
|
||||
stats?: StatBlock;
|
||||
dex?: [number, number, number, number, number];
|
||||
personality?: string | null;
|
||||
affinity?: number | null;
|
||||
specialDomestic?: string | null;
|
||||
specialWar?: string | null;
|
||||
imageServer?: number;
|
||||
picture?: number | string | null;
|
||||
text?: string | null;
|
||||
experience?: number;
|
||||
dedication?: number;
|
||||
weight?: number;
|
||||
sourceInfo: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ScenarioGeneralPoolClaim {
|
||||
poolEntryId: number;
|
||||
uniqueName: string;
|
||||
claimedAt: string;
|
||||
}
|
||||
|
||||
const CLAIM_META_KEY = 'scenarioGeneralPoolClaim';
|
||||
|
||||
const readFiniteNumber = (value: unknown): number | null =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
|
||||
const readOptionalString = (value: unknown): string | null | undefined => {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
};
|
||||
|
||||
export const parseScenarioGeneralPoolCandidate = (entry: {
|
||||
id: number;
|
||||
uniqueName: string;
|
||||
info: unknown;
|
||||
}): ScenarioGeneralPoolCandidate => {
|
||||
const info = asRecord(entry.info);
|
||||
const name = typeof info.generalName === 'string' && info.generalName !== '' ? info.generalName : entry.uniqueName;
|
||||
const leadership = readFiniteNumber(info.leadership);
|
||||
const strength = readFiniteNumber(info.strength);
|
||||
const intelligence = readFiniteNumber(info.intel);
|
||||
const rawDex = Array.isArray(info.dex) ? info.dex.map(readFiniteNumber) : [];
|
||||
const dex =
|
||||
rawDex.length === 5 && rawDex.every((value): value is number => value !== null)
|
||||
? (rawDex as [number, number, number, number, number])
|
||||
: undefined;
|
||||
const experience = readFiniteNumber(info.experience);
|
||||
const dedication = readFiniteNumber(info.dedication);
|
||||
const weight = readFiniteNumber(info.weight);
|
||||
const imageServer = readFiniteNumber(info.imgsvr);
|
||||
const specialDomestic = readOptionalString(info.specialDomestic);
|
||||
const specialWar = readOptionalString(info.specialWar);
|
||||
|
||||
return {
|
||||
poolEntryId: entry.id,
|
||||
uniqueName: entry.uniqueName,
|
||||
name,
|
||||
sourceInfo: structuredClone(info),
|
||||
...(leadership !== null && strength !== null && intelligence !== null
|
||||
? {
|
||||
stats: {
|
||||
leadership,
|
||||
strength,
|
||||
intelligence,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(dex ? { dex } : {}),
|
||||
...(specialDomestic !== undefined ? { specialDomestic } : {}),
|
||||
...(specialWar !== undefined ? { specialWar } : {}),
|
||||
...(imageServer !== null ? { imageServer } : {}),
|
||||
...(info.picture === null || typeof info.picture === 'string' || typeof info.picture === 'number'
|
||||
? { picture: info.picture }
|
||||
: {}),
|
||||
...(experience !== null ? { experience } : {}),
|
||||
...(dedication !== null ? { dedication } : {}),
|
||||
...(weight !== null ? { weight } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
export const buildScenarioGeneralPoolClaimMeta = (
|
||||
candidate: ScenarioGeneralPoolCandidate,
|
||||
claimedAt: Date
|
||||
): Pick<GeneralMeta, typeof CLAIM_META_KEY> => ({
|
||||
[CLAIM_META_KEY]: {
|
||||
poolEntryId: candidate.poolEntryId,
|
||||
uniqueName: candidate.uniqueName,
|
||||
claimedAt: claimedAt.toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
export const readScenarioGeneralPoolClaim = (meta: Record<string, unknown>): ScenarioGeneralPoolClaim | null => {
|
||||
const raw = asRecord(meta[CLAIM_META_KEY]);
|
||||
const poolEntryId = readFiniteNumber(raw.poolEntryId);
|
||||
if (
|
||||
poolEntryId === null ||
|
||||
!Number.isSafeInteger(poolEntryId) ||
|
||||
poolEntryId <= 0 ||
|
||||
typeof raw.uniqueName !== 'string' ||
|
||||
raw.uniqueName === '' ||
|
||||
typeof raw.claimedAt !== 'string' ||
|
||||
Number.isNaN(new Date(raw.claimedAt).getTime())
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
poolEntryId,
|
||||
uniqueName: raw.uniqueName,
|
||||
claimedAt: raw.claimedAt,
|
||||
};
|
||||
};
|
||||
|
||||
export const getScenarioGeneralPoolCandidateWeight = (candidate: ScenarioGeneralPoolCandidate): number => {
|
||||
const weight = candidate.weight ?? candidate.dex?.reduce((sum, value) => sum + value, 0) ?? 0;
|
||||
// SPoolUnderU100 gives NPC/system draws (owner <= 0) a minimum weight so
|
||||
// zero-dex growth candidates remain selectable. User selection calculates
|
||||
// its distinct owner-aware weight in selectPoolService.
|
||||
return candidate.sourceInfo.event100Growth === true ? Math.max(100_000, weight) : weight;
|
||||
};
|
||||
|
||||
const pickUsingWeightPair = <T>(rng: RandomGenerator, values: Array<[T, number]>): T => {
|
||||
let total = 0;
|
||||
for (const [, weight] of values) {
|
||||
if (weight > 0) {
|
||||
total += weight;
|
||||
}
|
||||
}
|
||||
let cursor = rng.nextFloat1() * total;
|
||||
for (const [value, weight] of values) {
|
||||
if (weight <= 0) {
|
||||
if (cursor <= 0) {
|
||||
return value;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (cursor <= weight) {
|
||||
return value;
|
||||
}
|
||||
cursor -= weight;
|
||||
}
|
||||
throw new Error('Unreachable weighted general-pool selection.');
|
||||
};
|
||||
|
||||
/**
|
||||
* Ref keeps the original weighted array while retrying duplicate pool IDs.
|
||||
* A duplicate draw therefore consumes RNG instead of shrinking the weights.
|
||||
*/
|
||||
export const pickUniqueScenarioGeneralPoolCandidates = (
|
||||
rng: RandomGenerator,
|
||||
candidates: readonly ScenarioGeneralPoolCandidate[],
|
||||
count: number
|
||||
): ScenarioGeneralPoolCandidate[] => {
|
||||
if (count <= 0) {
|
||||
return [];
|
||||
}
|
||||
if (candidates.length < count) {
|
||||
throw new Error('pool 부족');
|
||||
}
|
||||
const weighted = candidates.map(
|
||||
(candidate) =>
|
||||
[candidate, getScenarioGeneralPoolCandidateWeight(candidate)] as [ScenarioGeneralPoolCandidate, number]
|
||||
);
|
||||
const selectedIds = new Set<number>();
|
||||
const selected: ScenarioGeneralPoolCandidate[] = [];
|
||||
while (selected.length < count) {
|
||||
const candidate = pickUsingWeightPair(rng, weighted);
|
||||
if (selectedIds.has(candidate.poolEntryId)) {
|
||||
continue;
|
||||
}
|
||||
selectedIds.add(candidate.poolEntryId);
|
||||
selected.push(candidate);
|
||||
}
|
||||
return selected;
|
||||
};
|
||||
|
||||
export type LegacyNpcStatType = '무' | '지' | '무지';
|
||||
|
||||
export const resolveLegacyNpcStatTypeFromFixedStats = (rng: RandomGenerator, stats: StatBlock): LegacyNpcStatType => {
|
||||
if (stats.leadership < 40) {
|
||||
return '무지';
|
||||
}
|
||||
if (stats.intelligence * 0.8 > stats.strength) {
|
||||
return '지';
|
||||
}
|
||||
if (stats.strength * 0.8 > stats.intelligence) {
|
||||
return '무';
|
||||
}
|
||||
return pickUsingWeightPair(rng, [
|
||||
['무', stats.strength],
|
||||
['지', stats.intelligence],
|
||||
]);
|
||||
};
|
||||
@@ -33,18 +33,38 @@ import {
|
||||
} from '@sammo-ts/logic/actions/turn/actionContextHelpers.js';
|
||||
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
|
||||
import type { NationTurnCommandSpec } from './index.js';
|
||||
import {
|
||||
buildScenarioGeneralPoolClaimMeta,
|
||||
pickUniqueScenarioGeneralPoolCandidates,
|
||||
resolveLegacyNpcStatTypeFromFixedStats,
|
||||
type ScenarioGeneralPoolCandidate,
|
||||
} from '@sammo-ts/logic/actions/turn/generalPool.js';
|
||||
import {
|
||||
CENTENNIAL_ALL_STAR_NPC_PROGRESS_MULTIPLIER,
|
||||
applyCentennialAllStarTarget,
|
||||
initializeCentennialGeneratedNpc,
|
||||
readCentennialAllStarPoolTarget,
|
||||
resolveCentennialAllStarRules,
|
||||
resolveCentennialNpcDexTargetRatio,
|
||||
type CentennialAllStarRules,
|
||||
} from '@sammo-ts/logic/scenario/centennialAllStar.js';
|
||||
|
||||
export interface VolunteerRecruitArgs {}
|
||||
|
||||
export interface VolunteerRecruitCandidate {
|
||||
name: string;
|
||||
poolEntryId?: number;
|
||||
uniqueName?: string;
|
||||
stats?: Partial<StatBlock>;
|
||||
dex?: [number, number, number, number, number];
|
||||
personality?: string | null;
|
||||
affinity?: number | null;
|
||||
specialDomestic?: string | null;
|
||||
specialWar?: string | null;
|
||||
picture?: number | string | null;
|
||||
imageServer?: number;
|
||||
text?: string | null;
|
||||
sourceInfo?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface VolunteerRecruitResolveContext<
|
||||
@@ -53,13 +73,16 @@ export interface VolunteerRecruitResolveContext<
|
||||
currentYear: number;
|
||||
currentMonth: number;
|
||||
startYear: number;
|
||||
centennialRules: CentennialAllStarRules;
|
||||
centennialNpcDexTargetRatio: number;
|
||||
averageNationGeneralCount: number;
|
||||
nationAverageStats?: StatBlock;
|
||||
nationAverageExperience?: number;
|
||||
nationAverageDedication?: number;
|
||||
nationAverageDex?: [number, number, number, number, number];
|
||||
friendlyGenerals: Array<General<TriggerState>>;
|
||||
generalPool?: VolunteerRecruitCandidate[];
|
||||
generalPool?: ScenarioGeneralPoolCandidate[];
|
||||
existingGeneralNames?: string[];
|
||||
createGeneralId: () => number;
|
||||
turnTermSeconds: number;
|
||||
turnTimeBase: Date;
|
||||
@@ -112,6 +135,18 @@ const DEFAULT_SPEC_AGE = 19;
|
||||
const DEFAULT_NPC_STAT_TOTAL = 150;
|
||||
const DEFAULT_NPC_STAT_MIN = 10;
|
||||
const DEFAULT_NPC_STAT_MAX = 75;
|
||||
const NPC_NAME_PREFIXES = ['', 'ⓝ', 'ⓝ', 'ⓜ', 'ⓖ', '㉥', 'ⓤ', 'ⓞ'] as const;
|
||||
const NPC_STATE_NAME_PREFIXES: Readonly<Record<number, string>> = {
|
||||
0: '',
|
||||
1: 'ⓝ',
|
||||
2: 'ⓝ',
|
||||
3: 'ⓜ',
|
||||
4: 'ⓖ',
|
||||
5: '㉥',
|
||||
6: 'ⓤ',
|
||||
9: 'ⓞ',
|
||||
};
|
||||
const STORED_NAME_PREFIXES = new Set(Object.values(NPC_STATE_NAME_PREFIXES).filter(Boolean));
|
||||
|
||||
const addMetaValue = (
|
||||
meta: Record<string, TriggerValue>,
|
||||
@@ -146,6 +181,43 @@ const legacyChoiceIndex = (rng: RandomGenerator, length: number): number => {
|
||||
const legacyChoice = <T>(rng: RandomGenerator, values: readonly T[]): T =>
|
||||
values[legacyChoiceIndex(rng, values.length)]!;
|
||||
|
||||
const restoreLegacyStoredName = (general: Pick<General, 'name' | 'npcState'>): string => {
|
||||
if (STORED_NAME_PREFIXES.has(general.name[0] ?? '')) {
|
||||
return general.name;
|
||||
}
|
||||
return `${NPC_STATE_NAME_PREFIXES[general.npcState] ?? ''}${general.name}`;
|
||||
};
|
||||
|
||||
const countLegacyNameDuplicates = (names: readonly string[], candidate: string): number =>
|
||||
NPC_NAME_PREFIXES.reduce(
|
||||
(total, prefix) => total + names.filter((name) => name.startsWith(`${prefix}${candidate}`)).length,
|
||||
0
|
||||
);
|
||||
|
||||
const pickLegacyRandomNames = (
|
||||
rng: RandomGenerator,
|
||||
count: number,
|
||||
existingNames: readonly string[],
|
||||
firstNames: readonly string[],
|
||||
middleNames: readonly string[],
|
||||
lastNames: readonly string[]
|
||||
): string[] =>
|
||||
Array.from({ length: count }, () => {
|
||||
let loopCount = 0;
|
||||
while (true) {
|
||||
let name = `${legacyChoice(rng, firstNames)}${legacyChoice(rng, middleNames)}${legacyChoice(rng, lastNames)}`;
|
||||
const duplicateCount = countLegacyNameDuplicates(existingNames, name);
|
||||
if (duplicateCount === 0) {
|
||||
return name;
|
||||
}
|
||||
if (loopCount >= 99 || duplicateCount < 2) {
|
||||
name += duplicateCount + 1;
|
||||
return name;
|
||||
}
|
||||
loopCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
const pickByWeight = <T extends string>(rng: RandomGenerator, weights: Record<T, number>): T => {
|
||||
const entries = Object.entries(weights) as Array<[T, number]>;
|
||||
const first = entries[0];
|
||||
@@ -208,12 +280,10 @@ const resolveCandidate = (
|
||||
if (env.pickCandidate) {
|
||||
return env.pickCandidate(context, rng);
|
||||
}
|
||||
const pool = context.generalPool ?? [];
|
||||
if (pool.length === 0) {
|
||||
if (context.generalPool === undefined) {
|
||||
return null;
|
||||
}
|
||||
const idx = rng.nextInt(0, pool.length);
|
||||
return pool[idx] ?? null;
|
||||
return pickUniqueScenarioGeneralPoolCandidates(rng, context.generalPool, 1)[0] ?? null;
|
||||
};
|
||||
|
||||
const resolveStats = (
|
||||
@@ -357,44 +427,81 @@ export class ActionResolver<
|
||||
const firstNames = this.env.randomGeneralFirstNames ?? ['가'];
|
||||
const middleNames = this.env.randomGeneralMiddleNames ?? [''];
|
||||
const lastNames = this.env.randomGeneralLastNames ?? ['가'];
|
||||
const candidates = Array.from({ length: createCount }, () => {
|
||||
const selected = resolveCandidate(context, context.rng, this.env);
|
||||
if (selected) {
|
||||
return selected;
|
||||
}
|
||||
return {
|
||||
name: `${legacyChoice(context.rng, firstNames)}${legacyChoice(
|
||||
const candidates: VolunteerRecruitCandidate[] = this.env.pickCandidate
|
||||
? Array.from(
|
||||
{ length: createCount },
|
||||
() =>
|
||||
resolveCandidate(context, context.rng, this.env) ?? {
|
||||
name: pickLegacyRandomNames(
|
||||
context.rng,
|
||||
middleNames
|
||||
)}${legacyChoice(context.rng, lastNames)}`,
|
||||
};
|
||||
});
|
||||
1,
|
||||
context.existingGeneralNames ?? [],
|
||||
firstNames,
|
||||
middleNames,
|
||||
lastNames
|
||||
)[0]!,
|
||||
}
|
||||
)
|
||||
: context.generalPool === undefined
|
||||
? pickLegacyRandomNames(
|
||||
context.rng,
|
||||
createCount,
|
||||
context.existingGeneralNames ?? [],
|
||||
firstNames,
|
||||
middleNames,
|
||||
lastNames
|
||||
).map((name) => ({ name }))
|
||||
: pickUniqueScenarioGeneralPoolCandidates(context.rng, context.generalPool, createCount);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const centennialTarget =
|
||||
candidate.sourceInfo && candidate.uniqueName
|
||||
? readCentennialAllStarPoolTarget({
|
||||
uniqueName: candidate.uniqueName,
|
||||
name: candidate.name,
|
||||
sourceInfo: candidate.sourceInfo,
|
||||
})
|
||||
: null;
|
||||
const newGeneralId = context.createGeneralId();
|
||||
const name = this.env.decorateName ? this.env.decorateName(candidate.name, NPC_TYPE) : `ⓖ${candidate.name}`;
|
||||
const birthYear = context.currentYear - baseAge;
|
||||
const deathYear = context.currentYear + deathYears;
|
||||
const killturn = randomRangeInt(context.rng, killTurnMin, killTurnMax);
|
||||
const affinity = candidate.affinity ?? randomRangeInt(context.rng, 1, 150);
|
||||
let pickType: '무' | '지' | '무지';
|
||||
let stats: StatBlock;
|
||||
if (candidate.stats && !centennialTarget) {
|
||||
stats = resolveStats(context, context.rng, this.env, candidate);
|
||||
pickType = resolveLegacyNpcStatTypeFromFixedStats(context.rng, stats);
|
||||
} else {
|
||||
const generated = buildLegacyRandomStats(context.rng, this.env);
|
||||
const stats = candidate.stats ? resolveStats(context, context.rng, this.env, candidate) : generated.stats;
|
||||
pickType = generated.pickType;
|
||||
stats = generated.stats;
|
||||
}
|
||||
const averageDex = context.nationAverageDex ?? [0, 0, 0, 0, 0];
|
||||
const dexTotal = averageDex[0] + averageDex[1] + averageDex[2] + averageDex[3];
|
||||
let dex: [number, number, number, number, number];
|
||||
if (candidate.dex?.[0] && !centennialTarget) {
|
||||
dex = candidate.dex;
|
||||
} else {
|
||||
const rawDex: [number, number, number, number] =
|
||||
generated.pickType === '무'
|
||||
pickType === '무'
|
||||
? legacyChoice(context.rng, [
|
||||
[(dexTotal * 5) / 8, dexTotal / 8, dexTotal / 8, dexTotal / 8],
|
||||
[dexTotal / 8, (dexTotal * 5) / 8, dexTotal / 8, dexTotal / 8],
|
||||
[dexTotal / 8, dexTotal / 8, (dexTotal * 5) / 8, dexTotal / 8],
|
||||
])
|
||||
: [dexTotal / 8, dexTotal / 8, dexTotal / 8, (dexTotal * 5) / 8];
|
||||
const dex: [number, number, number, number] = [
|
||||
: pickType === '지'
|
||||
? [dexTotal / 8, dexTotal / 8, dexTotal / 8, (dexTotal * 5) / 8]
|
||||
: [dexTotal / 4, dexTotal / 4, dexTotal / 4, dexTotal / 4];
|
||||
dex = [
|
||||
Math.trunc(rawDex[0]),
|
||||
Math.trunc(rawDex[1]),
|
||||
Math.trunc(rawDex[2]),
|
||||
Math.trunc(rawDex[3]),
|
||||
Math.trunc(averageDex[4]),
|
||||
];
|
||||
}
|
||||
const personality =
|
||||
candidate.personality ?? legacyChoice(context.rng, this.env.availablePersonalities ?? ['che_안전']);
|
||||
const turnSecond = randomRangeInt(context.rng, 0, context.turnTermSeconds - 1);
|
||||
@@ -416,9 +523,12 @@ export class ActionResolver<
|
||||
dex2: dex[1],
|
||||
dex3: dex[2],
|
||||
dex4: dex[3],
|
||||
dex5: Math.trunc(averageDex[4]),
|
||||
dex5: dex[4],
|
||||
turnSecond,
|
||||
turnFraction,
|
||||
...(candidate.poolEntryId !== undefined && candidate.uniqueName
|
||||
? buildScenarioGeneralPoolClaimMeta(candidate as ScenarioGeneralPoolCandidate, context.turnTimeBase)
|
||||
: {}),
|
||||
};
|
||||
addMetaValue(meta, 'affinity', affinity);
|
||||
addMetaValue(meta, 'picture', candidate.picture ?? null);
|
||||
@@ -428,7 +538,10 @@ export class ActionResolver<
|
||||
addMetaValue(meta, 'specage2', DEFAULT_SPEC_AGE);
|
||||
addMetaValue(meta, 'text', candidate.text ?? null);
|
||||
|
||||
const newGeneral = {
|
||||
const averageExperience = Math.trunc(context.nationAverageExperience ?? 0);
|
||||
const averageDedication = Math.trunc(context.nationAverageDedication ?? 0);
|
||||
|
||||
let newGeneral = {
|
||||
...buildRecruitmentGeneral<TriggerState>({
|
||||
id: newGeneralId,
|
||||
name,
|
||||
@@ -440,8 +553,10 @@ export class ActionResolver<
|
||||
npcState: NPC_TYPE,
|
||||
gold: this.env.defaultNpcGold,
|
||||
rice: this.env.defaultNpcRice,
|
||||
experience: Math.trunc(context.nationAverageExperience ?? 0),
|
||||
dedication: Math.trunc(context.nationAverageDedication ?? 0),
|
||||
// GeneralBuilder::build() uses PHP's falsy `?: age * 100`
|
||||
// after setExpDed(), including when a nation's averages are 0.
|
||||
experience: averageExperience || baseAge * 100,
|
||||
dedication: averageDedication || baseAge * 100,
|
||||
crewTypeId: this.env.defaultCrewTypeId,
|
||||
role: {
|
||||
personality,
|
||||
@@ -454,7 +569,29 @@ export class ActionResolver<
|
||||
...(turnTick === undefined ? {} : { turnTick }),
|
||||
bornYear: birthYear,
|
||||
deadYear: deathYear,
|
||||
imageServer: candidate.imageServer ?? 0,
|
||||
picture: candidate.picture ?? 'default.jpg',
|
||||
};
|
||||
if (centennialTarget) {
|
||||
const initialized = initializeCentennialGeneratedNpc(
|
||||
newGeneral,
|
||||
centennialTarget,
|
||||
context.centennialRules
|
||||
);
|
||||
const growth = applyCentennialAllStarTarget(
|
||||
{ ...newGeneral, ...initialized },
|
||||
centennialTarget,
|
||||
{
|
||||
startYear: context.startYear,
|
||||
year: context.currentYear,
|
||||
month: context.currentMonth,
|
||||
},
|
||||
context.centennialRules,
|
||||
CENTENNIAL_ALL_STAR_NPC_PROGRESS_MULTIPLIER,
|
||||
context.centennialNpcDexTargetRatio
|
||||
);
|
||||
newGeneral = { ...newGeneral, stats: growth.stats, role: growth.role, meta: growth.meta };
|
||||
}
|
||||
effects.push(createGeneralAddEffect(newGeneral));
|
||||
}
|
||||
|
||||
@@ -521,12 +658,20 @@ export const actionContextBuilder: ActionContextBuilder = (base, options) => {
|
||||
currentYear: options.world.currentYear,
|
||||
currentMonth: options.world.currentMonth,
|
||||
startYear: resolveStartYear(options.world, options.scenarioMeta),
|
||||
centennialRules: resolveCentennialAllStarRules(options.scenarioConfig),
|
||||
centennialNpcDexTargetRatio: resolveCentennialNpcDexTargetRatio(options.scenarioConfig),
|
||||
averageNationGeneralCount: buildAverageNationGeneralCount(options.worldRef),
|
||||
nationAverageStats: nationSummary.averageStats,
|
||||
nationAverageExperience: nationSummary.averageExperience,
|
||||
nationAverageDedication: nationSummary.averageDedication,
|
||||
nationAverageDex: nationSummary.averageDex,
|
||||
friendlyGenerals,
|
||||
existingGeneralNames: options.worldRef?.listGenerals().map(restoreLegacyStoredName) ?? [],
|
||||
...(() => {
|
||||
const claimedAt = options.world.lastTurnTime ?? base.general.turnTime;
|
||||
const generalPool = options.worldRef?.listGeneralPoolCandidates?.(claimedAt);
|
||||
return generalPool === undefined ? {} : { generalPool };
|
||||
})(),
|
||||
createGeneralId: options.createGeneralId,
|
||||
turnTermSeconds: Math.max(1, Math.round(options.world.tickSeconds)),
|
||||
turnTimeBase: options.world.lastTurnTime ?? base.general.turnTime,
|
||||
|
||||
@@ -286,6 +286,7 @@ const resolveCityRiceConsumption = (options: {
|
||||
castleCrewTypeId: number;
|
||||
year: number;
|
||||
startYear: number;
|
||||
maxTechLevel?: number;
|
||||
}): number => {
|
||||
const cityReport = options.battle.reports.find((report: WarUnitReport) => report.type === 'city');
|
||||
if (!cityReport) {
|
||||
@@ -304,7 +305,7 @@ const resolveCityRiceConsumption = (options: {
|
||||
|
||||
let rice = (cityReport.killed / 100) * 0.8;
|
||||
rice *= riceCoef;
|
||||
rice *= getTechCost(tech);
|
||||
rice *= getTechCost(tech, options.maxTechLevel);
|
||||
rice *= trainAtmos / 100 - 0.2;
|
||||
return Math.round(rice);
|
||||
};
|
||||
@@ -454,6 +455,7 @@ export const processBattleSimJob = (
|
||||
castleCrewTypeId: payload.config.castleCrewTypeId,
|
||||
year: payload.time.year,
|
||||
startYear: payload.time.startYear,
|
||||
...(payload.config.maxTechLevel === undefined ? {} : { maxTechLevel: payload.config.maxTechLevel }),
|
||||
});
|
||||
defenderAvgRice += (defenderRiceInit - defenderRiceAfter + cityRice) * weight;
|
||||
|
||||
|
||||
@@ -21,16 +21,16 @@ export const itemModule: ItemModule = {
|
||||
reqSecu: 0,
|
||||
unique: false,
|
||||
onCalcStat: function (
|
||||
_context: GeneralActionContext | WarActionContext,
|
||||
context: GeneralActionContext | WarActionContext,
|
||||
statName: GeneralStatName | WarStatName,
|
||||
value: unknown,
|
||||
aux?: unknown
|
||||
): unknown {
|
||||
if (statName === 'strength') {
|
||||
const auxObj = aux as Record<string, unknown> | undefined;
|
||||
const year = resolveNumber(auxObj?.['year']);
|
||||
const startYear = resolveNumber(auxObj?.['startYear']);
|
||||
const maxTechLevel = resolveNumber(auxObj?.['maxTechLevel'], 12);
|
||||
const year = resolveNumber(context.time?.year, resolveNumber(auxObj?.['year']));
|
||||
const startYear = resolveNumber(context.time?.startYear, resolveNumber(auxObj?.['startYear']));
|
||||
const maxTechLevel = resolveNumber(context.maxTechLevel, resolveNumber(auxObj?.['maxTechLevel'], 12));
|
||||
const relYear = Math.max(0, year - startYear);
|
||||
const bonus = 5 + clamp(Math.floor(relYear / 4), 0, maxTechLevel);
|
||||
|
||||
|
||||
@@ -21,16 +21,16 @@ export const itemModule: ItemModule = {
|
||||
reqSecu: 0,
|
||||
unique: false,
|
||||
onCalcStat: function (
|
||||
_context: GeneralActionContext | WarActionContext,
|
||||
context: GeneralActionContext | WarActionContext,
|
||||
statName: GeneralStatName | WarStatName,
|
||||
value: unknown,
|
||||
aux?: unknown
|
||||
): unknown {
|
||||
if (statName === 'intelligence') {
|
||||
const auxObj = aux as Record<string, unknown> | undefined;
|
||||
const year = resolveNumber(auxObj?.['year']);
|
||||
const startYear = resolveNumber(auxObj?.['startYear']);
|
||||
const maxTechLevel = resolveNumber(auxObj?.['maxTechLevel'], 12);
|
||||
const year = resolveNumber(context.time?.year, resolveNumber(auxObj?.['year']));
|
||||
const startYear = resolveNumber(context.time?.startYear, resolveNumber(auxObj?.['startYear']));
|
||||
const maxTechLevel = resolveNumber(context.maxTechLevel, resolveNumber(auxObj?.['maxTechLevel'], 12));
|
||||
const relYear = Math.max(0, year - startYear);
|
||||
const bonus = 5 + clamp(Math.floor(relYear / 4), 0, maxTechLevel);
|
||||
|
||||
|
||||
@@ -21,16 +21,16 @@ export const itemModule: ItemModule = {
|
||||
reqSecu: 0,
|
||||
unique: false,
|
||||
onCalcStat: function (
|
||||
_context: GeneralActionContext | WarActionContext,
|
||||
context: GeneralActionContext | WarActionContext,
|
||||
statName: GeneralStatName | WarStatName,
|
||||
value: unknown,
|
||||
aux?: unknown
|
||||
): unknown {
|
||||
if (statName === 'leadership') {
|
||||
const auxObj = aux as Record<string, unknown> | undefined;
|
||||
const year = resolveNumber(auxObj?.['year']);
|
||||
const startYear = resolveNumber(auxObj?.['startYear']);
|
||||
const maxTechLevel = resolveNumber(auxObj?.['maxTechLevel'], 12);
|
||||
const year = resolveNumber(context.time?.year, resolveNumber(auxObj?.['year']));
|
||||
const startYear = resolveNumber(context.time?.startYear, resolveNumber(auxObj?.['startYear']));
|
||||
const maxTechLevel = resolveNumber(context.maxTechLevel, resolveNumber(auxObj?.['maxTechLevel'], 12));
|
||||
const relYear = Math.max(0, year - startYear);
|
||||
const bonus = 5 + clamp(Math.floor(relYear / 4), 0, maxTechLevel);
|
||||
|
||||
|
||||
@@ -0,0 +1,672 @@
|
||||
import { asNumber, asRecord } from '@sammo-ts/common';
|
||||
|
||||
import type { General, GeneralMeta, GeneralRole, StatBlock } from '../domain/entities.js';
|
||||
import { LEGACY_DEFAULT_MAX_LEVEL } from './constants.js';
|
||||
|
||||
export const CENTENNIAL_ALL_STAR_POOL = 'SPoolUnderU100';
|
||||
export const CENTENNIAL_ALL_STAR_AUX_KEY = 'event100_allstar';
|
||||
export const CENTENNIAL_ALL_STAR_TRAIT_UNLOCK_PROGRESS = 0.4;
|
||||
export const CENTENNIAL_ALL_STAR_NPC_PROGRESS_MULTIPLIER = 0.9;
|
||||
export const CENTENNIAL_ALL_STAR_DEFAULT_GROWTH_YEARS = 15;
|
||||
export const CENTENNIAL_ALL_STAR_DEFAULT_DEX_LIMIT = 1_000_000;
|
||||
|
||||
export const isCentennialAllStarActive = (scenarioConfig: unknown): boolean =>
|
||||
asRecord(asRecord(scenarioConfig).map).targetGeneralPool === CENTENNIAL_ALL_STAR_POOL;
|
||||
|
||||
export const isCentennialStatResetAllowed = (scenarioConfig: unknown): boolean =>
|
||||
!isCentennialAllStarActive(scenarioConfig);
|
||||
|
||||
const STAT_KEYS = ['leadership', 'strength', 'intel'] as const;
|
||||
const DEX_KEYS = ['dex1', 'dex2', 'dex3', 'dex4', 'dex5'] as const;
|
||||
|
||||
type CentennialStatKey = (typeof STAT_KEYS)[number];
|
||||
type CentennialDexKey = (typeof DEX_KEYS)[number];
|
||||
|
||||
export interface CentennialAllStarTarget {
|
||||
uniqueName: string;
|
||||
generalName?: string;
|
||||
leadership: number;
|
||||
strength: number;
|
||||
intel: number;
|
||||
dex: readonly [number, number, number, number, number];
|
||||
specialDomestic?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CentennialAllStarPoolCandidate {
|
||||
uniqueName: string;
|
||||
name: string;
|
||||
sourceInfo: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CentennialAllStarEnvironment {
|
||||
startYear: number;
|
||||
year: number;
|
||||
month: number;
|
||||
}
|
||||
|
||||
export interface CentennialAllStarRules {
|
||||
defaultStatMin: number;
|
||||
defaultStatMax: number;
|
||||
defaultStatTotal: number;
|
||||
maxStatLevel: number;
|
||||
defaultSpecialDomestic: string | null;
|
||||
dexLimit?: number;
|
||||
}
|
||||
|
||||
export interface CentennialAllStarScenarioConfig {
|
||||
stat: {
|
||||
min: number;
|
||||
max: number;
|
||||
total: number;
|
||||
};
|
||||
const: Record<string, unknown>;
|
||||
map: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CentennialAllStarAux {
|
||||
targetId: string;
|
||||
target: CentennialAllStarTarget;
|
||||
granted: Record<CentennialStatKey | CentennialDexKey, number>;
|
||||
dexConsumed: Record<CentennialDexKey, number>;
|
||||
dexFloor: Record<CentennialDexKey, number>;
|
||||
progressMonth: number;
|
||||
milestone: number;
|
||||
naturalSpecialDomestic: string | null;
|
||||
eventSpecialDomestic: string | null;
|
||||
userInitialStats: Record<CentennialStatKey, number> | null;
|
||||
dexTargetRatio: number;
|
||||
}
|
||||
|
||||
export interface CentennialAllStarApplyResult {
|
||||
stats: StatBlock;
|
||||
role: GeneralRole;
|
||||
meta: GeneralMeta;
|
||||
progress: number;
|
||||
milestone: number;
|
||||
previousMilestone: number;
|
||||
targetChanged: boolean;
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
export const resolveCentennialAllStarRules = (
|
||||
config: CentennialAllStarScenarioConfig,
|
||||
fallbackSpecialDomestic: string | null = null
|
||||
): CentennialAllStarRules => ({
|
||||
defaultStatMin: config.stat.min,
|
||||
defaultStatMax: config.stat.max,
|
||||
defaultStatTotal: config.stat.total,
|
||||
maxStatLevel: asNumber(config.const.maxLevel, LEGACY_DEFAULT_MAX_LEVEL),
|
||||
defaultSpecialDomestic:
|
||||
typeof config.const.defaultSpecialDomestic === 'string'
|
||||
? config.const.defaultSpecialDomestic
|
||||
: fallbackSpecialDomestic,
|
||||
dexLimit: asNumber(config.const.dexLimit, CENTENNIAL_ALL_STAR_DEFAULT_DEX_LIMIT),
|
||||
});
|
||||
|
||||
export const resolveCentennialNpcDexTargetRatio = (config: CentennialAllStarScenarioConfig): number => {
|
||||
const ratio = asNumber(config.map.centennialNpcDexTargetRatio, 0.4);
|
||||
if (ratio < 0 || ratio > 1) {
|
||||
throw new Error('centennialNpcDexTargetRatio must be between 0 and 1');
|
||||
}
|
||||
return ratio;
|
||||
};
|
||||
|
||||
const emptyGranted = (): CentennialAllStarAux['granted'] => ({
|
||||
leadership: 0,
|
||||
strength: 0,
|
||||
intel: 0,
|
||||
dex1: 0,
|
||||
dex2: 0,
|
||||
dex3: 0,
|
||||
dex4: 0,
|
||||
dex5: 0,
|
||||
});
|
||||
|
||||
const emptyDex = (): Record<CentennialDexKey, number> => ({
|
||||
dex1: 0,
|
||||
dex2: 0,
|
||||
dex3: 0,
|
||||
dex4: 0,
|
||||
dex5: 0,
|
||||
});
|
||||
|
||||
const readFiniteNumber = (source: Record<string, unknown>, key: string, fallback = 0): number => {
|
||||
const value = source[key];
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const readIntegerRecord = <Key extends string>(raw: unknown, keys: readonly Key[]): Record<Key, number> => {
|
||||
const source = asRecord(raw);
|
||||
return Object.fromEntries(
|
||||
keys.map((key) => [key, Math.max(0, Math.trunc(readFiniteNumber(source, key)))])
|
||||
) as Record<Key, number>;
|
||||
};
|
||||
|
||||
const normalizeTarget = (raw: unknown, fallback?: CentennialAllStarTarget): CentennialAllStarTarget => {
|
||||
const source = asRecord(raw);
|
||||
const dex = Array.isArray(source.dex) ? source.dex : fallback?.dex;
|
||||
const uniqueName = typeof source.uniqueName === 'string' ? source.uniqueName : fallback?.uniqueName;
|
||||
if (!uniqueName || !dex || dex.length !== DEX_KEYS.length) {
|
||||
if (fallback) {
|
||||
return fallback;
|
||||
}
|
||||
throw new Error('100기 올스타 목표 정보가 올바르지 않습니다.');
|
||||
}
|
||||
const normalizedDex = dex.map((value) =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? Math.trunc(value) : Number.NaN
|
||||
);
|
||||
if (normalizedDex.some((value) => !Number.isInteger(value) || value < 0)) {
|
||||
throw new Error(`100기 올스타 숙련 목표가 올바르지 않습니다: ${uniqueName}`);
|
||||
}
|
||||
const readTargetStat = (key: CentennialStatKey): number => {
|
||||
const value = source[key] ?? fallback?.[key];
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
throw new Error(`100기 올스타 능력 목표가 올바르지 않습니다: ${uniqueName}`);
|
||||
}
|
||||
return Math.trunc(value);
|
||||
};
|
||||
return {
|
||||
...(fallback ?? {}),
|
||||
...source,
|
||||
uniqueName,
|
||||
leadership: readTargetStat('leadership'),
|
||||
strength: readTargetStat('strength'),
|
||||
intel: readTargetStat('intel'),
|
||||
dex: normalizedDex as [number, number, number, number, number],
|
||||
specialDomestic:
|
||||
typeof source.specialDomestic === 'string' || source.specialDomestic === null
|
||||
? source.specialDomestic
|
||||
: (fallback?.specialDomestic ?? null),
|
||||
};
|
||||
};
|
||||
|
||||
export const readCentennialAllStarPoolTarget = (
|
||||
candidate: CentennialAllStarPoolCandidate | null | undefined
|
||||
): CentennialAllStarTarget | null => {
|
||||
if (!candidate || candidate.sourceInfo.event100Growth !== true) {
|
||||
return null;
|
||||
}
|
||||
return normalizeTarget({
|
||||
...candidate.sourceInfo,
|
||||
uniqueName: candidate.uniqueName,
|
||||
generalName: candidate.name,
|
||||
});
|
||||
};
|
||||
|
||||
export const initialCentennialAllStarAux = (
|
||||
target: CentennialAllStarTarget,
|
||||
rules: Pick<CentennialAllStarRules, 'defaultStatMin'>,
|
||||
userInitialStats: Record<CentennialStatKey, number> | null = null
|
||||
): CentennialAllStarAux => {
|
||||
const granted = emptyGranted();
|
||||
if (userInitialStats) {
|
||||
for (const key of STAT_KEYS) {
|
||||
const initial = userInitialStats[key] ?? rules.defaultStatMin;
|
||||
granted[key] = Math.max(0, initial - Math.min(initial, rules.defaultStatMin));
|
||||
}
|
||||
}
|
||||
return {
|
||||
targetId: target.uniqueName,
|
||||
target,
|
||||
granted,
|
||||
dexConsumed: emptyDex(),
|
||||
dexFloor: emptyDex(),
|
||||
progressMonth: -1,
|
||||
milestone: 0,
|
||||
naturalSpecialDomestic: null,
|
||||
eventSpecialDomestic: null,
|
||||
userInitialStats,
|
||||
dexTargetRatio: 1,
|
||||
};
|
||||
};
|
||||
|
||||
export const readCentennialAllStarAux = (
|
||||
meta: Record<string, unknown>,
|
||||
fallbackTarget?: CentennialAllStarTarget
|
||||
): CentennialAllStarAux | null => {
|
||||
const source = asRecord(meta[CENTENNIAL_ALL_STAR_AUX_KEY]);
|
||||
if (Object.keys(source).length === 0 && !fallbackTarget) {
|
||||
return null;
|
||||
}
|
||||
const target = normalizeTarget(source.target, fallbackTarget);
|
||||
const rawInitial = asRecord(source.userInitialStats);
|
||||
const userInitialStats = Object.keys(rawInitial).length
|
||||
? (Object.fromEntries(STAT_KEYS.map((key) => [key, Math.trunc(readFiniteNumber(rawInitial, key))])) as Record<
|
||||
CentennialStatKey,
|
||||
number
|
||||
>)
|
||||
: null;
|
||||
return {
|
||||
targetId: typeof source.targetId === 'string' ? source.targetId : target.uniqueName,
|
||||
target,
|
||||
granted: readIntegerRecord(source.granted, [...STAT_KEYS, ...DEX_KEYS]),
|
||||
dexConsumed: readIntegerRecord(source.dexConsumed, DEX_KEYS),
|
||||
dexFloor: readIntegerRecord(source.dexFloor, DEX_KEYS),
|
||||
progressMonth: Math.trunc(readFiniteNumber(source, 'progressMonth', -1)),
|
||||
milestone: Math.trunc(readFiniteNumber(source, 'milestone')),
|
||||
naturalSpecialDomestic:
|
||||
typeof source.naturalSpecialDomestic === 'string' ? source.naturalSpecialDomestic : null,
|
||||
eventSpecialDomestic: typeof source.eventSpecialDomestic === 'string' ? source.eventSpecialDomestic : null,
|
||||
userInitialStats,
|
||||
dexTargetRatio: readFiniteNumber(source, 'dexTargetRatio', 1),
|
||||
};
|
||||
};
|
||||
|
||||
export const calculateCentennialUserInitialStats = (
|
||||
target: CentennialAllStarTarget,
|
||||
rules: Pick<CentennialAllStarRules, 'defaultStatMin' | 'defaultStatMax' | 'defaultStatTotal'>
|
||||
): Record<CentennialStatKey, number> => {
|
||||
const targets = {} as Record<CentennialStatKey, number>;
|
||||
const bases = {} as Record<CentennialStatKey, number>;
|
||||
for (const key of STAT_KEYS) {
|
||||
const value = Math.min(rules.defaultStatMax, Math.max(0, Math.trunc(target[key])));
|
||||
targets[key] = value;
|
||||
bases[key] = Math.min(value, rules.defaultStatMin);
|
||||
}
|
||||
const targetTotal = STAT_KEYS.reduce((sum, key) => sum + targets[key], 0);
|
||||
const desiredTotal = Math.min(rules.defaultStatTotal, targetTotal);
|
||||
const baseTotal = STAT_KEYS.reduce((sum, key) => sum + bases[key], 0);
|
||||
const capacityTotal = targetTotal - baseTotal;
|
||||
if (capacityTotal <= 0 || desiredTotal <= baseTotal) {
|
||||
return bases;
|
||||
}
|
||||
|
||||
const ratio = (desiredTotal - baseTotal) / capacityTotal;
|
||||
const result = {} as Record<CentennialStatKey, number>;
|
||||
const fractions = STAT_KEYS.map((key, order) => {
|
||||
const raw = bases[key] + (targets[key] - bases[key]) * ratio;
|
||||
result[key] = Math.floor(raw);
|
||||
return { key, fraction: raw - result[key], order };
|
||||
}).sort((left, right) => right.fraction - left.fraction || left.order - right.order);
|
||||
let remainder = desiredTotal - STAT_KEYS.reduce((sum, key) => sum + result[key], 0);
|
||||
for (const { key } of fractions) {
|
||||
if (remainder <= 0) {
|
||||
break;
|
||||
}
|
||||
if (result[key] >= targets[key]) {
|
||||
continue;
|
||||
}
|
||||
result[key] += 1;
|
||||
remainder -= 1;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export const calculateCentennialProgress = (
|
||||
environment: CentennialAllStarEnvironment,
|
||||
progressMultiplier = 1,
|
||||
growthYears = CENTENNIAL_ALL_STAR_DEFAULT_GROWTH_YEARS
|
||||
): number => {
|
||||
if (progressMultiplier < 0 || progressMultiplier > 1) {
|
||||
throw new Error('progress multiplier must be between 0 and 1');
|
||||
}
|
||||
if (growthYears <= 0) {
|
||||
throw new Error('growthYears must be positive');
|
||||
}
|
||||
if (environment.month < 1 || environment.month > 12) {
|
||||
throw new Error('month must be between 1 and 12');
|
||||
}
|
||||
const elapsedMonths = Math.max(0, (environment.year - environment.startYear) * 12 + environment.month - 1);
|
||||
// Ref caps the common calendar progress first and applies the NPC
|
||||
// multiplier afterwards. Generated M/G generals therefore remain at a
|
||||
// permanent 90% stat target even after the fifteenth year.
|
||||
return Math.min(1, elapsedMonths / (growthYears * 12)) * progressMultiplier;
|
||||
};
|
||||
|
||||
export const centennialStatFloor = (target: number, minimum: number, progress: number): number => {
|
||||
const normalizedProgress = Math.max(0, Math.min(1, progress));
|
||||
if (target <= minimum) {
|
||||
return target;
|
||||
}
|
||||
return Math.min(target, Math.floor(minimum + (target - minimum) * normalizedProgress));
|
||||
};
|
||||
|
||||
export const centennialDexFloor = (target: number, progress: number): number => {
|
||||
const normalizedProgress = Math.max(0, Math.min(1, progress));
|
||||
return Math.min(target, Math.floor(target * normalizedProgress * normalizedProgress));
|
||||
};
|
||||
|
||||
export const calculateCentennialDexTargetFloor = (
|
||||
target: number,
|
||||
environment: CentennialAllStarEnvironment,
|
||||
targetRatio = 1,
|
||||
dexLimit = CENTENNIAL_ALL_STAR_DEFAULT_DEX_LIMIT
|
||||
): number => {
|
||||
if (targetRatio < 0 || targetRatio > 1) {
|
||||
throw new Error('dex target ratio must be between 0 and 1');
|
||||
}
|
||||
const capped = Math.min(dexLimit, Math.max(0, Math.trunc(target)));
|
||||
const scaled = Math.floor(capped * targetRatio);
|
||||
// Ref는 M/G장의 0.9 배율을 능력치에만 적용하고 숙련 진행률은 공통값을 쓴다.
|
||||
return centennialDexFloor(scaled, calculateCentennialProgress(environment));
|
||||
};
|
||||
|
||||
const advance = (current: number, granted: number, floor: number): { value: number; granted: number } => {
|
||||
const delta = Math.max(0, floor - current);
|
||||
return { value: current + delta, granted: Math.max(0, granted) + delta };
|
||||
};
|
||||
|
||||
const replaceTarget = (current: number, oldGranted: number, newFloor: number): { value: number; granted: number } => {
|
||||
const organic = Math.max(0, current - Math.max(0, oldGranted));
|
||||
const value = Math.max(organic, newFloor);
|
||||
return { value, granted: value - organic };
|
||||
};
|
||||
|
||||
export const calculateCentennialUserCurrentTargetStats = (
|
||||
target: CentennialAllStarTarget,
|
||||
environment: CentennialAllStarEnvironment,
|
||||
rules: CentennialAllStarRules
|
||||
): Record<CentennialStatKey, number> => {
|
||||
const initial = calculateCentennialUserInitialStats(target, rules);
|
||||
const progress = calculateCentennialProgress(environment);
|
||||
return Object.fromEntries(
|
||||
STAT_KEYS.map((key) => [
|
||||
key,
|
||||
Math.max(
|
||||
initial[key],
|
||||
centennialStatFloor(
|
||||
Math.min(rules.maxStatLevel, Math.max(0, Math.trunc(target[key]))),
|
||||
rules.defaultStatMin,
|
||||
progress
|
||||
)
|
||||
),
|
||||
])
|
||||
) as Record<CentennialStatKey, number>;
|
||||
};
|
||||
|
||||
export const calculateCentennialLegacyUserGrant = (
|
||||
current: number,
|
||||
eventGrant: number,
|
||||
rules: Pick<CentennialAllStarRules, 'defaultStatMin' | 'defaultStatMax'>
|
||||
): number => {
|
||||
const normalizedEventGrant = Math.max(0, Math.trunc(eventGrant));
|
||||
const beforeEventGrant = Math.max(0, Math.trunc(current) - normalizedEventGrant);
|
||||
const replaceableInitialGrant = Math.max(
|
||||
0,
|
||||
Math.min(beforeEventGrant, rules.defaultStatMax) - Math.min(beforeEventGrant, rules.defaultStatMin)
|
||||
);
|
||||
return normalizedEventGrant + replaceableInitialGrant;
|
||||
};
|
||||
|
||||
/**
|
||||
* Ref's first S100 deployment did not persist userInitialStats. Before the
|
||||
* first reselection it treats the ordinary creation-range portion as an event
|
||||
* grant, so changing targets cannot preserve those points as organic growth.
|
||||
*/
|
||||
export const prepareCentennialLegacyUserReselection = (
|
||||
general: Pick<General, 'stats' | 'meta'>,
|
||||
rules: Pick<CentennialAllStarRules, 'defaultStatMin' | 'defaultStatMax'>
|
||||
): GeneralMeta => {
|
||||
const rawAuxValue = (general.meta as Record<string, unknown>)[CENTENNIAL_ALL_STAR_AUX_KEY];
|
||||
if (!rawAuxValue || typeof rawAuxValue !== 'object' || Array.isArray(rawAuxValue)) {
|
||||
return general.meta;
|
||||
}
|
||||
const rawAux = asRecord(rawAuxValue);
|
||||
const rawInitial = rawAux.userInitialStats;
|
||||
if (rawInitial && typeof rawInitial === 'object' && !Array.isArray(rawInitial)) {
|
||||
return general.meta;
|
||||
}
|
||||
|
||||
const rawGranted = asRecord(rawAux.granted);
|
||||
const granted = readIntegerRecord(rawGranted, [...STAT_KEYS, ...DEX_KEYS]);
|
||||
const currentStats: Record<CentennialStatKey, number> = {
|
||||
leadership: general.stats.leadership,
|
||||
strength: general.stats.strength,
|
||||
intel: general.stats.intelligence,
|
||||
};
|
||||
const legacyInitialStats = {} as Record<CentennialStatKey, number>;
|
||||
for (const key of STAT_KEYS) {
|
||||
const current = Math.trunc(currentStats[key]);
|
||||
const oldEventGrant = Math.max(0, Math.trunc(readFiniteNumber(rawGranted, key)));
|
||||
granted[key] = calculateCentennialLegacyUserGrant(current, granted[key], rules);
|
||||
const beforeEventGrant = Math.max(0, current - oldEventGrant);
|
||||
legacyInitialStats[key] = Math.min(beforeEventGrant, rules.defaultStatMax);
|
||||
}
|
||||
|
||||
const meta: GeneralMeta = { ...general.meta };
|
||||
const mutableMeta: Record<string, unknown> = meta;
|
||||
mutableMeta[CENTENNIAL_ALL_STAR_AUX_KEY] = {
|
||||
...rawAux,
|
||||
granted,
|
||||
userInitialStats: legacyInitialStats,
|
||||
};
|
||||
return meta;
|
||||
};
|
||||
|
||||
export const applyCentennialAllStarTarget = (
|
||||
general: Pick<General, 'stats' | 'role' | 'meta'>,
|
||||
target: CentennialAllStarTarget,
|
||||
environment: CentennialAllStarEnvironment,
|
||||
rules: CentennialAllStarRules,
|
||||
progressMultiplier = 1,
|
||||
dexTargetRatio = 1
|
||||
): CentennialAllStarApplyResult => {
|
||||
const progress = calculateCentennialProgress(environment, progressMultiplier);
|
||||
const progressMonth = Math.floor(
|
||||
Math.max(0, (environment.year - environment.startYear) * 12 + environment.month - 1) * progressMultiplier
|
||||
);
|
||||
const previousAux = readCentennialAllStarAux(general.meta as Record<string, unknown>, target);
|
||||
const aux = previousAux ?? initialCentennialAllStarAux(target, rules);
|
||||
const targetChanged = aux.targetId !== target.uniqueName;
|
||||
const granted = { ...aux.granted };
|
||||
const dexConsumed = targetChanged ? emptyDex() : { ...aux.dexConsumed };
|
||||
const dexFloor = { ...aux.dexFloor };
|
||||
const isUserTarget = aux.userInitialStats !== null;
|
||||
const nextUserInitialStats =
|
||||
targetChanged && isUserTarget ? calculateCentennialUserInitialStats(target, rules) : aux.userInitialStats;
|
||||
const dexTargetRatioChanged = aux.dexTargetRatio !== dexTargetRatio;
|
||||
const userCurrentTargetStats = isUserTarget
|
||||
? calculateCentennialUserCurrentTargetStats(target, environment, rules)
|
||||
: null;
|
||||
const stats = { ...general.stats };
|
||||
const role = { ...general.role, items: { ...general.role.items } };
|
||||
const meta: GeneralMeta = { ...general.meta };
|
||||
const mutableMeta: Record<string, unknown> = meta;
|
||||
let changed = dexTargetRatioChanged;
|
||||
|
||||
const statProperty: Record<CentennialStatKey, keyof StatBlock> = {
|
||||
leadership: 'leadership',
|
||||
strength: 'strength',
|
||||
intel: 'intelligence',
|
||||
};
|
||||
for (const key of STAT_KEYS) {
|
||||
const targetValue = Math.min(rules.maxStatLevel, Math.max(0, Math.trunc(target[key])));
|
||||
const floor = userCurrentTargetStats?.[key] ?? centennialStatFloor(targetValue, rules.defaultStatMin, progress);
|
||||
const property = statProperty[key];
|
||||
const current = stats[property];
|
||||
const result = targetChanged
|
||||
? replaceTarget(current, granted[key], floor)
|
||||
: advance(current, granted[key], floor);
|
||||
if (result.value !== current) {
|
||||
stats[property] = result.value;
|
||||
changed = true;
|
||||
}
|
||||
granted[key] = result.granted;
|
||||
}
|
||||
|
||||
for (const [index, key] of DEX_KEYS.entries()) {
|
||||
const floor = Math.max(
|
||||
0,
|
||||
calculateCentennialDexTargetFloor(
|
||||
target.dex[index]!,
|
||||
environment,
|
||||
dexTargetRatio,
|
||||
rules.dexLimit ?? CENTENNIAL_ALL_STAR_DEFAULT_DEX_LIMIT
|
||||
) - dexConsumed[key]
|
||||
);
|
||||
dexFloor[key] = floor;
|
||||
const current = Math.trunc(readFiniteNumber(mutableMeta, key));
|
||||
const result =
|
||||
targetChanged || dexTargetRatioChanged
|
||||
? replaceTarget(current, granted[key], floor)
|
||||
: advance(current, granted[key], floor);
|
||||
if (result.value !== current) {
|
||||
mutableMeta[key] = result.value;
|
||||
changed = true;
|
||||
}
|
||||
granted[key] = result.granted;
|
||||
}
|
||||
|
||||
let naturalSpecialDomestic = aux.naturalSpecialDomestic;
|
||||
let eventSpecialDomestic = aux.eventSpecialDomestic;
|
||||
if (targetChanged && eventSpecialDomestic !== null && role.specialDomestic === eventSpecialDomestic) {
|
||||
role.specialDomestic = naturalSpecialDomestic ?? rules.defaultSpecialDomestic;
|
||||
eventSpecialDomestic = null;
|
||||
changed = true;
|
||||
}
|
||||
const targetSpecial = target.specialDomestic;
|
||||
if (
|
||||
progress >= CENTENNIAL_ALL_STAR_TRAIT_UNLOCK_PROGRESS &&
|
||||
typeof targetSpecial === 'string' &&
|
||||
targetSpecial !== ''
|
||||
) {
|
||||
if (naturalSpecialDomestic === null) {
|
||||
naturalSpecialDomestic = role.specialDomestic;
|
||||
}
|
||||
if (role.specialDomestic !== targetSpecial) {
|
||||
role.specialDomestic = targetSpecial;
|
||||
changed = true;
|
||||
}
|
||||
eventSpecialDomestic = targetSpecial;
|
||||
}
|
||||
|
||||
const previousMilestone = aux.milestone;
|
||||
const milestone = Math.min(5, Math.floor(progress * 5 + 0.0000001));
|
||||
const nextAux: CentennialAllStarAux = {
|
||||
...aux,
|
||||
targetId: target.uniqueName,
|
||||
target,
|
||||
granted,
|
||||
dexConsumed,
|
||||
dexFloor,
|
||||
progressMonth: Math.max(aux.progressMonth, progressMonth),
|
||||
milestone: Math.max(previousMilestone, milestone),
|
||||
naturalSpecialDomestic,
|
||||
eventSpecialDomestic,
|
||||
userInitialStats: nextUserInitialStats,
|
||||
dexTargetRatio,
|
||||
};
|
||||
mutableMeta[CENTENNIAL_ALL_STAR_AUX_KEY] = nextAux;
|
||||
|
||||
return {
|
||||
stats,
|
||||
role,
|
||||
meta,
|
||||
progress,
|
||||
milestone,
|
||||
previousMilestone,
|
||||
targetChanged,
|
||||
changed: changed || targetChanged || milestone > previousMilestone,
|
||||
};
|
||||
};
|
||||
|
||||
export const calculateCentennialGeneratedNpcInitialStats = (
|
||||
target: CentennialAllStarTarget,
|
||||
generated: StatBlock
|
||||
): StatBlock => {
|
||||
const keyOrder = Object.fromEntries(STAT_KEYS.map((key, index) => [key, index])) as Record<
|
||||
CentennialStatKey,
|
||||
number
|
||||
>;
|
||||
const targetOrder = [...STAT_KEYS].sort(
|
||||
(left, right) => target[right] - target[left] || keyOrder[left] - keyOrder[right]
|
||||
);
|
||||
const generatedValues = [generated.leadership, generated.strength, generated.intelligence].sort(
|
||||
(left, right) => right - left
|
||||
);
|
||||
const values = Object.fromEntries(targetOrder.map((key, index) => [key, generatedValues[index]!])) as Record<
|
||||
CentennialStatKey,
|
||||
number
|
||||
>;
|
||||
return {
|
||||
leadership: values.leadership,
|
||||
strength: values.strength,
|
||||
intelligence: values.intel,
|
||||
};
|
||||
};
|
||||
|
||||
export const initializeCentennialGeneratedNpc = (
|
||||
general: Pick<General, 'stats' | 'role' | 'meta'>,
|
||||
target: CentennialAllStarTarget,
|
||||
rules: CentennialAllStarRules
|
||||
): Pick<CentennialAllStarApplyResult, 'stats' | 'role' | 'meta'> => {
|
||||
const meta: GeneralMeta = { ...general.meta };
|
||||
const mutableMeta: Record<string, unknown> = meta;
|
||||
Object.assign(mutableMeta, {
|
||||
dex1: 0,
|
||||
dex2: 0,
|
||||
dex3: 0,
|
||||
dex4: 0,
|
||||
dex5: 0,
|
||||
[CENTENNIAL_ALL_STAR_AUX_KEY]: initialCentennialAllStarAux(target, rules),
|
||||
});
|
||||
return {
|
||||
stats: calculateCentennialGeneratedNpcInitialStats(target, general.stats),
|
||||
role: { ...general.role, items: { ...general.role.items } },
|
||||
meta,
|
||||
};
|
||||
};
|
||||
|
||||
export const reconcileCentennialDexConversion = (
|
||||
metaInput: GeneralMeta,
|
||||
sourceKey: CentennialDexKey,
|
||||
destinationKey: CentennialDexKey,
|
||||
sourceBefore: number,
|
||||
sourceAfter: number,
|
||||
destinationBefore: number,
|
||||
destinationAfter: number,
|
||||
convertCoefficient: number
|
||||
): GeneralMeta => {
|
||||
if (!DEX_KEYS.includes(sourceKey) || !DEX_KEYS.includes(destinationKey) || sourceKey === destinationKey) {
|
||||
throw new Error('invalid dex conversion keys');
|
||||
}
|
||||
if (convertCoefficient < 0 || convertCoefficient > 1) {
|
||||
throw new Error('dex conversion coefficient must be between 0 and 1');
|
||||
}
|
||||
const sourceDecrease = Math.max(0, sourceBefore - sourceAfter);
|
||||
const destinationIncrease = Math.max(0, destinationAfter - destinationBefore);
|
||||
if (sourceDecrease === 0 && destinationIncrease === 0) {
|
||||
return metaInput;
|
||||
}
|
||||
const aux = readCentennialAllStarAux(metaInput as Record<string, unknown>);
|
||||
if (!aux) {
|
||||
return metaInput;
|
||||
}
|
||||
const granted = { ...aux.granted };
|
||||
const dexConsumed = { ...aux.dexConsumed };
|
||||
const sourceGrantedBefore = Math.min(Math.max(0, sourceBefore), Math.max(0, granted[sourceKey]));
|
||||
const eventGrantRemoved = sourceBefore > 0 ? Math.trunc((sourceDecrease * sourceGrantedBefore) / sourceBefore) : 0;
|
||||
const sourceGrantedAfter = Math.max(0, sourceGrantedBefore - eventGrantRemoved);
|
||||
const destinationGrantedBefore = Math.min(Math.max(0, destinationBefore), Math.max(0, granted[destinationKey]));
|
||||
let eventGrantTransferred =
|
||||
sourceBefore > 0 ? Math.trunc((destinationIncrease * sourceGrantedBefore) / sourceBefore) : 0;
|
||||
eventGrantTransferred = Math.min(destinationIncrease, eventGrantRemoved, eventGrantTransferred);
|
||||
granted[sourceKey] = sourceGrantedAfter;
|
||||
granted[destinationKey] = Math.min(Math.max(0, destinationAfter), destinationGrantedBefore + eventGrantTransferred);
|
||||
|
||||
const sourceFloor = Math.max(0, aux.dexFloor[sourceKey] ?? sourceBefore);
|
||||
const gapBefore = Math.max(0, sourceFloor - sourceBefore);
|
||||
const gapAfter = Math.max(0, sourceFloor - sourceAfter);
|
||||
dexConsumed[sourceKey] = Math.max(0, dexConsumed[sourceKey] + Math.max(0, gapAfter - gapBefore));
|
||||
const meta: GeneralMeta = { ...metaInput };
|
||||
const mutableMeta: Record<string, unknown> = meta;
|
||||
mutableMeta[CENTENNIAL_ALL_STAR_AUX_KEY] = {
|
||||
...aux,
|
||||
granted,
|
||||
dexConsumed,
|
||||
};
|
||||
return meta;
|
||||
};
|
||||
|
||||
export const centennialRecordableValue = (current: number, granted: number): number =>
|
||||
Math.max(0, current - Math.max(0, granted));
|
||||
@@ -2,3 +2,4 @@ export * from './types.js';
|
||||
export * from './parseScenario.js';
|
||||
export * from './scenarioEffect.js';
|
||||
export * from './constants.js';
|
||||
export * from './centennialAllStar.js';
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface GeneralActionContext<TriggerState extends GeneralTriggerState =
|
||||
month: number;
|
||||
startYear: number;
|
||||
};
|
||||
maxTechLevel?: number;
|
||||
}
|
||||
|
||||
export interface GeneralTriggerContext<
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { City, General, GeneralTriggerState, Nation } from '@sammo-ts/logic
|
||||
import type { ActionLogger } from '@sammo-ts/logic/logging/actionLogger.js';
|
||||
import type { WarStatName } from '@sammo-ts/logic/actionModules/types.js';
|
||||
import type { WarUnit } from './units.js';
|
||||
import type { WarTimeContext } from './types.js';
|
||||
import { WarTriggerCaller } from './triggers.js';
|
||||
|
||||
export interface WarActionContext<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
|
||||
@@ -13,6 +14,8 @@ export interface WarActionContext<TriggerState extends GeneralTriggerState = Gen
|
||||
log?: ActionLogger;
|
||||
rng?: RandUtil;
|
||||
unit?: WarUnit<TriggerState>;
|
||||
time?: WarTimeContext;
|
||||
maxTechLevel?: number;
|
||||
}
|
||||
|
||||
export interface WarActionModule<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
|
||||
|
||||
@@ -520,7 +520,7 @@ export const resolveWarAftermath = <TriggerState extends GeneralTriggerState = G
|
||||
|
||||
let rice = (cityKilled / 100) * 0.8;
|
||||
rice *= riceCoef;
|
||||
rice *= getTechCost(getMetaNumber(defenderNation.meta, 'tech', 0));
|
||||
rice *= getTechCost(getMetaNumber(defenderNation.meta, 'tech', 0), input.config.maxTechLevel);
|
||||
rice *= resolveCityTrainAtmos(input.time.year, input.time.startYear) / 100 - 0.2;
|
||||
rice = round(rice);
|
||||
|
||||
|
||||
@@ -278,7 +278,8 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
|
||||
true,
|
||||
resolveCrewType(crewTypeIndex, input.attacker.general.crewTypeId),
|
||||
attackerLogger,
|
||||
attackerPipeline
|
||||
attackerPipeline,
|
||||
input.time
|
||||
);
|
||||
|
||||
const cityLogger = loggerFactory({
|
||||
@@ -313,7 +314,8 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
|
||||
false,
|
||||
resolveCrewType(crewTypeIndex, defender.general.crewTypeId),
|
||||
defenderLogger,
|
||||
createPipeline(defender, crewTypeCatalog.warActionModule)
|
||||
createPipeline(defender, crewTypeCatalog.warActionModule),
|
||||
input.time
|
||||
);
|
||||
if (computeBattleOrder(unit, attackerUnit) <= 0) {
|
||||
continue;
|
||||
@@ -750,7 +752,8 @@ export const resolveDefenderOrder = <TriggerState extends GeneralTriggerState =
|
||||
true,
|
||||
resolveCrewType(crewTypeIndex, input.attacker.general.crewTypeId),
|
||||
attackerLogger,
|
||||
attackerPipeline
|
||||
attackerPipeline,
|
||||
input.time
|
||||
);
|
||||
|
||||
const defenderUnits: WarUnitGeneral<TriggerState>[] = [];
|
||||
@@ -770,7 +773,8 @@ export const resolveDefenderOrder = <TriggerState extends GeneralTriggerState =
|
||||
false,
|
||||
resolveCrewType(crewTypeIndex, defender.general.crewTypeId),
|
||||
defenderLogger,
|
||||
createPipeline(defender, crewTypeCatalog.warActionModule)
|
||||
createPipeline(defender, crewTypeCatalog.warActionModule),
|
||||
input.time
|
||||
);
|
||||
if (computeBattleOrder(unit, attackerUnit) <= 0) {
|
||||
continue;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user