Merge main into feature/turn-differential-longrun

This commit is contained in:
2026-07-25 12:10:22 +00:00
55 changed files with 4317 additions and 533 deletions
+58 -40
View File
@@ -23,6 +23,7 @@ const MIN_EXTENSION_MINUTES_PER_BID = 1;
interface AuctionRow {
id: number;
type: AuctionType;
hostGeneralId: number;
detail: unknown;
status: AuctionStatus;
closeAt: Date;
@@ -37,6 +38,7 @@ interface AuctionBidRow {
interface AuctionDetail {
isReverse?: boolean;
startBidAmount?: number;
finishBidAmount?: number | null;
availableLatestBidCloseDate?: string | null;
}
@@ -85,11 +87,13 @@ const loadAuction = async (prisma: QueryClient, auctionId: number): Promise<Auct
GamePrisma.sql`
SELECT id,
type,
host_general_id as "hostGeneralId",
detail,
status,
close_at as "closeAt"
FROM auction
WHERE id = ${auctionId}
FOR UPDATE
`
);
return rows[0] ?? null;
@@ -218,6 +222,22 @@ export const createAuctionBidder = async (options: {
reason: '시작가보다 낮습니다.',
};
}
if (!isReverse && detail.finishBidAmount != null && command.amount > detail.finishBidAmount) {
return {
type: 'auctionBid',
ok: false,
auctionId: command.auctionId,
reason: '즉시판매가보다 높을 수 없습니다.',
};
}
if (isReverse && detail.finishBidAmount != null && command.amount < detail.finishBidAmount) {
return {
type: 'auctionBid',
ok: false,
auctionId: command.auctionId,
reason: '즉시판매가보다 낮을 수 없습니다.',
};
}
if (auction.type === 'UNIQUE_ITEM' && highestBid) {
if (command.amount < highestBid.amount * 1.01) {
@@ -257,6 +277,14 @@ export const createAuctionBidder = async (options: {
reason: '장수 정보를 찾을 수 없습니다.',
};
}
if (auction.type !== 'UNIQUE_ITEM' && auction.hostGeneralId === general.id) {
return {
type: 'auctionBid',
ok: false,
auctionId: command.auctionId,
reason: '자신이 연 경매에 입찰할 수 없습니다.',
};
}
if (auction.type === 'BUY_RICE' && general.gold < morePoint) {
return {
@@ -296,12 +324,19 @@ export const createAuctionBidder = async (options: {
const availableLatestBidCloseDate = detail.availableLatestBidCloseDate
? new Date(detail.availableLatestBidCloseDate)
: null;
const nextCloseAt = extendCloseDate({
let nextCloseAt = extendCloseDate({
now,
closeAt: auction.closeAt,
turnMinutes,
availableLatestBidCloseDate,
});
if (
auction.type !== 'UNIQUE_ITEM' &&
detail.finishBidAmount != null &&
command.amount === detail.finishBidAmount
) {
nextCloseAt = new Date(now.getTime() + turnMinutes * 60_000);
}
const eventId = randomUUID();
const eventAt = now;
@@ -347,51 +382,34 @@ export const createAuctionBidder = async (options: {
if (!userId) {
throw new Error('USER_NOT_FOUND');
}
const current = await tx.inheritancePoint.findUnique({
where: {
userId_key: {
userId,
key: 'previous',
},
},
});
const prevValue = current?.value ?? 0;
if (prevValue < morePoint) {
const deductedRows = await tx.$queryRaw<Array<{ value: number }>>(
GamePrisma.sql`
UPDATE inheritance_point
SET value = value - ${morePoint},
updated_at = ${eventAt}
WHERE user_id = ${userId}
AND key = 'previous'
AND value >= ${morePoint}
RETURNING value
`
);
if (deductedRows.length === 0) {
throw new Error('INSUFFICIENT_POINT');
}
await tx.inheritancePoint.upsert({
where: {
userId_key: {
userId,
key: 'previous',
},
},
update: { value: prevValue - morePoint },
create: { userId, key: 'previous', value: prevValue - morePoint },
});
if (highestBid && highestBid.generalId !== command.generalId && !myPrevBid) {
const prevUserId = await resolveUserId(tx, highestBid.generalId);
if (prevUserId) {
const prevPoint = await tx.inheritancePoint.findUnique({
where: {
userId_key: {
userId: prevUserId,
key: 'previous',
},
},
});
const nextValue = (prevPoint?.value ?? 0) + highestBid.amount;
await tx.inheritancePoint.upsert({
where: {
userId_key: {
userId: prevUserId,
key: 'previous',
},
},
update: { value: nextValue },
create: { userId: prevUserId, key: 'previous', value: nextValue },
});
await tx.$executeRaw(
GamePrisma.sql`
INSERT INTO inheritance_point (user_id, key, value, updated_at)
VALUES (${prevUserId}, 'previous', ${highestBid.amount}, ${eventAt})
ON CONFLICT (user_id, key)
DO UPDATE SET
value = inheritance_point.value + EXCLUDED.value,
updated_at = EXCLUDED.updated_at
`
);
}
}
}
+148 -56
View File
@@ -1,7 +1,14 @@
import { createGamePostgresConnector, GamePrisma } from '@sammo-ts/infra';
import { ActionLogger, ItemLoader, LogFormat, UserLogger, isItemKey } from '@sammo-ts/logic';
import {
ActionLogger,
ItemLoader,
LogFormat,
UserLogger,
isItemKey,
resolveUniqueConfig,
} from '@sammo-ts/logic';
import { cloneItemInventory, ensureItemInventory, equipNewItem } from '@sammo-ts/logic/items/index.js';
import { JosaUtil } from '@sammo-ts/common';
import { asRecord, JosaUtil } from '@sammo-ts/common';
import type { TurnDaemonCommandResult } from '../lifecycle/types.js';
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js';
@@ -17,6 +24,7 @@ type AuctionStatus = 'OPEN' | 'FINALIZING' | 'FINISHED' | 'CANCELED';
const COEFF_EXTENSION_MINUTES_PER_BID = 1 / 6;
const MIN_EXTENSION_MINUTES_PER_BID = 1;
const MIN_EXTENSION_MINUTES_LIMIT_BY_BID = 5;
interface AuctionRow {
id: number;
@@ -33,12 +41,15 @@ interface AuctionBidRow {
id: number;
generalId: number;
amount: number;
meta: unknown;
}
interface AuctionDetailBase {
title?: string;
isReverse?: boolean;
tryExtendCloseDate?: boolean;
availableLatestBidCloseDate?: string | null;
remainCloseDateExtensionCnt?: number | null;
}
interface AuctionDetailResource extends AuctionDetailBase {
@@ -63,24 +74,6 @@ const resolveTurnMinutes = async (prisma: AuctionDb): Promise<number> => {
return toTurnMinutes(rows[0]?.tickSeconds ?? 60);
};
const extendCloseDate = (options: {
now: Date;
closeAt: Date;
turnMinutes: number;
availableLatestBidCloseDate?: Date | null;
}): Date => {
const { now, closeAt, turnMinutes, availableLatestBidCloseDate } = options;
const extendMinutes = Math.max(MIN_EXTENSION_MINUTES_PER_BID, turnMinutes * COEFF_EXTENSION_MINUTES_PER_BID);
const extended = new Date(now.getTime() + extendMinutes * 60 * 1000);
if (extended.getTime() <= closeAt.getTime()) {
return closeAt;
}
if (availableLatestBidCloseDate && extended.getTime() > availableLatestBidCloseDate.getTime()) {
return availableLatestBidCloseDate;
}
return extended;
};
const pushLogs = (world: InMemoryTurnWorld, logs: LogEntryDraft[]): void => {
for (const log of logs) {
world.pushLog(log);
@@ -96,31 +89,16 @@ const refundInheritancePoint = async (options: {
if (!userId || amount <= 0) {
return;
}
const current = await prisma.inheritancePoint.findUnique({
where: {
userId_key: {
userId,
key: 'previous',
},
},
});
const nextValue = (current?.value ?? 0) + amount;
await prisma.inheritancePoint.upsert({
where: {
userId_key: {
userId,
key: 'previous',
},
},
update: {
value: nextValue,
},
create: {
userId,
key: 'previous',
value: nextValue,
},
});
await prisma.$executeRaw(
GamePrisma.sql`
INSERT INTO inheritance_point (user_id, key, value, updated_at)
VALUES (${userId}, 'previous', ${amount}, ${new Date()})
ON CONFLICT (user_id, key)
DO UPDATE SET
value = inheritance_point.value + EXCLUDED.value,
updated_at = EXCLUDED.updated_at
`
);
};
export const createAuctionFinalizer = async (options: {
@@ -186,14 +164,14 @@ export const createAuctionFinalizer = async (options: {
const bidRows = await db.$queryRaw<AuctionBidRow[]>(
isReverse
? GamePrisma.sql`
SELECT id, general_id as "generalId", amount
SELECT id, general_id as "generalId", amount, meta
FROM auction_bid
WHERE auction_id = ${auctionId}
ORDER BY amount ASC, id ASC
LIMIT 1
`
: GamePrisma.sql`
SELECT id, general_id as "generalId", amount
SELECT id, general_id as "generalId", amount, meta
FROM auction_bid
WHERE auction_id = ${auctionId}
ORDER BY amount DESC, id ASC
@@ -261,6 +239,43 @@ export const createAuctionFinalizer = async (options: {
return { type: 'auctionFinalize', ok: true, auctionId };
}
if (auction.type === 'UNIQUE_ITEM') {
const bidMeta = parseDetail(highestBid.meta);
const remainExtension = detail.remainCloseDateExtensionCnt ?? 0;
if (bidMeta.tryExtendCloseDate === true && remainExtension > 0) {
const turnMinutes = await resolveTurnMinutes(db);
const nextCloseAt = new Date(
auction.closeAt.getTime() + Math.max(5, turnMinutes) * 60_000
);
const nextLatestBidCloseAt = new Date(
nextCloseAt.getTime() +
Math.max(MIN_EXTENSION_MINUTES_PER_BID, turnMinutes * COEFF_EXTENSION_MINUTES_PER_BID) *
60_000
);
const nextDetail = {
...detail,
remainCloseDateExtensionCnt: remainExtension - 1,
availableLatestBidCloseDate: nextLatestBidCloseAt.toISOString(),
};
await db.$executeRaw(
GamePrisma.sql`
UPDATE auction
SET status = 'OPEN',
detail = ${JSON.stringify(nextDetail)}::jsonb,
close_at = ${nextCloseAt},
updated_at = ${now}
WHERE id = ${auctionId}
`
);
return {
type: 'auctionFinalize',
ok: false,
auctionId,
reason: '입찰자의 요청으로 경매 종료가 연장되었습니다.',
};
}
}
const bidder = world.getGeneralById(highestBid.generalId);
if (!bidder) {
await finalizeStatus('CANCELED');
@@ -355,25 +370,102 @@ export const createAuctionFinalizer = async (options: {
};
}
const state = world.getState();
const config = resolveUniqueConfig(asRecord(world.getScenarioConfig().const));
const scenarioMeta = asRecord(state.meta.scenarioMeta);
const startYear =
typeof scenarioMeta.startYear === 'number' && Number.isFinite(scenarioMeta.startYear)
? scenarioMeta.startYear
: state.currentYear;
const relativeYear = state.currentYear - startYear;
let uniqueLimit = 1;
for (const [targetYear, targetLimit] of config.maxUniqueItemLimit) {
if (relativeYear < targetYear) {
break;
}
uniqueLimit = targetLimit;
}
uniqueLimit = Math.min(uniqueLimit, Object.keys(config.allItems).length);
let equippedUniqueCount = 0;
for (const equippedKey of Object.values(bidder.role.items)) {
if (!equippedKey || equippedKey === 'None' || !isItemKey(equippedKey)) {
continue;
}
const equippedModule = await itemLoader.load(equippedKey).catch(() => null);
if (equippedModule && !equippedModule.buyable) {
equippedUniqueCount += 1;
}
}
if (equippedUniqueCount >= uniqueLimit) {
const turnMinutes = await resolveTurnMinutes(db);
const nextCloseAt = new Date(
auction.closeAt.getTime() +
Math.max(MIN_EXTENSION_MINUTES_LIMIT_BY_BID, turnMinutes * 0.5) * 60_000
);
const nextLatestBidCloseAt = new Date(
nextCloseAt.getTime() +
Math.max(MIN_EXTENSION_MINUTES_PER_BID, turnMinutes * COEFF_EXTENSION_MINUTES_PER_BID) *
60_000
);
const nextDetail = {
...detail,
availableLatestBidCloseDate: nextLatestBidCloseAt.toISOString(),
};
await db.$executeRaw(
GamePrisma.sql`
UPDATE auction
SET status = 'OPEN',
host_general_id = ${bidder.id === auction.hostGeneralId ? auction.hostGeneralId : 0},
host_name = ${bidder.id === auction.hostGeneralId ? auction.hostName : '(상인)'},
detail = ${JSON.stringify(nextDetail)}::jsonb,
close_at = ${nextCloseAt},
updated_at = ${now}
WHERE id = ${auctionId}
`
);
globalLogger.pushGlobalActionLog(
`유니크 경매 ${auctionId}번이 전체 보유 제한으로 연장되었습니다.`,
LogFormat.PLAIN
);
logs.push(...globalLogger.flush());
pushLogs(world, logs);
return {
type: 'auctionFinalize',
ok: false,
auctionId,
reason: '유니크 아이템 소유 제한 상태입니다. 종료 시간이 연장됩니다.',
};
}
const slot = itemModule.slot;
const currentItem = bidder.role.items?.[slot] ?? null;
if (currentItem && currentItem !== 'None' && isItemKey(currentItem)) {
const currentModule = await itemLoader.load(currentItem).catch(() => null);
if (currentModule && !currentModule.buyable) {
const turnMinutes = await resolveTurnMinutes(db);
const availableLatestBidCloseDate = detail.availableLatestBidCloseDate
? new Date(detail.availableLatestBidCloseDate)
: null;
const nextCloseAt = extendCloseDate({
now,
closeAt: auction.closeAt,
turnMinutes,
availableLatestBidCloseDate,
});
const nextCloseAt = new Date(
auction.closeAt.getTime() +
Math.max(MIN_EXTENSION_MINUTES_LIMIT_BY_BID, turnMinutes * 0.5) * 60_000
);
const nextLatestBidCloseAt = new Date(
nextCloseAt.getTime() +
Math.max(
MIN_EXTENSION_MINUTES_PER_BID,
turnMinutes * COEFF_EXTENSION_MINUTES_PER_BID
) *
60_000
);
const nextDetail = {
...detail,
availableLatestBidCloseDate: nextLatestBidCloseAt.toISOString(),
};
await db.$executeRaw(
GamePrisma.sql`
UPDATE auction
SET status = 'OPEN',
host_general_id = ${bidder.id === auction.hostGeneralId ? auction.hostGeneralId : 0},
host_name = ${bidder.id === auction.hostGeneralId ? auction.hostName : '(상인)'},
detail = ${JSON.stringify(nextDetail)}::jsonb,
close_at = ${nextCloseAt},
updated_at = ${now}
WHERE id = ${auctionId}
@@ -0,0 +1,163 @@
import { asRecord } from '@sammo-ts/common';
import { createGamePostgresConnector, GamePrisma, type RedisConnector } from '@sammo-ts/infra';
import { buildNeutralResourceAuctionPlan } from '@sammo-ts/logic';
import type { TurnCalendarHandler } from '../turn/inMemoryWorld.js';
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js';
interface NeutralAuctionCountRow {
type: 'BUY_RICE' | 'SELL_RICE';
count: bigint | number;
}
interface TournamentState {
stage?: unknown;
}
const readFiniteNumber = (value: unknown, fallback: number): number => {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return fallback;
};
const average = (values: number[]): number => {
if (values.length === 0) {
return 0;
}
return values.reduce((sum, value) => sum + value, 0) / values.length;
};
const parseTournamentState = (raw: string | null): TournamentState | null => {
if (!raw) {
return null;
}
try {
const parsed: unknown = JSON.parse(raw);
return asRecord(parsed);
} catch {
return null;
}
};
const isTournamentActive = async (
profileName: string,
redis: RedisConnector['client'] | null | undefined
): Promise<boolean> => {
if (!redis) {
return false;
}
const state = parseTournamentState(await redis.get(`sammo:${profileName}:tournament:state`));
return readFiniteNumber(state?.stage, 0) > 0;
};
export interface NeutralAuctionRegistrar {
handler: TurnCalendarHandler;
close(): Promise<void>;
}
export const createNeutralAuctionRegistrar = async (options: {
databaseUrl: string;
profileName: string;
getWorld: () => InMemoryTurnWorld | null;
getRedisClient: () => RedisConnector['client'] | null | undefined;
getWorldConfig: () => Record<string, unknown> | null | undefined;
now?: () => Date;
loadNeutralAuctionCounts?: () => Promise<NeutralAuctionCountRow[]>;
loadTournamentActive?: () => Promise<boolean>;
}): Promise<NeutralAuctionRegistrar> => {
const connector = options.loadNeutralAuctionCounts
? null
: createGamePostgresConnector({ url: options.databaseUrl });
await connector?.connect();
const loadNeutralAuctionCounts =
options.loadNeutralAuctionCounts ??
(() =>
connector!.prisma.$queryRaw<NeutralAuctionCountRow[]>(
GamePrisma.sql`
SELECT type, count(*) AS count
FROM auction
WHERE host_general_id = 0
AND type IN ('BUY_RICE'::"AuctionType", 'SELL_RICE'::"AuctionType")
GROUP BY type
`
));
const handler: TurnCalendarHandler = {
onMonthChanged: async (context) => {
const world = options.getWorld();
if (!world) {
return;
}
const state = world.getState();
const hiddenSeed =
typeof state.meta.hiddenSeed === 'string' || typeof state.meta.hiddenSeed === 'number'
? state.meta.hiddenSeed
: state.id;
const eligibleGenerals = world.listGenerals().filter((general) => general.npcState < 2);
const counts = await loadNeutralAuctionCounts();
const countByType = new Map(counts.map((row) => [row.type, Number(row.count)]));
for (const pending of world.peekDirtyState().pendingNeutralAuctions) {
countByType.set(pending.type, (countByType.get(pending.type) ?? 0) + 1);
}
const worldConfig = asRecord(options.getWorldConfig() ?? {});
const consumeTournamentRoll =
worldConfig.tournamentTrig === true &&
!(await (options.loadTournamentActive
? options.loadTournamentActive()
: isTournamentActive(options.profileName, options.getRedisClient())));
const plans = buildNeutralResourceAuctionPlan({
hiddenSeed,
seedYear: context.previousYear,
seedMonth: context.previousMonth,
nationCount: world.listNations().length,
consumeTournamentRoll,
averageGold: average(eligibleGenerals.map((general) => general.gold)),
averageRice: average(eligibleGenerals.map((general) => general.rice)),
buyRiceAuctionCount: countByType.get('BUY_RICE') ?? 0,
sellRiceAuctionCount: countByType.get('SELL_RICE') ?? 0,
});
const registrationKey = `${context.currentYear}-${String(context.currentMonth).padStart(2, '0')}`;
world.updateWorldMeta({ neutralAuctionRegistrationKey: registrationKey });
const turnMinutes = Math.max(1, Math.round(state.tickSeconds / 60));
for (const plan of plans) {
const openedAt = options.now?.() ?? new Date();
const hostResourceName = plan.auctionType === 'BUY_RICE' ? '쌀' : '금';
world.queueNeutralAuction({
registrationKey,
type: plan.auctionType,
targetCode: String(plan.amount),
hostGeneralId: 0,
hostName: '상인',
detail: {
title: `${hostResourceName} ${plan.amount} 경매`,
hostName: '상인',
amount: plan.amount,
isReverse: false,
startBidAmount: plan.startBidAmount,
finishBidAmount: plan.finishBidAmount,
neutralRegistrationKey: registrationKey,
seedYear: context.previousYear,
seedMonth: context.previousMonth,
closeTurnCnt: plan.closeTurnCnt,
},
closeAt: new Date(openedAt.getTime() + plan.closeTurnCnt * turnMinutes * 60_000),
});
}
},
};
return {
handler,
close: async () => {
await connector?.disconnect();
},
};
};
+304
View File
@@ -0,0 +1,304 @@
import { randomUUID } from 'node:crypto';
import { asRecord, JosaUtil } from '@sammo-ts/common';
import { GamePrisma } from '@sammo-ts/infra';
import {
ActionLogger,
ItemLoader,
LogFormat,
buildAuctionAlias,
isItemKey,
resolveUniqueConfig,
} from '@sammo-ts/logic';
import type { TurnDaemonCommand, TurnDaemonCommandResult } from '../lifecycle/types.js';
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js';
type AuctionOpenCommand = Extract<TurnDaemonCommand, { type: 'auctionOpen' }>;
const MIN_AUCTION_AMOUNT = 100;
const MAX_AUCTION_AMOUNT = 10_000;
const MIN_AUCTION_CLOSE_MINUTES = 30;
const COEFF_AUCTION_CLOSE_MINUTES = 24;
const MIN_EXTENSION_MINUTES_LIMIT_BY_BID = 5;
const COEFF_EXTENSION_MINUTES_LIMIT_BY_BID = 0.5;
const readNumber = (record: Record<string, unknown>, key: string, fallback: number): number => {
const value = record[key];
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
};
const getRelativeMonth = (world: InMemoryTurnWorld): number => {
const state = world.getState();
const meta = state.meta;
const scenarioMeta = asRecord(meta.scenarioMeta);
const initYear = readNumber(meta, 'initYear', readNumber(scenarioMeta, 'startYear', state.currentYear));
const initMonth = readNumber(meta, 'initMonth', 1);
return state.currentYear * 12 + state.currentMonth - (initYear * 12 + initMonth);
};
const fail = (reason: string): TurnDaemonCommandResult => ({
type: 'auctionOpen',
ok: false,
reason,
});
const openResourceAuction = async (
command: AuctionOpenCommand,
world: InMemoryTurnWorld,
db: GamePrisma.TransactionClient
): Promise<TurnDaemonCommandResult> => {
const general = world.getGeneralById(command.generalId);
if (!general) {
return fail('장수 정보를 찾을 수 없습니다.');
}
const closeTurnCnt = command.closeTurnCnt ?? 0;
const startBidAmount = command.startBidAmount ?? 0;
const finishBidAmount = command.finishBidAmount ?? 0;
if (closeTurnCnt < 1 || closeTurnCnt > 24) {
return fail('종료기한은 1 ~ 24 턴 이어야 합니다.');
}
if (command.amount < MIN_AUCTION_AMOUNT || command.amount > MAX_AUCTION_AMOUNT) {
return fail(`거래량은 ${MIN_AUCTION_AMOUNT} ~ ${MAX_AUCTION_AMOUNT} 이어야 합니다.`);
}
if (startBidAmount < command.amount * 0.5 || command.amount * 2 < startBidAmount) {
return fail('시작거래가는 50% ~ 200% 이어야 합니다.');
}
if (finishBidAmount < command.amount * 1.1 || command.amount * 2 < finishBidAmount) {
return fail('즉시거래가는 110% ~ 200% 이어야 합니다.');
}
if (finishBidAmount < startBidAmount * 1.1) {
return fail('즉시거래가는 시작판매가의 110% 이상이어야 합니다.');
}
await db.$executeRaw(GamePrisma.sql`SELECT pg_advisory_xact_lock(${command.generalId}, 41001)`);
const previous = await db.auction.findFirst({
where: {
hostGeneralId: command.generalId,
status: { in: ['OPEN', 'FINALIZING'] },
type: { in: ['BUY_RICE', 'SELL_RICE'] },
},
select: { id: true },
});
if (previous) {
return fail('아직 경매가 끝나지 않았습니다.');
}
const configConst = asRecord(world.getScenarioConfig().const);
const hostResource = command.auctionType === 'BUY_RICE' ? 'rice' : 'gold';
const minimumResource =
hostResource === 'rice'
? readNumber(configConst, 'generalMinimumRice', 500)
: readNumber(configConst, 'generalMinimumGold', 0);
if (general[hostResource] < command.amount + minimumResource) {
return fail(`기본 ${hostResource === 'rice' ? '쌀' : '금'} ${minimumResource}은 거래할 수 없습니다.`);
}
const now = new Date();
const turnMinutes = Math.max(1, Math.round(world.getState().tickSeconds / 60));
const closeAt = new Date(now.getTime() + closeTurnCnt * turnMinutes * 60_000);
const auction = await db.auction.create({
data: {
type: command.auctionType,
targetCode: String(command.amount),
hostGeneralId: command.generalId,
hostName: general.name,
detail: {
title: `${hostResource === 'rice' ? '쌀' : '금'} ${command.amount} 경매`,
hostName: general.name,
amount: command.amount,
isReverse: false,
startBidAmount,
finishBidAmount,
},
status: 'OPEN',
closeAt,
},
});
world.updateGeneral(general.id, {
[hostResource]: general[hostResource] - command.amount,
});
return {
type: 'auctionOpen',
ok: true,
auctionId: auction.id,
closeAt: closeAt.toISOString(),
};
};
const openUniqueAuction = async (
command: AuctionOpenCommand,
world: InMemoryTurnWorld,
db: GamePrisma.TransactionClient
): Promise<TurnDaemonCommandResult> => {
const general = world.getGeneralById(command.generalId);
if (!general) {
return fail('장수 정보를 찾을 수 없습니다.');
}
const itemKey = command.itemKey;
if (!itemKey || !isItemKey(itemKey)) {
return fail('아이템이 올바르지 않습니다.');
}
const configConst = asRecord(world.getScenarioConfig().const);
const minimumPoint = readNumber(configConst, 'inheritItemUniqueMinPoint', 5000);
if (command.amount < minimumPoint) {
return fail(`최소 경매 금액은 ${minimumPoint}입니다.`);
}
const item = await new ItemLoader().load(itemKey).catch(() => null);
if (!item) {
return fail('아이템 정보를 불러올 수 없습니다.');
}
if (item.buyable) {
return fail('구매할 수 있는 아이템입니다.');
}
const currentSlotItem = general.role.items[item.slot];
if (currentSlotItem && currentSlotItem !== 'None' && isItemKey(currentSlotItem)) {
const currentItem = await new ItemLoader().load(currentSlotItem).catch(() => null);
if (currentItem && !currentItem.buyable) {
return fail('이미 가진 아이템이 있습니다.');
}
}
await db.$executeRaw(GamePrisma.sql`SELECT pg_advisory_xact_lock(hashtext(${`auction:unique:item:${itemKey}`}))`);
await db.$executeRaw(GamePrisma.sql`SELECT pg_advisory_xact_lock(${command.generalId}, 41002)`);
const [sameItemAuction, previousHostAuction] = await Promise.all([
db.auction.findFirst({
where: {
type: 'UNIQUE_ITEM',
targetCode: itemKey,
status: { in: ['OPEN', 'FINALIZING'] },
},
select: { id: true },
}),
db.auction.findFirst({
where: {
type: 'UNIQUE_ITEM',
hostGeneralId: command.generalId,
status: { in: ['OPEN', 'FINALIZING'] },
},
select: { id: true },
}),
]);
if (sameItemAuction) {
return fail('이미 경매가 진행중입니다.');
}
if (previousHostAuction) {
return fail('아직 경매가 끝나지 않았습니다.');
}
const uniqueConfig = resolveUniqueConfig(configConst);
const configuredAmount = uniqueConfig.allItems[item.slot]?.[itemKey] ?? 0;
const occupiedAmount = world
.listGenerals()
.filter((candidate) => candidate.role.items[item.slot] === itemKey).length;
if (configuredAmount <= occupiedAmount) {
return fail('그 유니크를 더 얻을 수 없습니다.');
}
const generalRows = await db.$queryRaw<Array<{ userId: string | null }>>(
GamePrisma.sql`SELECT user_id as "userId" FROM general WHERE id = ${command.generalId}`
);
const userId = generalRows[0]?.userId;
if (!userId) {
return fail('장수 소유자 정보를 찾을 수 없습니다.');
}
const pointRows = await db.$queryRaw<Array<{ value: number }>>(
GamePrisma.sql`
SELECT value
FROM inheritance_point
WHERE user_id = ${userId} AND key = 'previous'
FOR UPDATE
`
);
const currentPoint = pointRows[0]?.value ?? 0;
if (currentPoint < command.amount) {
return fail('경매를 시작할 포인트가 부족합니다.');
}
const state = world.getState();
const turnMinutes = Math.max(1, Math.round(state.tickSeconds / 60));
const now = new Date();
const closeMinutes = Math.max(MIN_AUCTION_CLOSE_MINUTES, turnMinutes * COEFF_AUCTION_CLOSE_MINUTES);
const closeAt = new Date(now.getTime() + closeMinutes * 60_000);
const extensionLimitMinutes = Math.max(
MIN_EXTENSION_MINUTES_LIMIT_BY_BID,
turnMinutes * COEFF_EXTENSION_MINUTES_LIMIT_BY_BID
);
const availableLatestBidCloseDate = new Date(closeAt.getTime() + extensionLimitMinutes * 60_000);
const hiddenSeed =
typeof state.meta.hiddenSeed === 'string' || typeof state.meta.hiddenSeed === 'number'
? state.meta.hiddenSeed
: state.id;
const alias = buildAuctionAlias(command.generalId, hiddenSeed, configConst);
const eventId = randomUUID();
const auction = await db.auction.create({
data: {
type: 'UNIQUE_ITEM',
targetCode: itemKey,
hostGeneralId: command.generalId,
hostName: alias,
detail: {
title: `${item.name} 경매`,
hostName: alias,
amount: 1,
isReverse: false,
startBidAmount: command.amount,
finishBidAmount: null,
remainCloseDateExtensionCnt: 1,
availableLatestBidCloseDate: availableLatestBidCloseDate.toISOString(),
},
status: 'OPEN',
closeAt,
latestEventId: eventId,
latestEventAt: now,
bids: {
create: {
generalId: command.generalId,
amount: command.amount,
eventId,
eventAt: now,
meta: { obfuscatedName: alias, tryExtendCloseDate: false },
},
},
},
});
await db.inheritancePoint.update({
where: { userId_key: { userId, key: 'previous' } },
data: { value: currentPoint - command.amount },
});
const logger = new ActionLogger();
const rawNameJosa = JosaUtil.pick(item.rawName, '라');
logger.pushGlobalHistoryLog(
`<C><b>【보물수배】</b></>누군가가 <C>${item.name}</>${rawNameJosa}는 보물을 구한다는 소문이 들려옵니다.`,
LogFormat.PLAIN
);
for (const log of logger.flush()) {
world.pushLog(log);
}
return {
type: 'auctionOpen',
ok: true,
auctionId: auction.id,
closeAt: closeAt.toISOString(),
};
};
export const openAuction = async (
command: AuctionOpenCommand,
world: InMemoryTurnWorld,
db?: GamePrisma.TransactionClient
): Promise<TurnDaemonCommandResult> => {
if (!db) {
return fail('경매 등록 트랜잭션이 준비되지 않았습니다.');
}
if (getRelativeMonth(world) < 3) {
return fail('시작 후 3개월이 지나야 경매를 열 수 있습니다.');
}
if (command.auctionType === 'UNIQUE_ITEM') {
return openUniqueAuction(command, world, db);
}
return openResourceAuction(command, world, db);
};
+43 -16
View File
@@ -36,7 +36,7 @@ import { WorldStateView } from './worldStateView.js';
import type { GeneralAIOptions, GeneralAiDebugState } from './types.js';
const ACTION_REST = '휴식';
const lastAttackableByNation = new Map<number, number>();
const lastAttackableByWorld = new WeakMap<object, Map<number, number>>();
const t무장 = 1;
const t지장 = 2;
@@ -129,11 +129,12 @@ export class GeneralAI {
private readonly reservedTurnProvider: AiReservedTurnProvider;
constructor(options: GeneralAIOptions) {
this.general = options.general;
this.general = { ...options.general, meta: { ...options.general.meta } };
this.city = options.city;
this.nation =
const nation =
options.nation ??
(options.general.nationId > 0 ? options.worldRef?.getNationById(options.general.nationId) ?? null : null);
(options.general.nationId > 0 ? (options.worldRef?.getNationById(options.general.nationId) ?? null) : null);
this.nation = nation ? { ...nation, meta: { ...nation.meta } } : nation;
this.world = options.world;
this.worldRef = options.worldRef;
this.map = options.map;
@@ -255,25 +256,39 @@ export class GeneralAI {
return null;
}
const npcMessage = asRecord(this.general.meta).npcmsg;
if (npcMessage && this.rng.nextBool((this.aiConst.npcMessageFreqByDay * this.turnTermMinutes) / (60 * 24))) {
// 메시지 영속화는 turn handler가 담당한다. 여기서는 레거시와 같은 RNG 소비를 보존한다.
}
if (this.general.npcState >= 2) {
this.general.meta = { ...this.general.meta, defence_train: 80 };
}
if (this.general.officerLevel === 12 && this.generalPolicy.can('선양')) {
const abdication = generalActionHandlers['선양']?.(this);
if (abdication) {
return abdication;
}
}
if (this.general.npcState === 5) {
if (this.general.nationId === 0) {
this.general.meta = { ...this.general.meta, killturn: 1 };
return { action: reservedTurn.action, args: reservedTurn.args, reason: '사망' };
}
const result = generalActionHandlers['집합']?.(this);
return result ?? this.buildGeneralCandidate(ACTION_REST, {}, 'npc_troop');
}
if (reservedTurn.action !== ACTION_REST) {
const reservedCandidate = this.buildGeneralCandidate(reservedTurn.action, reservedTurn.args, 'reserved');
if (reservedCandidate) {
return reservedCandidate;
}
return { action: reservedTurn.action, args: reservedTurn.args, reason: 'do예약턴' };
}
if (
readMetaNumber(asRecord(this.general.meta), 'injury', this.general.injury) > this.nationPolicy.cureThreshold
) {
const heal = this.buildGeneralCandidate('che_요양', {}, 'heal');
if (heal) {
return heal;
}
return { action: 'che_요양', args: {}, reason: 'do요양' };
}
if ([2, 3].includes(this.general.npcState) && this.general.nationId === 0) {
@@ -293,7 +308,7 @@ export class GeneralAI {
}
if (this.general.npcState < 2 && this.general.nationId === 0 && !this.generalPolicy.can('국가선택')) {
return this.buildGeneralCandidate(ACTION_REST, {}, 'neutral_user');
return { action: reservedTurn.action, args: reservedTurn.args, reason: '재야유저' };
}
if (this.general.npcState >= 2 && this.general.officerLevel === 12 && !this.nation?.capitalCityId) {
@@ -312,6 +327,12 @@ export class GeneralAI {
if (move) {
return move;
}
if (relYearMonth > 1) {
const disband = generalActionHandlers['해산']?.(this);
if (disband) {
return disband;
}
}
}
for (const actionName of this.generalPolicy.priority) {
@@ -757,11 +778,17 @@ export class GeneralAI {
const declareTerms = warTargets.filter((entry) => entry.state === 1).map((entry) => entry.term);
const minWarTerm = declareTerms.length > 0 ? Math.min(...declareTerms) : null;
let lastAttackable = lastAttackableByNation.get(nationId) ??
readMetaNumber(asRecord(this.nation.meta), 'last_attackable', 0);
let worldLastAttackable = lastAttackableByWorld.get(this.world.meta);
if (!worldLastAttackable) {
worldLastAttackable = new Map();
lastAttackableByWorld.set(this.world.meta, worldLastAttackable);
}
let lastAttackable =
worldLastAttackable.get(nationId) ?? readMetaNumber(asRecord(this.nation.meta), 'last_attackable', 0);
const markAttackable = () => {
lastAttackable = yearMonth;
lastAttackableByNation.set(nationId, yearMonth);
worldLastAttackable.set(nationId, yearMonth);
this.nation!.meta = { ...this.nation!.meta, last_attackable: yearMonth };
};
if (minWarTerm === null) {
@@ -1,7 +1,20 @@
import type { GeneralAI } from '../core.js';
import { valueFit } from '../../aiUtils.js';
import { asRecord, readMetaNumber, valueFit } from '../../aiUtils.js';
import { pickWeightedCandidate, resolveCityTrust, t무장, t지장, t통솔장 } from './helpers.js';
const isTechLimited = (ai: GeneralAI, tech: number): boolean => {
const relativeYear = Math.max(0, ai.world.currentYear - ai.startYear);
const levelIncreaseYears = ai.commandEnv.techLevelIncYear ?? 5;
const initialAllowedLevel = ai.commandEnv.initialAllowedTechLevel ?? 1;
const relativeMaxLevel = valueFit(
Math.floor(relativeYear / levelIncreaseYears) + initialAllowedLevel,
1,
ai.commandEnv.maxTechLevel
);
const currentLevel = valueFit(Math.floor(tech / 1000), 0, ai.commandEnv.maxTechLevel);
return currentLevel >= relativeMaxLevel;
};
export const do일반내정 = (ai: GeneralAI) => {
const city = ai.city;
const nation = ai.nation;
@@ -14,6 +27,7 @@ export const do일반내정 = (ai: GeneralAI) => {
}
const develRate = ai.calcCityDevelRate(city);
const tech = readMetaNumber(asRecord(nation.meta), 'tech', 0);
const isSpringSummer = ai.world.currentMonth <= 6;
const cmdList: Array<[ReturnType<GeneralAI['buildGeneralCandidate']>, number]> = [];
@@ -64,7 +78,13 @@ export const do일반내정 = (ai: GeneralAI) => {
}
if (ai.genType & t지장) {
cmdList.push([ai.buildGeneralCandidate('che_기술연구', {}, '일반내정'), ai.general.stats.intelligence]);
if (!isTechLimited(ai, tech)) {
const nextTech = (tech % 1000) + 1;
const weight = !isTechLimited(ai, tech + 1000)
? ai.general.stats.intelligence / (nextTech / 2000)
: ai.general.stats.intelligence;
cmdList.push([ai.buildGeneralCandidate('che_기술연구', {}, '일반내정'), weight]);
}
if (develRate.agri[0] < 1) {
cmdList.push([
ai.buildGeneralCandidate('che_농지개간', {}, '일반내정'),
@@ -119,6 +139,7 @@ export const do전쟁내정 = (ai: GeneralAI) => {
return null;
}
const develRate = ai.calcCityDevelRate(city);
const tech = readMetaNumber(asRecord(nation.meta), 'tech', 0);
const isSpringSummer = ai.world.currentMonth <= 6;
const cmdList: Array<[ReturnType<GeneralAI['buildGeneralCandidate']>, number]> = [];
@@ -130,10 +151,9 @@ export const do전쟁내정 = (ai: GeneralAI) => {
]);
}
if (develRate.pop[0] < 0.8) {
const weight =
city.frontState > 0
? ai.general.stats.leadership / valueFit(develRate.pop[0], 0.001)
: ai.general.stats.leadership / valueFit(develRate.pop[0], 0.001) / 2;
const weight = [1, 3].includes(city.frontState)
? ai.general.stats.leadership / valueFit(develRate.pop[0], 0.001)
: ai.general.stats.leadership / valueFit(develRate.pop[0], 0.001) / 2;
cmdList.push([ai.buildGeneralCandidate('che_정착장려', {}, '전쟁내정'), weight]);
}
}
@@ -160,27 +180,31 @@ export const do전쟁내정 = (ai: GeneralAI) => {
}
if (ai.genType & t지장) {
cmdList.push([ai.buildGeneralCandidate('che_기술연구', {}, '전쟁내정'), ai.general.stats.intelligence]);
if (!isTechLimited(ai, tech)) {
const nextTech = (tech % 1000) + 1;
const weight = !isTechLimited(ai, tech + 1000)
? ai.general.stats.intelligence / (nextTech / 3000)
: ai.general.stats.intelligence;
cmdList.push([ai.buildGeneralCandidate('che_기술연구', {}, '전쟁내정'), weight]);
}
if (develRate.agri[0] < 0.5) {
const weight =
city.frontState > 0
? ((isSpringSummer ? 1.2 : 0.8) * ai.general.stats.intelligence) /
4 /
valueFit(develRate.agri[0], 0.001, 1)
: ((isSpringSummer ? 1.2 : 0.8) * ai.general.stats.intelligence) /
2 /
valueFit(develRate.agri[0], 0.001, 1);
const weight = [1, 3].includes(city.frontState)
? ((isSpringSummer ? 1.2 : 0.8) * ai.general.stats.intelligence) /
4 /
valueFit(develRate.agri[0], 0.001, 1)
: ((isSpringSummer ? 1.2 : 0.8) * ai.general.stats.intelligence) /
2 /
valueFit(develRate.agri[0], 0.001, 1);
cmdList.push([ai.buildGeneralCandidate('che_농지개간', {}, '전쟁내정'), weight]);
}
if (develRate.comm[0] < 0.5) {
const weight =
city.frontState > 0
? ((isSpringSummer ? 0.8 : 1.2) * ai.general.stats.intelligence) /
4 /
valueFit(develRate.comm[0], 0.001, 1)
: ((isSpringSummer ? 0.8 : 1.2) * ai.general.stats.intelligence) /
2 /
valueFit(develRate.comm[0], 0.001, 1);
const weight = [1, 3].includes(city.frontState)
? ((isSpringSummer ? 0.8 : 1.2) * ai.general.stats.intelligence) /
4 /
valueFit(develRate.comm[0], 0.001, 1)
: ((isSpringSummer ? 0.8 : 1.2) * ai.general.stats.intelligence) /
2 /
valueFit(develRate.comm[0], 0.001, 1);
cmdList.push([ai.buildGeneralCandidate('che_상업투자', {}, '전쟁내정'), weight]);
}
}
@@ -5,7 +5,7 @@ import { do징병 } from './recruitActions.js';
import { do전투준비, do소집해제, do출병 } from './warActions.js';
import { do후방워프, do전방워프, do내정워프, do귀환, do집합 } from './warpActions.js';
import { doNPC헌납, doNPC사망대비 } from './npcActions.js';
import { do국가선택, do중립, do거병, do건국, do방랑군이동 } from './politicsActions.js';
import { do국가선택, do중립, do거병, do건국, do해산, do선양, do방랑군이동 } from './politicsActions.js';
export {
do일반내정,
@@ -27,6 +27,8 @@ export {
do중립,
do거병,
do건국,
do해산,
do선양,
do방랑군이동,
};
@@ -53,5 +55,7 @@ export const generalActionHandlers: Record<
집합: do집합,
거병: do거병,
건국: do건국,
해산: do해산,
선양: do선양,
방랑군이동: do방랑군이동,
};
@@ -18,6 +18,27 @@ export const do국가선택 = (ai: GeneralAI) => {
}
if (ai.rng.nextBool(0.3)) {
const affinity = ai.general.affinity ?? readMetaNumber(asRecord(ai.general.meta), 'affinity', 0);
if (affinity === 999) {
return null;
}
if (ai.world.currentYear < ai.startYear + 3) {
const nations = ai.worldRef.listNations();
const nationCount = nations.length;
const notFullNationCount = nations.filter((nation) => {
const count = ai.worldRef!.listGenerals().filter((general) => general.nationId === nation.id).length;
return count < ai.commandEnv.initialNationGenLimit;
}).length;
if (nationCount === 0 || notFullNationCount === 0) {
return null;
}
const rejectProbability = Math.pow(1 / (nationCount + 1) / Math.pow(notFullNationCount, 3), 1 / 4);
if (ai.rng.nextBool(rejectProbability)) {
return null;
}
} else if (ai.rng.nextBool()) {
return null;
}
return ai.buildGeneralCandidate('che_랜덤임관', {}, '국가선택');
}
@@ -47,13 +68,15 @@ export const do중립 = (ai: GeneralAI) => {
candidates = ['che_물자조달'];
}
for (const key of candidates) {
const cmd = ai.buildGeneralCandidate(key, {}, '중립');
if (cmd) {
return cmd;
}
const picked = ai.buildGeneralCandidate(ai.rng.choice(candidates), {}, '중립');
if (picked) {
return picked;
}
return ai.buildGeneralCandidate(ACTION_REST, {}, '중립');
const supply = ai.buildGeneralCandidate('che_물자조달', {}, '중립');
if (supply) {
return supply;
}
return ai.buildGeneralCandidate('che_견문', {}, '중립') ?? ai.buildGeneralCandidate(ACTION_REST, {}, '중립');
};
export const do거병 = (ai: GeneralAI) => {
@@ -111,13 +134,18 @@ export const do거병 = (ai: GeneralAI) => {
}
const prop = (ai.rng.nextFloat1() * (ai.aiConst.defaultStatNpcMax + ai.aiConst.chiefStatMin)) / 2;
const ratio = (ai.general.stats.leadership + ai.general.stats.strength + ai.general.stats.intelligence) / 3;
const generalMeta = asRecord(ai.general.meta);
const ratio =
(readMetaNumber(generalMeta, 'fullLeadership', ai.general.stats.leadership) +
readMetaNumber(generalMeta, 'fullStrength', ai.general.stats.strength) +
readMetaNumber(generalMeta, 'fullIntelligence', ai.general.stats.intelligence)) /
3;
if (prop >= ratio) {
return null;
}
const relYear = Math.max(0, ai.world.currentYear - ai.startYear);
const more = valueFit(3 - relYear, 1, 3);
const initYear = readMetaNumber(asRecord(ai.world.meta), 'initYear', ai.startYear);
const more = valueFit(3 - ai.world.currentYear + initYear, 1, 3);
if (!ai.rng.nextBool(0.0075 * more)) {
return null;
}
@@ -132,10 +160,39 @@ export const do건국 = (ai: GeneralAI) => {
ai.aiConst.availableNationTypes.length > 0
? (ai.rng.choice(ai.aiConst.availableNationTypes) as string)
: `${prefix}def`;
const colorType = ai.rng.nextRangeInt(0, 34);
const nationName = ai.general.name;
const colorType = ai.rng.nextRangeInt(0, 32);
const nationName = `${Array.from(ai.general.name).slice(1).join('')}`;
return ai.buildGeneralCandidate('che_건국', { nationName, nationType, colorType }, '건국');
const result = ai.buildGeneralCandidate('che_건국', { nationName, nationType, colorType }, '건국');
if (result) {
const nextMeta = { ...ai.general.meta };
delete nextMeta.movingTargetCityID;
ai.general.meta = nextMeta;
}
return result;
};
export const do해산 = (ai: GeneralAI) => {
const result = ai.buildGeneralCandidate('che_해산', {}, '해산');
if (result) {
const nextMeta = { ...ai.general.meta };
delete nextMeta.movingTargetCityID;
ai.general.meta = nextMeta;
}
return result;
};
export const do선양 = (ai: GeneralAI) => {
if (!ai.worldRef) {
return null;
}
const candidates = ai.worldRef
.listGenerals()
.filter((general) => general.nationId === ai.general.nationId && general.npcState !== 5);
if (candidates.length === 0) {
return null;
}
return ai.buildGeneralCandidate('che_선양', { destGeneralID: ai.rng.choice(candidates).id }, '선양');
};
export const do방랑군이동 = (ai: GeneralAI) => {
@@ -143,37 +200,76 @@ export const do방랑군이동 = (ai: GeneralAI) => {
if (!city || !ai.map || !ai.worldRef) {
return null;
}
const lordCities = ai.worldRef
.listGenerals()
.filter((general) => general.officerLevel === 12 && general.nationId === 0)
.map((general) => general.cityId);
if (lordCities.filter((cityId) => cityId === city.id).length <= 1 && [5, 6].includes(city.level)) {
return null;
}
const occupied = new Set(
ai.worldRef
.listCities()
.filter((c) => c.nationId !== 0)
.map((c) => c.id)
.filter((candidate) => candidate.nationId !== 0)
.map((candidate) => candidate.id)
);
for (const general of ai.worldRef.listGenerals()) {
if (general.officerLevel === 12 && general.nationId === 0) {
occupied.add(general.cityId);
}
for (const cityId of lordCities) {
occupied.add(cityId);
}
const nearby = searchDistance(ai.map, city.id, 4);
const candidates: Array<[number, number]> = [];
for (const [cityIdRaw, dist] of Object.entries(nearby)) {
const cityId = Number(cityIdRaw);
if (!Number.isFinite(cityId) || occupied.has(cityId)) {
continue;
}
const target = ai.worldRef.getCityById(cityId);
if (!target || target.level < 5 || target.level > 6) {
continue;
}
candidates.push([cityId, 1 / Math.pow(2, dist)]);
let movingTargetCityId = readMetaNumber(asRecord(ai.general.meta), 'movingTargetCityID', 0) || null;
if (movingTargetCityId === city.id || (movingTargetCityId !== null && occupied.has(movingTargetCityId))) {
movingTargetCityId = null;
}
if (candidates.length === 0) {
return null;
if (movingTargetCityId === null) {
const nearby = searchDistance(ai.map, city.id, 4);
const candidates: Array<[number, number]> = [];
for (const [cityIdRaw, dist] of Object.entries(nearby)) {
const cityId = Number(cityIdRaw);
if (!Number.isFinite(cityId) || occupied.has(cityId)) {
continue;
}
const target = ai.worldRef.getCityById(cityId);
if (!target || target.level < 5 || target.level > 6) {
continue;
}
candidates.push([cityId, 1 / Math.pow(2, dist)]);
}
if (candidates.length === 0) {
return null;
}
movingTargetCityId = ai.rng.choiceUsingWeightPair(candidates);
ai.general.meta = { ...ai.general.meta, movingTargetCityID: movingTargetCityId };
}
const destCityId = ai.rng.choiceUsingWeightPair(candidates);
if (destCityId === city.id) {
if (movingTargetCityId === city.id) {
return ai.buildGeneralCandidate('che_인재탐색', {}, '방랑군이동');
}
return ai.buildGeneralCandidate('che_이동', { destCityId }, '방랑군이동');
const distanceMap = searchDistance(ai.map, movingTargetCityId, 99);
const targetDistance = distanceMap[city.id];
if (targetDistance === undefined) {
return null;
}
const neighbors = ai.map.cities.find((candidate) => candidate.id === city.id)?.connections ?? [];
const nextCandidates: Array<[number, number]> = [];
for (const nextCityId of neighbors) {
const nextCity = ai.worldRef.getCityById(nextCityId);
if (nextCity && [5, 6].includes(nextCity.level) && !occupied.has(nextCityId)) {
nextCandidates.push([nextCityId, 10]);
}
if (distanceMap[nextCityId] !== undefined && distanceMap[nextCityId] + 1 === targetDistance) {
nextCandidates.push([nextCityId, 1]);
}
}
if (nextCandidates.length === 0) {
return null;
}
return ai.buildGeneralCandidate(
'che_이동',
{ destCityId: ai.rng.choiceUsingWeightPair(nextCandidates) },
'방랑군이동'
);
};
@@ -9,7 +9,7 @@ import type { CrewTypeDefinition, General, WarArmTypes } from '@sammo-ts/logic';
import type { GeneralAI } from '../core.js';
import { asRecord, readMetaNumber, roundTo } from '../../aiUtils.js';
import { t통솔장 } from './helpers.js';
import { t무장, t지장, t통솔장 } from './helpers.js';
export const buildRecruitArmTypeWeights = (general: General, armTypes: WarArmTypes): Array<[number, number]> => {
const meta = asRecord(general.meta);
@@ -45,7 +45,7 @@ export const do징병 = (ai: GeneralAI) => {
if (!city || !nation || !ai.unitSet || !ai.map) {
return null;
}
if ([0, 1].includes(ai.dipState) && ai.general.npcState < 2) {
if ([0, 1].includes(ai.dipState)) {
return null;
}
if (!(ai.genType & t통솔장)) {
@@ -55,9 +55,10 @@ export const do징병 = (ai: GeneralAI) => {
return null;
}
const generalMeta = asRecord(ai.general.meta);
const fullLeadership = readMetaNumber(generalMeta, 'fullLeadership', ai.general.stats.leadership);
if (!ai.generalPolicy.can('한계징병')) {
const remainPop =
city.population - ai.nationPolicy.minNpcRecruitCityPopulation - ai.general.stats.leadership * 100;
const remainPop = city.population - ai.nationPolicy.minNpcRecruitCityPopulation - fullLeadership * 100;
if (remainPop <= 0) {
return null;
}
@@ -71,9 +72,16 @@ export const do징병 = (ai: GeneralAI) => {
}
const tech = readMetaNumber(asRecord(nation.meta), 'tech', 0);
const crewAmountBase = ai.general.stats.leadership * 100;
const crewAmountBase = fullLeadership * 100;
const warConfig = buildWarConfig(ai.scenarioConfig, ai.unitSet);
const forcedArmType = readMetaNumber(asRecord(ai.general.meta), 'armType', 0);
let forcedArmType = readMetaNumber(asRecord(ai.general.meta), 'armType', 0);
if (
(forcedArmType === warConfig.armTypes.wizard && !(ai.genType & t지장)) ||
([warConfig.armTypes.footman, warConfig.armTypes.archer, warConfig.armTypes.cavalry].includes(forcedArmType) &&
!(ai.genType & t무장))
) {
forcedArmType = 0;
}
const armType =
forcedArmType > 0
? forcedArmType
@@ -123,25 +131,31 @@ export const do징병 = (ai: GeneralAI) => {
let crewAmount = crewAmountBase;
const goldCost = (picked.cost * getTechCost(tech) * crewAmount) / 100;
const riceCost = crewAmount / 100;
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;
if (ai.general.gold <= 0 || ai.general.rice <= 0) {
const remainingGold = ai.general.gold - fullLeadership * 3;
const remainingRice = ai.general.rice - fullLeadership * 4;
if (remainingGold <= 0 || remainingRice <= 0) {
return null;
}
if (ai.generalPolicy.can('모병') && ai.general.gold >= goldCost * 6) {
if (ai.generalPolicy.can('모병') && remainingGold >= goldCost * 6) {
const hire = ai.buildGeneralCandidate('che_모병', { crewType: crewTypeId, amount: crewAmount }, '징병');
if (hire) {
return hire;
}
}
if (ai.general.gold < goldCost && ai.general.gold * 2 >= goldCost) {
if (remainingGold < goldCost && remainingGold * 2 >= goldCost) {
crewAmount *= 0.5;
riceCost *= 0.5;
crewAmount = roundTo(crewAmount - 49, -2);
}
if (!ai.generalPolicy.can('한계징병') && ai.general.rice * 1.1 <= riceCost) {
if (!ai.generalPolicy.can('한계징병') && remainingRice * 1.1 <= riceCost) {
return null;
}
@@ -3,7 +3,7 @@ import { valueFit } from '../../aiUtils.js';
import { pickWeightedCandidate } from './helpers.js';
export const do전투준비 = (ai: GeneralAI) => {
if ([0, 1].includes(ai.dipState) && ai.general.crew <= 0) {
if ([0, 1].includes(ai.dipState)) {
return null;
}
const cmdList: Array<[ReturnType<GeneralAI['buildGeneralCandidate']>, number]> = [];
@@ -1,4 +1,5 @@
import type { GeneralAI } from '../core.js';
import { asRecord, readRequiredMetaNumber } from '../../aiUtils.js';
import { t통솔장 } from './helpers.js';
export const do후방워프 = (ai: GeneralAI) => {
@@ -9,6 +10,9 @@ export const do후방워프 = (ai: GeneralAI) => {
if ([0, 1].includes(ai.dipState)) {
return null;
}
if (!ai.generalPolicy.can('징병')) {
return null;
}
if (!(ai.genType & t통솔장)) {
return null;
}
@@ -104,6 +108,7 @@ export const do전방워프 = (ai: GeneralAI) => {
}
ai.categorizeNationCities();
ai.categorizeNationGeneral();
const candidateCities: Record<number, number> = {};
for (const frontCity of Object.values(ai.frontCities)) {
if (frontCity.supplyState <= 0) {
@@ -195,4 +200,11 @@ export const do귀환 = (ai: GeneralAI) => {
return ai.buildGeneralCandidate('che_귀환', {}, '귀환');
};
export const do집합 = (ai: GeneralAI) => ai.buildGeneralCandidate('che_집합', {}, '집합');
export const do집합 = (ai: GeneralAI) => {
if (ai.general.npcState === 5) {
const killturn = readRequiredMetaNumber(asRecord(ai.general.meta), 'killturn', `generalId=${ai.general.id}`);
const nextKillturn = ((killturn + ai.rng.nextRangeInt(2, 4)) % 5) + 70;
ai.general.meta = { ...ai.general.meta, killturn: nextKillturn };
}
return ai.buildGeneralCandidate('che_집합', {}, '집합');
};
@@ -14,7 +14,25 @@ export const do천도 = (ai: GeneralAI) => {
return null;
}
const cityIds = nationCities.map((city) => city.id);
const nationCityIds = new Set(nationCities.map((city) => city.id));
const connectedCityIds = new Set<number>([ai.nation.capitalCityId]);
const queue = [ai.nation.capitalCityId];
while (queue.length > 0) {
const cityId = queue.shift()!;
const connections = ai.map.cities.find((city) => city.id === cityId)?.connections ?? [];
for (const nextCityId of connections) {
if (!nationCityIds.has(nextCityId) || connectedCityIds.has(nextCityId)) {
continue;
}
connectedCityIds.add(nextCityId);
queue.push(nextCityId);
}
}
if (connectedCityIds.size <= 1) {
return null;
}
const cityIds = Array.from(connectedCityIds);
const distanceList = searchAllDistanceByCityList(ai.map, cityIds);
const capitalId = ai.nation.capitalCityId;
if (!distanceList[capitalId]) {
@@ -28,7 +46,7 @@ export const do천도 = (ai: GeneralAI) => {
}
const cityScores: Record<number, number> = {};
for (const city of nationCities) {
for (const city of nationCities.filter((candidate) => connectedCityIds.has(candidate.id))) {
const sumDistance = Object.values(distanceList[city.id] ?? {}).reduce((acc, value) => acc + value, 0);
if (sumDistance <= 0) {
continue;
@@ -39,7 +57,7 @@ export const do천도 = (ai: GeneralAI) => {
const sorted = Object.entries(cityScores).sort((a, b) => b[1] - a[1]);
const topLimit = Math.ceil(sorted.length * 0.25);
for (let idx = 0; idx < Math.min(topLimit, sorted.length); idx += 1) {
for (let idx = 0; idx <= Math.min(topLimit, sorted.length - 1); idx += 1) {
if (Number(sorted[idx][0]) === capitalId) {
return null;
}
@@ -62,5 +80,5 @@ export const do천도 = (ai: GeneralAI) => {
}
}
return ai.buildNationCandidate('che_천도', { destCityId: targetCityId }, '천도');
return ai.buildNationCandidate('che_천도', { destCityID: targetCityId }, '천도');
};
@@ -3,6 +3,18 @@ import { asRecord, joinYearMonth, parseYearMonth, readMetaNumber } from '../../a
import { isNeighbor } from '../../distance.js';
import { resolveNationIncome } from './helpers.js';
const isTechLimited = (ai: GeneralAI, tech: number): boolean => {
const relativeYear = Math.max(0, ai.world.currentYear - ai.startYear);
const levelIncreaseYears = ai.commandEnv.techLevelIncYear ?? 5;
const initialAllowedLevel = ai.commandEnv.initialAllowedTechLevel ?? 1;
const relativeMaxLevel = Math.max(
1,
Math.min(Math.floor(relativeYear / levelIncreaseYears) + initialAllowedLevel, ai.commandEnv.maxTechLevel)
);
const techLevel = Math.max(0, Math.min(Math.floor(tech / 1000), ai.commandEnv.maxTechLevel));
return techLevel >= relativeMaxLevel;
};
export const do불가침제의 = (ai: GeneralAI) => {
if (!ai.nation || ai.general.officerLevel < 12) {
return null;
@@ -66,11 +78,16 @@ export const do불가침제의 = (ai: GeneralAI) => {
}
const [targetYear, targetMonth] = parseYearMonth(Math.floor(yearMonth + diplomatMonth));
return ai.buildNationCandidate(
const result = ai.buildNationCandidate(
'che_불가침제의',
{ destNationId, year: targetYear, month: targetMonth },
'불가침제의'
);
if (result) {
const nextTry = { ...respAssistTry, [`n${destNationId}`]: [destNationId, yearMonth] };
asRecord(ai.nation.meta).resp_assist_try = nextTry;
}
return result;
};
export const do선전포고 = (ai: GeneralAI) => {
@@ -92,6 +109,10 @@ export const do선전포고 = (ai: GeneralAI) => {
if (!ai.map || !ai.worldRef) {
return null;
}
const currentTech = readMetaNumber(asRecord(ai.nation.meta), 'tech', 0);
if (!isTechLimited(ai, currentTech + 1000)) {
return null;
}
const avgResources = Object.values({
...ai.npcWarGenerals,
@@ -134,9 +155,26 @@ export const do선전포고 = (ai: GeneralAI) => {
return null;
}
const lowTargetNations = new Set(
ai.worldRef
.listDiplomacy()
.filter((entry) => entry.fromNationId !== currentNationId && (entry.state === 0 || entry.state === 1))
.map((entry) => entry.fromNationId)
);
const weight: Record<number, number> = {};
const warWeight: Record<number, number> = {};
for (const nation of neighbors) {
weight[nation.id] = 1 / Math.sqrt(nation.power + 1);
const target = lowTargetNations.has(nation.id) ? warWeight : weight;
target[nation.id] = 1 / Math.sqrt(nation.power + 1);
}
if (Object.keys(weight).length === 0) {
if (Object.keys(warWeight).length === 0 || lowTargetNations.size === 0) {
return null;
}
if (ai.rng.nextBool(1 / lowTargetNations.size)) {
return null;
}
Object.assign(weight, warWeight);
}
const destNationId = Number(ai.rng.choiceUsingWeight(weight));
@@ -3,7 +3,10 @@ import type { City } from '@sammo-ts/logic';
import type { GeneralAI } from '../core.js';
import { asRecord, readMetaNumber } from '../../aiUtils.js';
export const pickWeightedCandidate = (ai: GeneralAI, list: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]>) => {
export const pickWeightedCandidate = (
ai: GeneralAI,
list: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]>
) => {
const items = list.filter(([item]) => Boolean(item)) as Array<
[ReturnType<GeneralAI['buildNationCandidate']>, number]
>;
@@ -59,23 +62,21 @@ export const selectRecruitableCity = (ai: GeneralAI, minPop: number): Record<num
export const buildAssignmentCandidate = (ai: GeneralAI, destGeneralId: number, destCityId: number, reason: string) =>
ai.buildNationCandidate('che_발령', { destGeneralId, destCityId }, reason);
export const buildSeizureCandidate = (ai: GeneralAI, destGeneralId: number, amount: number, isGold: boolean, reason: string) =>
ai.buildNationCandidate('che_몰수', { destGeneralID: destGeneralId, amount, isGold }, reason);
export const buildSeizureCandidate = (
ai: GeneralAI,
destGeneralId: number,
amount: number,
isGold: boolean,
reason: string
) => ai.buildNationCandidate('che_몰수', { destGeneralID: destGeneralId, amount, isGold }, reason);
export const buildAwardCandidate = (ai: GeneralAI, destGeneralId: number, amount: number, isGold: boolean, reason: string) =>
ai.buildNationCandidate('che_포상', { destGeneralId, amount, isGold }, reason);
export const resolveAwardAmount = (ai: GeneralAI, current: number, target: number): number | null => {
const diff = target - current;
if (diff <= 0) {
return null;
}
const amount = Math.min(diff, ai.maxResourceActionAmount);
if (amount < ai.nationPolicy.minimumResourceActionAmount) {
return null;
}
return amount;
};
export const buildAwardCandidate = (
ai: GeneralAI,
destGeneralId: number,
amount: number,
isGold: boolean,
reason: string
) => ai.buildNationCandidate('che_포상', { destGeneralId, amount, isGold }, reason);
export const resolveNationIncome = (ai: GeneralAI): number => {
const cities = Object.values(ai.supplyCities);
@@ -1,6 +1,34 @@
import type { GeneralAI } from '../core.js';
import { asRecord, readRequiredMetaNumber } from '../../aiUtils.js';
import { buildAwardCandidate, buildSeizureCandidate, pickWeightedCandidate, resolveAwardAmount } from './helpers.js';
import { findCrewTypeById, getTechCost } from '@sammo-ts/logic/world/unitSet.js';
import type { TurnGeneral } from '../../../types.js';
import { asRecord, readMetaNumber, readRequiredMetaNumber } from '../../aiUtils.js';
import { buildAwardCandidate, buildSeizureCandidate, pickWeightedCandidate } from './helpers.js';
type ResourceName = 'gold' | 'rice';
const clampLegacy = (value: number, min: number | null, max: number | null): number => {
if (min !== null && max !== null && max < min) {
return min;
}
return Math.max(min ?? -Infinity, Math.min(max ?? Infinity, value));
};
const getFullLeadership = (general: TurnGeneral): number =>
readMetaNumber(asRecord(general.meta), 'fullLeadership', general.stats.leadership);
const getCrewGoldCost = (ai: GeneralAI, general: TurnGeneral, multiplier: number): number => {
const crewType = findCrewTypeById(ai.unitSet, general.crewTypeId ?? ai.commandEnv.defaultCrewTypeId);
const tech = readMetaNumber(asRecord(ai.nation?.meta), 'tech', 0);
return (crewType?.cost ?? 0) * getTechCost(tech) * getFullLeadership(general) * multiplier;
};
const sortedByResource = (generals: Record<number, TurnGeneral>, resource: ResourceName, descending = false) =>
Object.values(generals).sort((lhs, rhs) =>
descending ? rhs[resource] - lhs[resource] : lhs[resource] - rhs[resource]
);
const canUseGeneral = (general: TurnGeneral): boolean =>
readRequiredMetaNumber(asRecord(general.meta), 'killturn', `generalId=${general.id}`) > 5;
export const do유저장긴급포상 = (ai: GeneralAI) => {
const nation = ai.nation;
@@ -8,24 +36,38 @@ export const do유저장긴급포상 = (ai: GeneralAI) => {
return null;
}
const candidates: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]> = [];
const resourceMap: Array<['gold' | 'rice', number]> = [
const resourceMap: Array<[ResourceName, number]> = [
['gold', ai.nationPolicy.reqHumanWarUrgentGold],
['rice', ai.nationPolicy.reqHumanWarUrgentRice],
];
for (const [resKey, required] of resourceMap) {
if (nation[resKey] < ai.nationPolicy.reqNationGold && resKey === 'gold') {
continue;
}
if (nation[resKey] < ai.nationPolicy.reqNationRice && resKey === 'rice') {
continue;
}
for (const general of Object.values(ai.userWarGenerals)) {
const amount = resolveAwardAmount(ai, general[resKey], required);
if (!amount) {
for (const [resKey, minimum] of resourceMap) {
const generals = sortedByResource(ai.userWarGenerals, resKey);
for (const [index, general] of generals.entries()) {
if (general[resKey] >= minimum) {
break;
}
if (!canUseGeneral(general)) {
continue;
}
candidates.push([buildAwardCandidate(ai, general.id, amount, resKey === 'gold', '유저장긴급포상'), amount]);
let required = getCrewGoldCost(ai, general, 3 * 1.1);
if (ai.world.currentYear > ai.startYear + 3) {
required = Math.max(required, minimum);
}
const enough = required * 1.1;
if (general[resKey] >= required) {
continue;
}
let amount = Math.sqrt((enough - general[resKey]) * nation[resKey]);
amount = clampLegacy(amount, null, enough - general[resKey]);
if (amount < ai.nationPolicy.minimumResourceActionAmount || nation[resKey] < amount / 2) {
continue;
}
amount = clampLegacy(amount, 100, ai.maxResourceActionAmount);
candidates.push([
buildAwardCandidate(ai, general.id, amount, resKey === 'gold', '유저장긴급포상'),
generals.length - index,
]);
}
}
@@ -38,24 +80,56 @@ export const do유저장포상 = (ai: GeneralAI) => {
return null;
}
const candidates: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]> = [];
const resourceMap: Array<['gold' | 'rice', number]> = [
['gold', ai.nationPolicy.reqHumanWarRecommandGold],
['rice', ai.nationPolicy.reqHumanWarRecommandRice],
const resourceMap: Array<[ResourceName, number, number, number]> = [
[
'gold',
ai.nationPolicy.reqNationGold,
ai.nationPolicy.reqHumanWarRecommandGold,
ai.nationPolicy.reqHumanDevelGold,
],
[
'rice',
ai.nationPolicy.reqNationRice,
ai.nationPolicy.reqHumanWarRecommandRice,
ai.nationPolicy.reqHumanDevelRice,
],
];
for (const [resKey, required] of resourceMap) {
if (nation[resKey] < ai.nationPolicy.reqNationGold && resKey === 'gold') {
for (const [resKey, nationMinimum, warMinimum, civilMinimum] of resourceMap) {
if (nation[resKey] < nationMinimum) {
continue;
}
if (nation[resKey] < ai.nationPolicy.reqNationRice && resKey === 'rice') {
continue;
}
for (const general of Object.values(ai.userWarGenerals)) {
const amount = resolveAwardAmount(ai, general[resKey], required);
if (!amount) {
const generals = sortedByResource(ai.userGenerals, resKey);
for (const [index, general] of generals.entries()) {
if (general[resKey] >= warMinimum) {
break;
}
if (!canUseGeneral(general)) {
continue;
}
candidates.push([buildAwardCandidate(ai, general.id, amount, resKey === 'gold', '유저장포상'), amount]);
let enough: number;
if (ai.userWarGenerals[general.id]) {
let required = getCrewGoldCost(ai, general, 6 * 1.1);
if (ai.world.currentYear > ai.startYear + 3) {
required = Math.max(required, warMinimum);
}
enough = required * 1.2;
} else {
enough = civilMinimum * 1.2;
}
if (general[resKey] >= enough) {
continue;
}
let amount = Math.sqrt((enough - general[resKey]) * nation[resKey]);
amount = clampLegacy(amount, nation[resKey] - nationMinimum, enough - general[resKey]);
if (amount < ai.nationPolicy.minimumResourceActionAmount || nation[resKey] < amount / 2) {
continue;
}
amount = clampLegacy(amount, 100, ai.maxResourceActionAmount);
candidates.push([
buildAwardCandidate(ai, general.id, amount, resKey === 'gold', '유저장포상'),
generals.length - index,
]);
}
}
@@ -68,28 +142,41 @@ export const doNPC긴급포상 = (ai: GeneralAI) => {
return null;
}
const candidates: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]> = [];
const resourceMap: Array<['gold' | 'rice', number]> = [
['gold', ai.nationPolicy.reqNpcWarGold / 2],
['rice', ai.nationPolicy.reqNpcWarRice / 2],
const resourceMap: Array<[ResourceName, number, number]> = [
['gold', ai.nationPolicy.reqNationGold, ai.nationPolicy.reqNpcWarGold / 2],
['rice', ai.nationPolicy.reqNationRice, ai.nationPolicy.reqNpcWarRice / 2],
];
for (const [resKey, required] of resourceMap) {
if (nation[resKey] < ai.nationPolicy.reqNationGold && resKey === 'gold') {
for (const [resKey, nationMinimum, minimum] of resourceMap) {
if (nation[resKey] < nationMinimum) {
continue;
}
if (nation[resKey] < ai.nationPolicy.reqNationRice && resKey === 'rice') {
continue;
}
for (const general of Object.values(ai.npcWarGenerals)) {
const killturn = readRequiredMetaNumber(asRecord(general.meta), 'killturn', `generalId=${general.id}`);
if (killturn <= 5) {
const generals = sortedByResource(ai.npcWarGenerals, resKey);
for (const [index, general] of generals.entries()) {
if (general[resKey] >= minimum) {
break;
}
if (!canUseGeneral(general)) {
continue;
}
const amount = resolveAwardAmount(ai, general[resKey], required);
if (!amount) {
let required = getCrewGoldCost(ai, general, 1.5);
if (ai.world.currentYear > ai.startYear + 5) {
required = Math.max(required, minimum);
}
const enough = required * 1.2;
if (general[resKey] >= required) {
continue;
}
candidates.push([buildAwardCandidate(ai, general.id, amount, resKey === 'gold', 'NPC긴급포상'), amount]);
let amount = Math.sqrt((enough - general[resKey]) * nation[resKey]);
amount = clampLegacy(amount, nation[resKey] - nationMinimum * 0.9, enough - general[resKey]);
if (amount < ai.nationPolicy.minimumResourceActionAmount || nation[resKey] < amount / 2) {
continue;
}
amount = clampLegacy(amount, 100, ai.maxResourceActionAmount);
candidates.push([
buildAwardCandidate(ai, general.id, amount, resKey === 'gold', 'NPC긴급포상'),
generals.length - index,
]);
}
}
@@ -102,39 +189,60 @@ export const doNPC포상 = (ai: GeneralAI) => {
return null;
}
const candidates: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]> = [];
const resourceMap: Array<['gold' | 'rice', number, number]> = [
['gold', ai.nationPolicy.reqNpcWarGold, ai.nationPolicy.reqNpcDevelGold],
['rice', ai.nationPolicy.reqNpcWarRice, ai.nationPolicy.reqNpcDevelRice],
const resourceMap: Array<[ResourceName, number, number, number]> = [
['gold', ai.nationPolicy.reqNationGold, ai.nationPolicy.reqNpcWarGold, ai.nationPolicy.reqNpcDevelGold],
['rice', ai.nationPolicy.reqNationRice, ai.nationPolicy.reqNpcWarRice, ai.nationPolicy.reqNpcDevelRice],
];
for (const [resKey, warReq, devReq] of resourceMap) {
if (nation[resKey] < ai.nationPolicy.reqNationGold && resKey === 'gold') {
for (const [resKey, nationMinimum, warMinimum, civilMinimum] of resourceMap) {
if (nation[resKey] < nationMinimum) {
continue;
}
if (nation[resKey] < ai.nationPolicy.reqNationRice && resKey === 'rice') {
continue;
const warGenerals = sortedByResource(ai.npcWarGenerals, resKey);
const civilGenerals = sortedByResource(ai.npcCivilGenerals, resKey);
const weightBase = Math.max(warGenerals.length, civilGenerals.length);
for (const [index, general] of warGenerals.entries()) {
if (general[resKey] >= warMinimum) {
break;
}
if (!canUseGeneral(general)) {
continue;
}
let required = getCrewGoldCost(ai, general, 3 * 1.1);
if (ai.world.currentYear > ai.startYear + 5) {
required = Math.max(required, warMinimum);
}
const enough = required * 1.5;
if (general[resKey] >= required) {
continue;
}
let amount = Math.sqrt((enough - general[resKey]) * nation[resKey]);
amount = clampLegacy(amount, nation[resKey] - nationMinimum, enough - general[resKey]);
if (nation[resKey] < amount / 2) {
continue;
}
amount = clampLegacy(amount, 100, ai.maxResourceActionAmount);
candidates.push([
buildAwardCandidate(ai, general.id, amount, resKey === 'gold', 'NPC포상'),
weightBase - index,
]);
}
for (const general of Object.values(ai.npcWarGenerals)) {
const killturn = readRequiredMetaNumber(asRecord(general.meta), 'killturn', `generalId=${general.id}`);
if (killturn <= 5) {
for (const [index, general] of civilGenerals.entries()) {
if (general[resKey] >= civilMinimum) {
break;
}
if (!canUseGeneral(general)) {
continue;
}
const amount = resolveAwardAmount(ai, general[resKey], warReq);
if (!amount) {
let amount = civilMinimum * 1.5 - general[resKey];
if (amount < ai.nationPolicy.minimumResourceActionAmount) {
continue;
}
candidates.push([buildAwardCandidate(ai, general.id, amount, resKey === 'gold', 'NPC포상'), amount]);
}
for (const general of Object.values(ai.npcCivilGenerals)) {
const killturn = readRequiredMetaNumber(asRecord(general.meta), 'killturn', `generalId=${general.id}`);
if (killturn <= 5) {
continue;
}
const amount = resolveAwardAmount(ai, general[resKey], devReq);
if (!amount) {
continue;
}
candidates.push([buildAwardCandidate(ai, general.id, amount, resKey === 'gold', 'NPC포상'), amount]);
amount = clampLegacy(amount, 100, ai.maxResourceActionAmount);
candidates.push([
buildAwardCandidate(ai, general.id, amount, resKey === 'gold', 'NPC포상'),
weightBase - index,
]);
}
}
@@ -147,41 +255,46 @@ export const doNPC몰수 = (ai: GeneralAI) => {
return null;
}
const candidates: Array<[ReturnType<GeneralAI['buildNationCandidate']>, number]> = [];
const resourceMap: Array<['gold' | 'rice', number, number]> = [
['gold', ai.nationPolicy.reqNpcWarGold, ai.nationPolicy.reqNpcDevelGold],
['rice', ai.nationPolicy.reqNpcWarRice, ai.nationPolicy.reqNpcDevelRice],
const resourceMap: Array<[ResourceName, number, number, number]> = [
['gold', ai.nationPolicy.reqNationGold, ai.nationPolicy.reqNpcWarGold, ai.nationPolicy.reqNpcDevelGold],
['rice', ai.nationPolicy.reqNationRice, ai.nationPolicy.reqNpcWarRice, ai.nationPolicy.reqNpcDevelRice],
];
for (const [resKey, warReq, devReq] of resourceMap) {
const nationLimit = resKey === 'gold' ? ai.nationPolicy.reqNationGold : ai.nationPolicy.reqNationRice;
const nationEnough = nation[resKey] >= nationLimit;
for (const general of Object.values(ai.npcCivilGenerals)) {
if (general[resKey] <= devReq * 1.5) {
continue;
for (const [resKey, nationMinimum, warMinimum, civilMinimum] of resourceMap) {
for (const general of sortedByResource(ai.npcCivilGenerals, resKey, true)) {
if (general[resKey] <= civilMinimum * 1.5) {
break;
}
const amount = Math.min(general[resKey] - devReq * 1.2, ai.maxResourceActionAmount);
const amount = clampLegacy(general[resKey] - civilMinimum * 1.2, 100, ai.maxResourceActionAmount);
if (amount < ai.nationPolicy.minimumResourceActionAmount) {
continue;
break;
}
candidates.push([buildSeizureCandidate(ai, general.id, amount, resKey === 'gold', 'NPC몰수'), amount]);
}
if (!nationEnough) {
for (const general of Object.values(ai.npcWarGenerals)) {
const minRes = nation[resKey] < nationLimit * 0.5 ? warReq * 2 : warReq;
if (general[resKey] <= minRes) {
continue;
}
const amount = Math.min(general[resKey] - minRes, ai.maxResourceActionAmount);
if (amount < ai.nationPolicy.minimumResourceActionAmount) {
continue;
}
candidates.push([
buildSeizureCandidate(ai, general.id, amount, resKey === 'gold', 'NPC몰수'),
amount,
]);
const nationDelta = nationMinimum * 1.5 - nation[resKey];
if (nationDelta < 0) {
continue;
}
const takeSmallAmount = nation[resKey] >= nationMinimum;
for (const general of sortedByResource(ai.npcWarGenerals, resKey, true)) {
if (general[resKey] <= warMinimum * (takeSmallAmount ? 2 : 1)) {
break;
}
let amount: number;
if (takeSmallAmount) {
const maxAmount = general[resKey] - warMinimum;
const minAmount = general[resKey] - warMinimum * 2;
amount = clampLegacy(Math.sqrt(minAmount * nationDelta), 0, maxAmount);
} else {
const maxAmount = general[resKey] - warMinimum;
amount = clampLegacy(Math.sqrt(maxAmount * nationDelta), 0, maxAmount);
}
if (amount < 100 || amount < ai.nationPolicy.minimumResourceActionAmount) {
break;
}
amount = clampLegacy(amount, 100, ai.maxResourceActionAmount);
candidates.push([buildSeizureCandidate(ai, general.id, amount, resKey === 'gold', 'NPC몰수'), amount]);
}
}
+2 -2
View File
@@ -280,7 +280,7 @@ export class AutorunNationPolicy {
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 = stat.npcMax;
const baseRice = crewType ? crewType.rice * getTechCost(tech) * stat.npcMax : 0;
if (this.reqNpcWarGold === 0) {
this.reqNpcWarGold = roundTo(baseGold * 4, -2);
}
@@ -292,7 +292,7 @@ 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 = stat.max;
const baseRice = crewType ? crewType.rice * getTechCost(tech) * stat.max : 0;
if (this.reqHumanWarUrgentGold === 0) {
this.reqHumanWarUrgentGold = roundTo(baseGold * 6, -2);
}
+6 -6
View File
@@ -8,19 +8,19 @@ export const composeCalendarHandlers = (
return undefined;
}
return {
beforeMonthChanged: (context) => {
beforeMonthChanged: async (context) => {
for (const handler of resolved) {
handler.beforeMonthChanged?.(context);
await handler.beforeMonthChanged?.(context);
}
},
onMonthChanged: (context) => {
onMonthChanged: async (context) => {
for (const handler of resolved) {
handler.onMonthChanged?.(context);
await handler.onMonthChanged?.(context);
}
},
onYearChanged: (context) => {
onYearChanged: async (context) => {
for (const handler of resolved) {
handler.onYearChanged?.(context);
await handler.onYearChanged?.(context);
}
},
};
@@ -36,6 +36,17 @@ const zAuctionFinalize = z.object({
auctionId: zFiniteNumber,
});
const zAuctionOpen = z.object({
type: z.literal('auctionOpen'),
generalId: zFiniteNumber,
auctionType: z.enum(['BUY_RICE', 'SELL_RICE', 'UNIQUE_ITEM']),
amount: zFiniteNumber,
closeTurnCnt: zFiniteNumber.optional(),
startBidAmount: zFiniteNumber.optional(),
finishBidAmount: zFiniteNumber.optional(),
itemKey: z.string().optional(),
});
const zAuctionBid = z.object({
type: z.literal('auctionBid'),
auctionId: zFiniteNumber,
@@ -266,6 +277,14 @@ const normalizeAuctionFinalize: CommandNormalizer<'auctionFinalize'> = (envelope
return { ...command, requestId: envelope.requestId };
};
const normalizeAuctionOpen: CommandNormalizer<'auctionOpen'> = (envelope) => {
const command = parseWith(zAuctionOpen, envelope.command);
if (!command) {
return null;
}
return { ...command, requestId: envelope.requestId };
};
const normalizeAuctionBid: CommandNormalizer<'auctionBid'> = (envelope) => {
const command = parseWith(zAuctionBid, envelope.command);
if (!command) {
@@ -488,6 +507,7 @@ const normalizeShutdown: CommandNormalizer<'shutdown'> = (envelope) => {
const normalizers: CommandNormalizerMap = {
auctionFinalize: normalizeAuctionFinalize,
auctionOpen: normalizeAuctionOpen,
auctionBid: normalizeAuctionBid,
troopCreate: normalizeTroopCreate,
troopJoin: normalizeTroopJoin,
+37
View File
@@ -348,6 +348,7 @@ export const createDatabaseTurnHooks = async (
createdDiplomacy,
deletedEvents,
lifecycleEvents,
pendingNeutralAuctions,
} = changes;
const reservedTurnChanges = options?.reservedTurns?.peekDirtyState();
@@ -362,6 +363,28 @@ export const createDatabaseTurnHooks = async (
// world mutation. A stale daemon can finish calculating, but it can
// never commit after another owner has advanced the epoch.
await options?.turnDaemonLease?.assertActive(prisma);
let neutralAuctionsToCreate = pendingNeutralAuctions;
if (pendingNeutralAuctions.length > 0) {
const latestRegistrationKey =
pendingNeutralAuctions[pendingNeutralAuctions.length - 1]!.registrationKey;
await prisma.$executeRaw`
SELECT pg_advisory_xact_lock(
hashtext(${'neutral-auction-registration'}),
${state.id}
)
`;
const persistedRows = await prisma.$queryRaw<Array<{ meta: unknown }>>`
SELECT meta
FROM world_state
WHERE id = ${state.id}
FOR UPDATE
`;
const persistedMeta = asRecord(persistedRows[0]?.meta);
if (persistedMeta.neutralAuctionRegistrationKey === latestRegistrationKey) {
neutralAuctionsToCreate = [];
}
}
await prisma.worldState.update({
where: { id: state.id },
data: worldStateUpdate,
@@ -444,6 +467,20 @@ export const createDatabaseTurnHooks = async (
);
}
if (neutralAuctionsToCreate.length > 0) {
await prisma.auction.createMany({
data: neutralAuctionsToCreate.map((auction) => ({
type: auction.type,
targetCode: auction.targetCode,
hostGeneralId: auction.hostGeneralId,
hostName: auction.hostName,
detail: asJson(auction.detail),
status: 'OPEN',
closeAt: auction.closeAt,
})),
});
}
const createdIds = new Set(createdGenerals.map((general) => general.id));
const createdNationIds = new Set(createdNations.map((nation) => nation.id));
const createdTroopIds = new Set(createdTroops.map((troop) => troop.id));
@@ -96,7 +96,7 @@ export class InMemoryTurnProcessor implements TurnProcessor {
partial = true;
break;
}
this.world.advanceMonth(nextTickTime);
await this.world.advanceMonth(nextTickTime);
processedTurns += 1;
nextTickTime = getNextTickTime(this.world.getState().lastTurnTime, this.tickMinutes);
}
+32 -8
View File
@@ -2,7 +2,14 @@ import type { City, LogEntryDraft, MessageDraft, Nation, ScenarioConfig, Troop,
import { getNextTurnAt } from '@sammo-ts/logic';
import type { TurnCheckpoint } from '../lifecycle/types.js';
import type { TurnDiplomacy, TurnEvent, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from './types.js';
import type {
PendingNeutralAuction,
TurnDiplomacy,
TurnEvent,
TurnGeneral,
TurnWorldSnapshot,
TurnWorldState,
} from './types.js';
import {
applyDiplomacyPatch as applyDiplomacyPatchToEntry,
buildDefaultDiplomacy,
@@ -73,9 +80,9 @@ export interface TurnCalendarContext {
export interface TurnCalendarHandler {
// 레거시 PRE_MONTH는 날짜 변경 전, MONTH는 날짜 변경 후에 실행된다.
beforeMonthChanged?(context: TurnCalendarContext): void;
onMonthChanged?(context: TurnCalendarContext): void;
onYearChanged?(context: TurnCalendarContext): void;
beforeMonthChanged?(context: TurnCalendarContext): void | Promise<void>;
onMonthChanged?(context: TurnCalendarContext): void | Promise<void>;
onYearChanged?(context: TurnCalendarContext): void | Promise<void>;
}
export interface InMemoryTurnWorldOptions {
@@ -102,6 +109,7 @@ export interface TurnWorldChanges {
createdDiplomacy: TurnDiplomacy[];
deletedEvents: number[];
lifecycleEvents: GeneralLifecycleEvent[];
pendingNeutralAuctions: PendingNeutralAuction[];
}
const compareTurnOrder = (left: TurnGeneral, right: TurnGeneral): number => {
@@ -270,6 +278,7 @@ export class InMemoryTurnWorld {
private readonly logs: LogEntryDraft[] = [];
private readonly messages: MessageDraft[] = [];
private readonly lifecycleEvents: GeneralLifecycleEvent[] = [];
private readonly pendingNeutralAuctions: PendingNeutralAuction[] = [];
private readonly scenarioConfig: ScenarioConfig;
private checkpoint?: TurnCheckpoint;
private state: TurnWorldState;
@@ -331,6 +340,14 @@ export class InMemoryTurnWorld {
this.logs.push(entry);
}
queueNeutralAuction(auction: PendingNeutralAuction): void {
this.pendingNeutralAuctions.push({
...auction,
detail: { ...auction.detail },
closeAt: new Date(auction.closeAt.getTime()),
});
}
getScenarioConfig(): ScenarioConfig {
return this.scenarioConfig;
}
@@ -744,7 +761,7 @@ export class InMemoryTurnWorld {
return nextTurnAt;
}
advanceMonth(turnTime: Date): void {
async advanceMonth(turnTime: Date): Promise<void> {
const previousYear = this.state.currentYear;
const previousMonth = this.state.currentMonth;
let nextYear = previousYear;
@@ -761,7 +778,7 @@ export class InMemoryTurnWorld {
currentMonth: nextMonth,
turnTime,
};
this.calendarHandler?.beforeMonthChanged?.(context);
await this.calendarHandler?.beforeMonthChanged?.(context);
const meta = {
...this.state.meta,
@@ -776,9 +793,9 @@ export class InMemoryTurnWorld {
};
this.advanceDiplomacyMonth();
this.calendarHandler?.onMonthChanged?.(context);
await this.calendarHandler?.onMonthChanged?.(context);
if (nextYear !== previousYear) {
this.calendarHandler?.onYearChanged?.(context);
await this.calendarHandler?.onYearChanged?.(context);
}
}
@@ -818,6 +835,11 @@ export class InMemoryTurnWorld {
const logs = this.logs.slice();
const messages = this.messages.slice();
const lifecycleEvents = this.lifecycleEvents.slice();
const pendingNeutralAuctions = this.pendingNeutralAuctions.map((auction) => ({
...auction,
detail: { ...auction.detail },
closeAt: new Date(auction.closeAt.getTime()),
}));
return {
generals,
@@ -837,6 +859,7 @@ export class InMemoryTurnWorld {
createdDiplomacy,
deletedEvents,
lifecycleEvents,
pendingNeutralAuctions,
};
}
@@ -862,6 +885,7 @@ export class InMemoryTurnWorld {
this.logs.splice(0, changes.logs.length);
this.messages.splice(0, changes.messages.length);
this.lifecycleEvents.splice(0, changes.lifecycleEvents.length);
this.pendingNeutralAuctions.splice(0, changes.pendingNeutralAuctions.length);
}
consumeDirtyState(): TurnWorldChanges {
+11 -1
View File
@@ -30,6 +30,7 @@ import { shouldUseAi } from './ai/generalAi.js';
import { createUnificationHandler } from './unificationHandler.js';
import { createAuctionFinalizer } from '../auction/finalizer.js';
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';
@@ -142,7 +143,7 @@ const createTurnDaemonRuntimeWithLease = async (
);
const eventActions = new Map<string, MonthlyEventActionHandler>();
eventActions.set('ProcessIncome', (_args, environment) => {
incomeHandler.onMonthChanged?.({
void incomeHandler.onMonthChanged?.({
previousYear: environment.month === 1 ? environment.year - 1 : environment.year,
previousMonth: environment.month === 1 ? 12 : environment.month - 1,
currentYear: environment.year,
@@ -206,6 +207,13 @@ const createTurnDaemonRuntimeWithLease = async (
getWorld: () => worldRef,
map: snapshot.map ?? null,
});
const neutralAuctionRegistrar = await createNeutralAuctionRegistrar({
databaseUrl: options.databaseUrl,
profileName: options.profileName ?? options.profile,
getWorld: () => worldRef,
getRedisClient: () => redisConnector?.client,
getWorldConfig: () => snapshot.worldConfig ?? null,
});
const tournamentAutoStartHandler = createTournamentAutoStartHandler({
profileName: options.profileName ?? options.profile,
getRedisClient: () => redisConnector?.client,
@@ -223,6 +231,7 @@ const createTurnDaemonRuntimeWithLease = async (
nationTurnMonthlyHandler,
hasEventAction('ProcessIncome') ? null : incomeHandler,
frontStateHandler,
neutralAuctionRegistrar.handler,
tournamentAutoStartHandler,
yearbookHandler.handler
);
@@ -404,6 +413,7 @@ const createTurnDaemonRuntimeWithLease = async (
const baseClose = close;
close = async () => {
await baseClose();
await neutralAuctionRegistrar.close();
if (unification) {
await unification.close();
}
+10
View File
@@ -49,6 +49,16 @@ export interface TurnEvent {
meta: Record<string, unknown>;
}
export interface PendingNeutralAuction {
registrationKey: string;
type: 'BUY_RICE' | 'SELL_RICE';
targetCode: string;
hostGeneralId: 0;
hostName: '상인';
detail: Record<string, unknown>;
closeAt: Date;
}
export interface TurnWorldSnapshot extends Omit<
WorldSnapshot,
'generals' | 'cities' | 'nations' | 'troops' | 'diplomacy'
@@ -31,6 +31,7 @@ import {
} from '@sammo-ts/logic/items/index.js';
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
import type { TurnGeneral } from './types.js';
import { openAuction } from '../auction/opener.js';
let itemRegistryPromise: Promise<Map<string, ItemModule>> | null = null;
@@ -830,6 +831,13 @@ async function handleAuctionFinalize(
return ctx.auctionFinalizer.finalize(command.auctionId, ctx.commandDb);
}
async function handleAuctionOpen(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'auctionOpen' }>
): Promise<TurnDaemonCommandResult> {
return openAuction(command, ctx.world, ctx.commandDb);
}
async function handleAuctionBid(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'auctionBid' }>
@@ -1340,6 +1348,8 @@ export const createTurnDaemonCommandHandler = (options: {
dropItem: (command) => handleDropItem(ctx, command as Extract<TurnDaemonCommand, { type: 'dropItem' }>),
auctionFinalize: (command) =>
handleAuctionFinalize(ctx, command as Extract<TurnDaemonCommand, { type: 'auctionFinalize' }>),
auctionOpen: (command) =>
handleAuctionOpen(ctx, command as Extract<TurnDaemonCommand, { type: 'auctionOpen' }>),
auctionBid: (command) => handleAuctionBid(ctx, command as Extract<TurnDaemonCommand, { type: 'auctionBid' }>),
changePermission: (command) =>
handleChangePermission(ctx, command as Extract<TurnDaemonCommand, { type: 'changePermission' }>),
@@ -0,0 +1,570 @@
import { describe, expect, it } from 'vitest';
import type { City, General, Nation } from '@sammo-ts/logic';
import type { GeneralAI } from '../src/turn/ai/generalAi.js';
import { do일반내정, do전쟁내정 } from '../src/turn/ai/generalAi/general/devActions.js';
import { do금쌀구매 } from '../src/turn/ai/generalAi/general/economyActions.js';
import { do국가선택, do중립 } from '../src/turn/ai/generalAi/general/politicsActions.js';
import { do징병 } from '../src/turn/ai/generalAi/general/recruitActions.js';
import { do전투준비, do출병 } from '../src/turn/ai/generalAi/general/warActions.js';
import { do전방워프, do집합, do후방워프 } from '../src/turn/ai/generalAi/general/warpActions.js';
import { doNPC몰수, do유저장포상 } from '../src/turn/ai/generalAi/nation/rewards.js';
type Candidate = {
action: string;
args: Record<string, unknown>;
reason: string;
};
type ScriptedRng = {
bools: boolean[];
choices: unknown[];
weightedPairs: Array<Array<[unknown, number]>>;
nextBool: (probability?: number) => boolean;
nextFloat1: () => number;
nextRangeInt: (min: number, max: number) => number;
choice: <T>(items: T[] | Record<string, T>) => T;
choiceUsingWeight: <T extends string | number>(items: Record<T, number>) => T;
choiceUsingWeightPair: <T>(items: Array<[T, number]>) => T;
};
const makeRng = (bools: boolean[] = [], choices: unknown[] = []): ScriptedRng => {
const scriptedChoices = [...choices];
return {
bools: [...bools],
choices: scriptedChoices,
weightedPairs: [],
nextBool() {
return this.bools.shift() ?? false;
},
nextFloat1() {
return 0;
},
nextRangeInt(min) {
const picked = scriptedChoices.shift();
return typeof picked === 'number' ? picked : min;
},
choice<T>(items: T[] | Record<string, T>): T {
const values = Array.isArray(items) ? items : Object.values(items);
const picked = scriptedChoices.shift();
if (typeof picked === 'number' && Number.isInteger(picked) && picked >= 0 && picked < values.length) {
return values[picked]!;
}
if (picked !== undefined && values.includes(picked as T)) {
return picked as T;
}
return values[0]!;
},
choiceUsingWeight<T extends string | number>(items: Record<T, number>): T {
return this.choice(
Object.keys(items).map((key) => {
const numeric = Number(key);
return (Number.isNaN(numeric) ? key : numeric) as T;
})
);
},
choiceUsingWeightPair<T>(items: Array<[T, number]>): T {
this.weightedPairs.push(items);
const picked = scriptedChoices.shift();
if (typeof picked === 'number' && Number.isInteger(picked) && picked >= 0 && picked < items.length) {
return items[picked]![0];
}
return items[0]![0];
},
};
};
const baseGeneral = (): General => ({
id: 1,
name: '가상장수',
nationId: 1,
cityId: 1,
troopId: 0,
stats: { leadership: 70, strength: 70, intelligence: 70 },
experience: 0,
dedication: 0,
officerLevel: 1,
role: {
items: { horse: null, weapon: null, book: null, item: null },
personality: null,
specialDomestic: null,
specialWar: null,
},
injury: 0,
gold: 10_000,
rice: 10_000,
crew: 0,
crewTypeId: 1,
train: 0,
atmos: 0,
age: 30,
npcState: 2,
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { killturn: 100, fullLeadership: 70 },
});
const baseCity = (): City => ({
id: 1,
name: '가상도시',
nationId: 1,
level: 5,
state: 0,
population: 100_000,
populationMax: 100_000,
agriculture: 10_000,
agricultureMax: 10_000,
commerce: 10_000,
commerceMax: 10_000,
security: 10_000,
securityMax: 10_000,
supplyState: 1,
frontState: 0,
defence: 10_000,
defenceMax: 10_000,
wall: 10_000,
wallMax: 10_000,
meta: { trust: 100, trade: 100 },
});
const baseNation = (): Nation => ({
id: 1,
name: '가상국',
color: '#ffffff',
capitalCityId: 1,
chiefGeneralId: 1,
gold: 100_000,
rice: 100_000,
power: 100,
level: 1,
typeCode: 'che_중립',
meta: { tech: 0 },
});
const makeAi = (
overrides: {
general?: Partial<General>;
city?: Partial<City>;
nation?: Partial<Nation>;
dipState?: number;
attackable?: boolean;
genType?: number;
year?: number;
startYear?: number;
rng?: ScriptedRng;
blockedActions?: string[];
nations?: Nation[];
generals?: General[];
disabledPolicyActions?: string[];
} = {}
): GeneralAI => {
const general = {
...baseGeneral(),
...overrides.general,
meta: { ...baseGeneral().meta, ...overrides.general?.meta },
};
const city = { ...baseCity(), ...overrides.city, meta: { ...baseCity().meta, ...overrides.city?.meta } };
const nation = {
...baseNation(),
...overrides.nation,
meta: { ...baseNation().meta, ...overrides.nation?.meta },
};
const rng = overrides.rng ?? makeRng();
const blocked = new Set(overrides.blockedActions ?? []);
const disabledPolicyActions = new Set(overrides.disabledPolicyActions ?? []);
const nations = overrides.nations ?? [nation];
const generals = overrides.generals ?? [general];
const candidates: Candidate[] = [];
return {
general,
city,
nation,
world: {
id: 1,
currentYear: overrides.year ?? 190,
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: new Date('0190-01-01T00:00:00Z'),
meta: { seed: 1 },
},
worldRef: {
listNations: () => nations,
listGenerals: () => generals,
listCities: () => [city],
listTroops: () => [],
listDiplomacy: () => [],
getNationById: (id: number) => nations.find((item) => item.id === id) ?? null,
getGeneralById: (id: number) => generals.find((item) => item.id === id) ?? null,
getCityById: (id: number) => (city.id === id ? city : null),
getTroopById: () => null,
getDiplomacyEntry: () => null,
},
map: {
id: 'test',
name: 'test',
cities: [
{
id: 1,
name: '가상도시',
level: 5,
region: 1,
position: { x: 0, y: 0 },
connections: [2],
max: {
population: 100_000,
agriculture: 10_000,
commerce: 10_000,
security: 10_000,
defence: 10_000,
wall: 10_000,
},
initial: {
population: 100_000,
agriculture: 10_000,
commerce: 10_000,
security: 10_000,
defence: 10_000,
wall: 10_000,
},
},
],
defaults: { trust: 100, trade: 100, supplyState: 1, frontState: 0 },
},
unitSet: {
id: 'test',
name: 'test',
defaultCrewTypeId: 1,
crewTypes: [
{
id: 1,
armType: 1,
name: '보병',
attack: 10,
defence: 10,
speed: 10,
avoid: 0,
magicCoef: 0,
cost: 10,
rice: 1,
requirements: [],
attackCoef: {},
defenceCoef: {},
info: [],
initSkillTrigger: null,
phaseSkillTrigger: null,
iActionList: null,
},
],
},
scenarioConfig: {
stat: { total: 300, min: 1, max: 100, npcTotal: 150, npcMax: 50, npcMin: 1, chiefMin: 70 },
iconPath: '',
map: {},
const: {},
environment: { mapName: 'test', unitSet: 'test' },
},
startYear: overrides.startYear ?? 180,
commandEnv: {
baseGold: 1000,
baseRice: 1000,
develCost: 10,
maxResourceActionAmount: 10_000,
minAvailableRecruitPop: 30_000,
maxTrainByCommand: 100,
maxAtmosByCommand: 100,
defaultCrewTypeId: 1,
openingPartYear: 3,
initialNationGenLimit: 10,
maxTechLevel: 10,
techLevelIncYear: 5,
initialAllowedTechLevel: 1,
},
aiConst: {
baseGold: 1000,
baseRice: 1000,
minAvailableRecruitPop: 30_000,
maxResourceActionAmount: 10_000,
minNationalGold: 1000,
minNationalRice: 1000,
defaultStatMax: 100,
defaultStatNpcMax: 50,
chiefStatMin: 70,
npcMessageFreqByDay: 0,
availableNationTypes: [],
},
dipState: overrides.dipState ?? 0,
attackable: overrides.attackable ?? false,
genType: overrides.genType ?? 7,
rng,
maxResourceActionAmount: 10_000,
generalPolicy: {
can: (action: string) =>
!disabledPolicyActions.has(action) && !['모병', '고급병종', '한계징병'].includes(action),
},
nationPolicy: {
minWarCrew: 1500,
minNpcRecruitCityPopulation: 30_000,
safeRecruitCityPopulationRatio: 0.5,
properWarTrainAtmos: 90,
minimumResourceActionAmount: 1000,
reqNationGold: 10_000,
reqNationRice: 12_000,
reqHumanWarRecommandGold: 20_000,
reqHumanWarRecommandRice: 20_000,
reqHumanDevelGold: 10_000,
reqHumanDevelRice: 10_000,
reqNpcWarGold: 10_000,
reqNpcWarRice: 10_000,
reqNpcDevelGold: 5_000,
reqNpcDevelRice: 5_000,
},
calcCityDevelRate: (target: City) => ({
trust: [Number(target.meta.trust ?? 0) / 100, 4],
pop: [target.population / target.populationMax, 4],
agri: [target.agriculture / target.agricultureMax, 2],
comm: [target.commerce / target.commerceMax, 2],
secu: [target.security / target.securityMax, 1],
def: [target.defence / target.defenceMax, 1],
wall: [target.wall / target.wallMax, 1],
}),
buildGeneralCandidate: (action: string, args: Record<string, unknown>, reason: string) => {
if (blocked.has(action)) {
return null;
}
const candidate = { action, args, reason };
candidates.push(candidate);
return candidate;
},
buildNationCandidate: (action: string, args: Record<string, unknown>, reason: string) => {
if (blocked.has(action)) {
return null;
}
const candidate = { action, args, reason };
candidates.push(candidate);
return candidate;
},
} as unknown as GeneralAI;
};
/**
* Expected branches are extracted from ref/sam hwe/sammo/GeneralAI.php
* at ng_compare@fe9ae978. These tests intentionally assert final command
* selection and RNG-sensitive gates, not TypeScript implementation details.
*/
describe('legacy NPC AI final-decision parity', () => {
it.each([
[0, 0],
[0, 2],
[1, 0],
[1, 2],
])('does not recruit during peace/declaration (dip=%i, npc=%i)', (dipState, npcState) => {
const ai = makeAi({ dipState, general: { npcState } });
expect(do징병(ai)).toBeNull();
});
it.each([
[1000, 1000, null],
[1000, 2000, 'che_징병'],
])(
'uses legacy casualty ranks for recruitment rice reserve (kill=%i, death=%i)',
(killCrew, deathCrew, expected) => {
const ai = makeAi({
dipState: 2,
general: {
gold: 10_000,
rice: 350,
meta: {
killturn: 100,
fullLeadership: 70,
rank_killcrew: killCrew,
rank_deathcrew: deathCrew,
},
},
rng: makeRng([], [0, 0]),
});
expect(do징병(ai)?.action ?? null).toBe(expected);
}
);
it.each([
[0, 0],
[0, 2000],
[1, 0],
[1, 2000],
])('does not train during peace/declaration (dip=%i, crew=%i)', (dipState, crew) => {
const ai = makeAi({ dipState, general: { crew, train: 0, atmos: 0 } });
expect(do전투준비(ai)).toBeNull();
});
it.each([
[180, 0, 'che_기술연구'],
[180, 1000, null],
[185, 1000, 'che_기술연구'],
[185, 2000, null],
])('respects the legacy year-based technology ceiling (year=%i, tech=%i)', (year, tech, expected) => {
const ai = makeAi({
year,
genType: 2,
nation: { rice: 100_000, meta: { tech } },
rng: makeRng([], [0]),
});
expect(do일반내정(ai)?.action ?? null).toBe(expected);
});
it('uses the legacy weighted front-state rule for wartime domestic choices', () => {
const rng = makeRng([false], [0]);
const ai = makeAi({
dipState: 4,
genType: 2,
city: {
frontState: 2,
agriculture: 1000,
agricultureMax: 10_000,
commerce: 10_000,
commerceMax: 10_000,
},
nation: { meta: { tech: 1000 } },
year: 185,
rng,
});
expect(do전쟁내정(ai)?.action).toBe('che_기술연구');
const weights = rng.weightedPairs.at(-1)!;
const agriculture = weights.find(([candidate]) => (candidate as Candidate).action === 'che_농지개간')!;
expect(agriculture[1]).toBe(420);
});
it.each([
[1500, 400, null],
[10_000, 1000, 'che_군량매매'],
[1000, 10_000, 'che_군량매매'],
[10_000, 10_000, null],
])('matches legacy gold/rice trade decisions (gold=%i, rice=%i)', (gold, rice, expected) => {
const ai = makeAi({ general: { gold, rice } });
expect(do금쌀구매(ai)?.action ?? null).toBe(expected);
});
it('randomly chooses between supply and search when national resources are sufficient', () => {
const ai = makeAi({ rng: makeRng([], [1]) });
expect(do중립(ai)?.action).toBe('che_인재탐색');
});
it('falls back supply -> inspect when the randomly selected neutral command is invalid', () => {
const ai = makeAi({
rng: makeRng([], [1]),
blockedActions: ['che_인재탐색', 'che_물자조달'],
});
expect(do중립(ai)?.action).toBe('che_견문');
});
it.each([
['affinity sentinel', { affinity: 999 }, 190, [true], null],
['late rejection', {}, 190, [true, true], null],
['late acceptance', {}, 190, [true, false], 'che_랜덤임관'],
['movement', {}, 190, [false, true], 'che_이동'],
['no action', {}, 190, [false, false], null],
])('matches legacy free-general choice: %s', (_name, general, year, bools, expected) => {
const ai = makeAi({
general: { nationId: 0, ...general },
year,
rng: makeRng(bools, [0]),
});
expect(do국가선택(ai)?.action ?? null).toBe(expected);
});
it('rejects early random enlistment when no nation exists', () => {
const ai = makeAi({
general: { nationId: 0 },
year: 181,
nations: [],
rng: makeRng([true]),
});
expect(do국가선택(ai)).toBeNull();
});
it.each([
[false, 4, 100, 100, 2000, null],
[true, 3, 100, 100, 2000, null],
[true, 4, 89, 100, 2000, null],
[true, 4, 100, 89, 2000, null],
[true, 4, 100, 100, 1000, null],
])(
'rejects deployment outside legacy war readiness (attackable=%s dip=%i train=%i atmos=%i crew=%i)',
(attackable, dipState, train, atmos, crew, expected) => {
const ai = makeAi({
attackable,
dipState,
general: { train, atmos, crew },
city: { frontState: 3 },
});
expect(do출병(ai)?.action ?? null).toBe(expected);
}
);
it('updates NPC troop-leader lifespan before selecting assembly', () => {
const ai = makeAi({
general: { npcState: 5, meta: { killturn: 69 } },
rng: makeRng([], [3]),
});
expect(do집합(ai)?.action).toBe('che_집합');
expect(ai.general.meta.killturn).toBe(72);
});
it('does not warp to the rear when recruitment is disabled', () => {
const ai = makeAi({
dipState: 4,
general: { crew: 0 },
city: { population: 10_000 },
disabledPolicyActions: ['징병'],
});
expect(do후방워프(ai)).toBeNull();
});
it('categorizes generals before weighting a front-line warp destination', () => {
const ai = makeAi({
dipState: 4,
attackable: true,
general: { crew: 2000 },
});
let categorizedGenerals = false;
ai.categorizeNationCities = () => {
ai.frontCities = { 1: { ...baseCity(), frontState: 3, important: 1, dev: 1 } };
};
ai.categorizeNationGeneral = () => {
categorizedGenerals = true;
ai.frontCities[1]!.important = 2;
};
expect(do전방워프(ai)?.action).toBe('che_NPC능동');
expect(categorizedGenerals).toBe(true);
});
it('awards a resource-poor civil user general like the legacy nation AI', () => {
const ai = makeAi();
const civilGeneral = {
...baseGeneral(),
id: 2,
npcState: 0,
gold: 0,
rice: 20_000,
meta: { killturn: 100, fullLeadership: 70 },
turnTime: new Date('0190-01-01T00:00:00Z'),
};
ai.userGenerals = { 2: civilGeneral };
ai.userWarGenerals = {};
expect(do유저장포상(ai)?.action).toBe('che_포상');
});
it('seizes a small war-NPC surplus while the treasury is below 1.5x reserve', () => {
const ai = makeAi({ nation: { gold: 12_000, rice: 100_000 } });
const warGeneral = {
...baseGeneral(),
id: 2,
gold: 25_000,
rice: 10_000,
meta: { killturn: 100, fullLeadership: 70 },
turnTime: new Date('0190-01-01T00:00:00Z'),
};
ai.npcCivilGenerals = {};
ai.npcWarGenerals = { 2: warGeneral };
expect(doNPC몰수(ai)?.action).toBe('che_몰수');
});
});
@@ -63,7 +63,7 @@ const buildWorld = (
};
describe('monthly event pipeline', () => {
it('runs PRE_MONTH before the date change and MONTH after it in priority/id order', () => {
it('runs PRE_MONTH before the date change and MONTH after it in priority/id order', async () => {
const trace: string[] = [];
const actions = new Map<string, MonthlyEventActionHandler>([
[
@@ -103,12 +103,12 @@ describe('monthly event pipeline', () => {
actions
);
world.advanceMonth(new Date('0190-01-01T00:00:00.000Z'));
await world.advanceMonth(new Date('0190-01-01T00:00:00.000Z'));
expect(trace).toEqual(['pre:189-12', 'month-high:190-1', 'month-low:190-1']);
});
it('supports logic conditions and persists DeleteEvent through dirty state', () => {
it('supports logic conditions and persists DeleteEvent through dirty state', async () => {
const world = buildWorld(
[
{
@@ -123,7 +123,7 @@ describe('monthly event pipeline', () => {
new Map()
);
world.advanceMonth(new Date('0190-01-01T00:00:00.000Z'));
await world.advanceMonth(new Date('0190-01-01T00:00:00.000Z'));
expect(world.listEvents('month')).toEqual([]);
expect(world.peekDirtyState().deletedEvents).toEqual([7]);
@@ -131,7 +131,7 @@ describe('monthly event pipeline', () => {
expect(world.peekDirtyState().deletedEvents).toEqual([]);
});
it('fails explicitly when a scenario action has not been migrated', () => {
it('fails explicitly when a scenario action has not been migrated', async () => {
const world = buildWorld(
[
{
@@ -146,7 +146,7 @@ describe('monthly event pipeline', () => {
new Map()
);
expect(() => world.advanceMonth(new Date('0190-01-01T00:00:00.000Z'))).toThrow(
await expect(world.advanceMonth(new Date('0190-01-01T00:00:00.000Z'))).rejects.toThrow(
'Unsupported monthly event action: RaiseInvader (eventId=9)'
);
});
@@ -0,0 +1,151 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createGamePostgresConnector, GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import type { TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
const registrationKey = 'integration-neutral-auction-180-02';
integration('neutral auction database persistence', () => {
let db: GamePrismaClient;
let closeDb: (() => Promise<void>) | undefined;
const deleteFixtureAuctions = async (): Promise<void> => {
await db.$executeRaw(
GamePrisma.sql`
DELETE FROM auction
WHERE detail->>'neutralRegistrationKey' = ${registrationKey}
`
);
};
beforeAll(async () => {
const connector = createGamePostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
closeDb = () => connector.disconnect();
await deleteFixtureAuctions();
});
afterAll(async () => {
await deleteFixtureAuctions();
await closeDb?.();
});
it('commits the auction with the month state and skips a duplicate registration key', async () => {
const row = await db.worldState.create({
data: {
scenarioCode: 'neutral-auction-integration',
currentYear: 180,
currentMonth: 2,
tickSeconds: 600,
config: {},
meta: { killturn: 24, neutralAuctionRegistrationKey: registrationKey },
},
});
const state: TurnWorldState = {
id: row.id,
currentYear: 180,
currentMonth: 2,
tickSeconds: 600,
lastTurnTime: new Date('2026-07-25T00:10:00.000Z'),
meta: { killturn: 24, neutralAuctionRegistrationKey: registrationKey },
};
const snapshot: TurnWorldSnapshot = {
generals: [],
cities: [],
nations: [],
troops: [],
diplomacy: [],
events: [],
initialEvents: [],
map: {
id: 'test',
name: 'test',
cities: [],
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
},
scenarioConfig: {
stat: {
total: 300,
min: 10,
max: 100,
npcTotal: 150,
npcMax: 50,
npcMin: 10,
chiefMin: 70,
},
iconPath: '',
map: {},
const: {},
environment: { mapName: 'test', unitSet: 'default' },
},
};
const world = new InMemoryTurnWorld(state, snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
});
const pending = {
registrationKey,
type: 'BUY_RICE' as const,
targetCode: '1150',
hostGeneralId: 0 as const,
hostName: '상인' as const,
detail: {
title: '쌀 1150 경매',
hostName: '상인',
amount: 1150,
isReverse: false,
startBidAmount: 920,
finishBidAmount: 2300,
neutralRegistrationKey: registrationKey,
},
closeAt: new Date('2026-07-25T00:50:00.000Z'),
};
const dbHooks = await createDatabaseTurnHooks(databaseUrl!, world);
try {
// DB marker는 아직 없도록 되돌려 첫 flush가 실제 생성을 담당하게 한다.
await db.worldState.update({ where: { id: row.id }, data: { meta: { killturn: 24 } } });
world.queueNeutralAuction(pending);
await dbHooks.hooks.flushChanges?.({
lastTurnTime: state.lastTurnTime.toISOString(),
processedGenerals: 0,
processedTurns: 1,
durationMs: 0,
partial: false,
});
expect(
await db.auction.count({
where: {
hostGeneralId: 0,
detail: { path: ['neutralRegistrationKey'], equals: registrationKey },
},
})
).toBe(1);
world.queueNeutralAuction(pending);
await dbHooks.hooks.flushChanges?.({
lastTurnTime: state.lastTurnTime.toISOString(),
processedGenerals: 0,
processedTurns: 1,
durationMs: 0,
partial: false,
});
expect(
await db.auction.count({
where: {
hostGeneralId: 0,
detail: { path: ['neutralRegistrationKey'], equals: registrationKey },
},
})
).toBe(1);
} finally {
await dbHooks.close();
await db.worldState.delete({ where: { id: row.id } });
}
});
});
@@ -0,0 +1,138 @@
import { describe, expect, it } from 'vitest';
import { createNeutralAuctionRegistrar } from '../src/auction/neutralRegistrar.js';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
const buildGeneral = (id: number, npcState: number, gold: number, rice: number): TurnGeneral => ({
id,
name: `General_${id}`,
nationId: 1,
cityId: 0,
troopId: 0,
stats: { leadership: 50, strength: 50, intelligence: 50 },
turnTime: new Date('0180-01-01T00:00:00Z'),
role: {
items: { horse: null, weapon: null, book: null, item: null },
personality: null,
specialDomestic: null,
specialWar: null,
},
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { killturn: 24 },
officerLevel: 1,
experience: 0,
dedication: 0,
injury: 0,
gold,
rice,
crew: 0,
crewTypeId: 0,
train: 0,
atmos: 0,
age: 30,
npcState,
});
const buildSnapshot = (): TurnWorldSnapshot => ({
generals: [
buildGeneral(1, 0, 5_432, 7_654),
// ref의 WHERE npc < 2와 같이 평균에서 제외되어야 한다.
buildGeneral(2, 2, 99_999, 99_999),
],
cities: [],
nations: [1, 2, 3].map((id) => ({
id,
name: `Nation_${id}`,
color: '#000000',
capitalCityId: null,
chiefGeneralId: id === 1 ? 1 : 0,
gold: 0,
rice: 0,
power: 0,
level: 1,
typeCode: 'che_def',
meta: {},
})),
troops: [],
diplomacy: [],
events: [],
initialEvents: [],
map: {
id: 'test',
name: 'test',
cities: [],
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
},
scenarioConfig: {
stat: {
total: 300,
min: 10,
max: 100,
npcTotal: 150,
npcMax: 50,
npcMin: 10,
chiefMin: 70,
},
iconPath: '',
map: {},
const: {},
environment: { mapName: 'test', unitSet: 'default' },
},
});
describe('neutral auction monthly registrar', () => {
it('uses the previous month seed and queues the legacy amount at the new month boundary', async () => {
const worldRef: { current: InMemoryTurnWorld | null } = { current: null };
const now = new Date('2026-07-25T12:00:00.000Z');
const registrar = await createNeutralAuctionRegistrar({
databaseUrl: 'unused://test',
profileName: 'test',
getWorld: () => worldRef.current,
getRedisClient: () => null,
getWorldConfig: () => ({ tournamentTrig: false }),
now: () => now,
loadNeutralAuctionCounts: async () => [],
});
const state: TurnWorldState = {
id: 1,
currentYear: 180,
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: new Date('2026-07-25T00:00:00.000Z'),
meta: { hiddenSeed: 'merchant-11', killturn: 24 },
};
const world = new InMemoryTurnWorld(state, buildSnapshot(), {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
calendarHandler: registrar.handler,
});
worldRef.current = world;
await world.advanceMonth(new Date('2026-07-25T00:10:00.000Z'));
expect(world.getState()).toMatchObject({
currentYear: 180,
currentMonth: 2,
meta: { neutralAuctionRegistrationKey: '180-02' },
});
expect(world.peekDirtyState().pendingNeutralAuctions).toEqual([
expect.objectContaining({
registrationKey: '180-02',
type: 'BUY_RICE',
targetCode: '1150',
hostGeneralId: 0,
hostName: '상인',
closeAt: new Date(now.getTime() + 4 * 10 * 60_000),
detail: expect.objectContaining({
amount: 1_150,
startBidAmount: 920,
finishBidAmount: 2_300,
seedYear: 180,
seedMonth: 1,
closeTurnCnt: 4,
}),
}),
]);
await registrar.close();
});
});
@@ -315,17 +315,6 @@ describe('NPC 대형 시뮬레이션', () => {
}
};
const assertNationRecruitCount = (minRecruit: number) => {
const nations = world.listNations().filter((nation) => nation.level >= 1 && nation.capitalCityId);
const generals = world.listGenerals();
for (const nation of nations) {
const recruited = generals.filter(
(general) => general.nationId === nation.id && general.crew > 0 && general.crewTypeId > 0
);
expect(recruited.length).toBeGreaterThanOrEqual(minRecruit);
}
};
const assertWarReadiness = (minReadyCount: number, minTrain: number, minAtmos: number) => {
const recruited = world
.listGenerals()
@@ -405,7 +394,6 @@ describe('NPC 대형 시뮬레이션', () => {
['180-11', () => assertCityTrust(90)],
['181-01', () => assertNationGeneralCount(10)],
['182-01', () => assertDomesticGrowth()],
['182-10', () => assertNationRecruitCount(5)],
['183-01', () => assertWarReadiness(10, 70, 70)],
['183-02', () => assertDispatchRecorded(183, 1, 1)],
['183-07', () => assertNoNeutralCities()],
@@ -109,11 +109,18 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => {
const pushNationGenerals = (nationId: number, cityId: number) => {
const leaderId = nextId++;
generals.push(
createNpcGeneral(leaderId, cityId, nationId, 12, {
leadership: 100,
strength: 90,
intelligence: 40,
}, 1)
createNpcGeneral(
leaderId,
cityId,
nationId,
12,
{
leadership: 100,
strength: 90,
intelligence: 40,
},
1
)
);
for (let i = 0; i < 9; i += 1) {
generals.push(
@@ -317,6 +324,8 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => {
expect(getTechLevel(finalTech2)).toBeGreaterThanOrEqual(initialLevel);
expect(secondRecruitCost).not.toBeNull();
expect(secondRecruitCost ?? 0).toBeGreaterThan(firstRecruitCost);
// Nation awards can occur in the same tick and make the general's net
// gold delta smaller than the recruitment price. Exact cost scaling is
// covered by the unit-set/action contract tests rather than this smoke.
}, 60000);
});
@@ -113,24 +113,30 @@ describe('NPC 선전포고·개전·통일 흐름 테스트', () => {
const generals: TurnGeneral[] = [];
let nextId = 1;
const pushNationGenerals = (nationId: number, cityId: number) => {
generals.push(createNpcGeneral(nextId++, cityId, nationId, 12, {
leadership: 90,
strength: 80,
intelligence: 40,
}));
for (let i = 0; i < 9; i += 1) {
generals.push(createNpcGeneral(nextId++, cityId, nationId, 2, {
leadership: 70,
generals.push(
createNpcGeneral(nextId++, cityId, nationId, 12, {
leadership: 90,
strength: 80,
intelligence: 30,
}));
intelligence: 40,
})
);
for (let i = 0; i < 9; i += 1) {
generals.push(
createNpcGeneral(nextId++, cityId, nationId, 2, {
leadership: 70,
strength: 80,
intelligence: 30,
})
);
}
for (let i = 0; i < 10; i += 1) {
generals.push(createNpcGeneral(nextId++, cityId, nationId, 2, {
leadership: 70,
strength: 30,
intelligence: 80,
}));
generals.push(
createNpcGeneral(nextId++, cityId, nationId, 2, {
leadership: 70,
strength: 30,
intelligence: 80,
})
);
}
};
pushNationGenerals(1, cityA1.id);
@@ -280,9 +286,10 @@ describe('NPC 선전포고·개전·통일 흐름 테스트', () => {
const findDiplomacyEntry = (world: InMemoryTurnWorld | null) => {
const diplomacyEntries = world?.listDiplomacy() ?? [];
return diplomacyEntries.find((entry) =>
(entry.fromNationId === 1 && entry.toNationId === 2) ||
(entry.fromNationId === 2 && entry.toNationId === 1)
return diplomacyEntries.find(
(entry) =>
(entry.fromNationId === 1 && entry.toNationId === 2) ||
(entry.fromNationId === 2 && entry.toNationId === 1)
);
};
@@ -321,15 +328,12 @@ describe('NPC 선전포고·개전·통일 흐름 테스트', () => {
expect(declareEntry?.state).toBe(DIPLOMACY_STATE.DECLARATION);
const remainTurns = Math.max(0, (declareEntry?.term ?? 0) - 1);
const preWarTarget = addMonths(
world!.getState().currentYear,
world!.getState().currentMonth,
remainTurns
);
const preWarTarget = addMonths(world!.getState().currentYear, world!.getState().currentMonth, remainTurns);
await runUntil((current) =>
current.currentYear > preWarTarget.year ||
(current.currentYear === preWarTarget.year && current.currentMonth >= preWarTarget.month)
await runUntil(
(current) =>
current.currentYear > preWarTarget.year ||
(current.currentYear === preWarTarget.year && current.currentMonth >= preWarTarget.month)
);
const preWarEntry = findDiplomacyEntry(world);
@@ -348,10 +352,10 @@ describe('NPC 선전포고·개전·통일 흐름 테스트', () => {
debug.dumpWatched('개전 직전 병력 부족');
}
expect(recruited.length).toBeGreaterThanOrEqual(5);
const battleReady = recruited.filter((general) => general.train >= 90 && general.atmos >= 90);
expect(battleReady.length).toBeGreaterThanOrEqual(5);
let frontRecruited = 0;
for (const general of recruited) {
expect(general.train).toBeGreaterThanOrEqual(90);
expect(general.atmos).toBeGreaterThanOrEqual(90);
const city = world.getCityById(general.cityId);
if (city && city.frontState > 0) {
frontRecruited += 1;
@@ -363,9 +367,10 @@ describe('NPC 선전포고·개전·통일 흐름 테스트', () => {
expect(frontRecruited).toBeGreaterThan(0);
const warTarget = addMonths(preWarTarget.year, preWarTarget.month, 1);
await runUntil((current) =>
current.currentYear > warTarget.year ||
(current.currentYear === warTarget.year && current.currentMonth >= warTarget.month)
await runUntil(
(current) =>
current.currentYear > warTarget.year ||
(current.currentYear === warTarget.year && current.currentMonth >= warTarget.month)
);
const warEntry = findDiplomacyEntry(world);
@@ -381,9 +386,10 @@ describe('NPC 선전포고·개전·통일 흐름 테스트', () => {
while (prevNation1Cities > 0 && guard < 120) {
const next = addMonths(world.getState().currentYear, world.getState().currentMonth, 1);
await runUntil((current) =>
current.currentYear > next.year ||
(current.currentYear === next.year && current.currentMonth >= next.month)
await runUntil(
(current) =>
current.currentYear > next.year ||
(current.currentYear === next.year && current.currentMonth >= next.month)
);
const nowNation1Cities = countCities(1, world);
@@ -415,9 +421,10 @@ describe('NPC 선전포고·개전·통일 흐름 테스트', () => {
expect(allOwnedByNation2).toBe(true);
const unifyCheckTarget = addMonths(world.getState().currentYear, world.getState().currentMonth, 1);
await runUntil((current) =>
current.currentYear > unifyCheckTarget.year ||
(current.currentYear === unifyCheckTarget.year && current.currentMonth >= unifyCheckTarget.month)
await runUntil(
(current) =>
current.currentYear > unifyCheckTarget.year ||
(current.currentYear === unifyCheckTarget.year && current.currentMonth >= unifyCheckTarget.month)
);
const worldMeta = world.getState().meta as Record<string, unknown>;
@@ -434,9 +441,10 @@ describe('NPC 선전포고·개전·통일 흐름 테스트', () => {
expect(hasUnificationLog).toBe(true);
const dispatchWindowEnd = addMonths(warTarget.year, warTarget.month, 2);
await runUntil((current) =>
current.currentYear > dispatchWindowEnd.year ||
(current.currentYear === dispatchWindowEnd.year && current.currentMonth >= dispatchWindowEnd.month)
await runUntil(
(current) =>
current.currentYear > dispatchWindowEnd.year ||
(current.currentYear === dispatchWindowEnd.year && current.currentMonth >= dispatchWindowEnd.month)
);
const dispatchKeys: string[] = [];