Merge main into feature/turn-differential-longrun
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
|
||||
import type { GameApiContext } from '../context.js';
|
||||
import { buildAuctionTimerKeys } from './keys.js';
|
||||
|
||||
export type OpenAuctionInput =
|
||||
| {
|
||||
auctionType: 'BUY_RICE' | 'SELL_RICE';
|
||||
amount: number;
|
||||
closeTurnCnt: number;
|
||||
startBidAmount: number;
|
||||
finishBidAmount: number;
|
||||
}
|
||||
| {
|
||||
auctionType: 'UNIQUE_ITEM';
|
||||
amount: number;
|
||||
itemKey: string;
|
||||
};
|
||||
|
||||
export const openAuctionWithDaemon = async (
|
||||
ctx: GameApiContext,
|
||||
generalId: number,
|
||||
input: OpenAuctionInput
|
||||
): Promise<{ auctionId: number; closeAt: string }> => {
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'auctionOpen',
|
||||
generalId,
|
||||
...input,
|
||||
});
|
||||
if (!result || result.type !== 'auctionOpen') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||
}
|
||||
if (!result.ok) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
|
||||
}
|
||||
|
||||
const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
|
||||
const closeAt = new Date(result.closeAt);
|
||||
await ctx.redis.zAdd(timerKeys.timerKey, [{ score: closeAt.getTime(), value: String(result.auctionId) }]);
|
||||
return {
|
||||
auctionId: result.auctionId,
|
||||
closeAt: result.closeAt,
|
||||
};
|
||||
};
|
||||
@@ -6,6 +6,9 @@ import type { DatabaseClient, GameApiContext, GeneralRow } from '../../context.j
|
||||
import { buildAuctionTimerKeys } from '../../auction/keys.js';
|
||||
import { GamePrisma } from '@sammo-ts/infra';
|
||||
import { ItemLoader, isItemKey } from '@sammo-ts/logic';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { buildAuctionAlias } from '@sammo-ts/logic';
|
||||
import { openAuctionWithDaemon } from '../../auction/open.js';
|
||||
|
||||
|
||||
const zBidInput = z.object({
|
||||
@@ -17,6 +20,18 @@ const zUniqueBidInput = zBidInput.extend({
|
||||
tryExtendCloseDate: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const zOpenResourceInput = z.object({
|
||||
amount: z.number().int().min(100).max(10_000),
|
||||
closeTurnCnt: z.number().int().min(1).max(24),
|
||||
startBidAmount: z.number().int().positive(),
|
||||
finishBidAmount: z.number().int().positive(),
|
||||
});
|
||||
|
||||
const zOpenUniqueInput = z.object({
|
||||
itemKey: z.string(),
|
||||
amount: z.number().int().positive(),
|
||||
});
|
||||
|
||||
type AuctionType = 'BUY_RICE' | 'SELL_RICE' | 'UNIQUE_ITEM';
|
||||
|
||||
interface AuctionRow {
|
||||
@@ -29,7 +44,7 @@ interface AuctionRow {
|
||||
closeAt: Date;
|
||||
}
|
||||
|
||||
interface AuctionDetail {
|
||||
export interface AuctionDetail {
|
||||
title?: string;
|
||||
amount?: number;
|
||||
isReverse?: boolean;
|
||||
@@ -161,6 +176,169 @@ const shouldUsePrevBid = (highestBid: AuctionBidRow | null, myPrevBid: AuctionBi
|
||||
};
|
||||
|
||||
export const auctionRouter = router({
|
||||
getOverview: authedProcedure.query(async ({ ctx }) => {
|
||||
const auth = requireAuth(ctx);
|
||||
const general = await ensureGeneral(ctx.db, auth.user.id);
|
||||
const [auctions, worldState, point, recentLogs] = await Promise.all([
|
||||
ctx.db.auction.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ type: { in: ['BUY_RICE', 'SELL_RICE'] }, status: 'OPEN' },
|
||||
{ type: 'UNIQUE_ITEM' },
|
||||
],
|
||||
},
|
||||
orderBy: [{ status: 'asc' }, { id: 'desc' }],
|
||||
take: 120,
|
||||
include: {
|
||||
bids: {
|
||||
orderBy: [{ amount: 'desc' }, { id: 'asc' }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
ctx.db.worldState.findFirst(),
|
||||
ctx.db.inheritancePoint.findUnique({
|
||||
where: { userId_key: { userId: auth.user.id, key: 'previous' } },
|
||||
}),
|
||||
ctx.db.logEntry.findMany({
|
||||
where: { text: { contains: '경매' } },
|
||||
orderBy: { id: 'desc' },
|
||||
take: 20,
|
||||
select: { id: true, text: true, createdAt: true },
|
||||
}),
|
||||
]);
|
||||
const generalIds = new Set<number>();
|
||||
for (const auction of auctions) {
|
||||
if (auction.type !== 'UNIQUE_ITEM' && auction.hostGeneralId > 0) {
|
||||
generalIds.add(auction.hostGeneralId);
|
||||
}
|
||||
for (const bid of auction.bids) {
|
||||
if (auction.type !== 'UNIQUE_ITEM') {
|
||||
generalIds.add(bid.generalId);
|
||||
}
|
||||
}
|
||||
}
|
||||
const names = new Map(
|
||||
(
|
||||
await ctx.db.general.findMany({
|
||||
where: { id: { in: [...generalIds] } },
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
).map((row) => [row.id, row.name])
|
||||
);
|
||||
const worldMeta = asRecord(worldState?.meta);
|
||||
const configConst = asRecord(asRecord(worldState?.config).const);
|
||||
const hiddenSeed =
|
||||
typeof worldMeta.hiddenSeed === 'string' || typeof worldMeta.hiddenSeed === 'number'
|
||||
? worldMeta.hiddenSeed
|
||||
: worldState?.id ?? 0;
|
||||
const callerAlias = buildAuctionAlias(general.id, hiddenSeed, configConst);
|
||||
|
||||
const mapped = auctions.map((auction) => {
|
||||
const detail = parseDetail(auction.detail);
|
||||
const highestBid = auction.bids[0] ?? null;
|
||||
const isUnique = auction.type === 'UNIQUE_ITEM';
|
||||
return {
|
||||
id: auction.id,
|
||||
type: auction.type,
|
||||
targetCode: auction.targetCode,
|
||||
status: auction.status,
|
||||
hostGeneralId: isUnique ? null : auction.hostGeneralId,
|
||||
hostName: isUnique
|
||||
? auction.hostName ?? buildAuctionAlias(auction.hostGeneralId, hiddenSeed, configConst)
|
||||
: auction.hostName ?? names.get(auction.hostGeneralId) ?? '상인',
|
||||
isCallerHost: auction.hostGeneralId === general.id,
|
||||
closeAt: auction.closeAt.toISOString(),
|
||||
detail,
|
||||
highestBid: highestBid
|
||||
? {
|
||||
amount: highestBid.amount,
|
||||
bidderName: isUnique
|
||||
? buildAuctionAlias(highestBid.generalId, hiddenSeed, configConst)
|
||||
: names.get(highestBid.generalId) ?? '상인',
|
||||
isCaller: highestBid.generalId === general.id,
|
||||
eventAt: highestBid.eventAt.toISOString(),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
resourceAuctions: mapped.filter((auction) => auction.type !== 'UNIQUE_ITEM'),
|
||||
uniqueAuctions: mapped.filter((auction) => auction.type === 'UNIQUE_ITEM'),
|
||||
callerAlias,
|
||||
remainPoint: point?.value ?? 0,
|
||||
recentLogs: recentLogs.map((log) => ({
|
||||
id: log.id,
|
||||
text: log.text,
|
||||
createdAt: log.createdAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}),
|
||||
getUniqueDetail: authedProcedure
|
||||
.input(z.object({ auctionId: z.number().int().positive() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const auth = requireAuth(ctx);
|
||||
const general = await ensureGeneral(ctx.db, auth.user.id);
|
||||
const [auction, worldState, point] = await Promise.all([
|
||||
ctx.db.auction.findFirst({
|
||||
where: { id: input.auctionId, type: 'UNIQUE_ITEM' },
|
||||
include: { bids: { orderBy: [{ amount: 'desc' }, { id: 'asc' }] } },
|
||||
}),
|
||||
ctx.db.worldState.findFirst(),
|
||||
ctx.db.inheritancePoint.findUnique({
|
||||
where: { userId_key: { userId: auth.user.id, key: 'previous' } },
|
||||
}),
|
||||
]);
|
||||
if (!auction) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Auction not found.' });
|
||||
}
|
||||
const worldMeta = asRecord(worldState?.meta);
|
||||
const configConst = asRecord(asRecord(worldState?.config).const);
|
||||
const hiddenSeed =
|
||||
typeof worldMeta.hiddenSeed === 'string' || typeof worldMeta.hiddenSeed === 'number'
|
||||
? worldMeta.hiddenSeed
|
||||
: worldState?.id ?? 0;
|
||||
return {
|
||||
auction: {
|
||||
id: auction.id,
|
||||
targetCode: auction.targetCode,
|
||||
status: auction.status,
|
||||
hostName:
|
||||
auction.hostName ?? buildAuctionAlias(auction.hostGeneralId, hiddenSeed, configConst),
|
||||
isCallerHost: auction.hostGeneralId === general.id,
|
||||
closeAt: auction.closeAt.toISOString(),
|
||||
detail: parseDetail(auction.detail),
|
||||
},
|
||||
bids: auction.bids.map((bid) => ({
|
||||
id: bid.id,
|
||||
amount: bid.amount,
|
||||
bidderName: buildAuctionAlias(bid.generalId, hiddenSeed, configConst),
|
||||
isCaller: bid.generalId === general.id,
|
||||
eventAt: bid.eventAt.toISOString(),
|
||||
})),
|
||||
callerAlias: buildAuctionAlias(general.id, hiddenSeed, configConst),
|
||||
remainPoint: point?.value ?? 0,
|
||||
};
|
||||
}),
|
||||
openBuyRice: authedProcedure.input(zOpenResourceInput).mutation(async ({ ctx, input }) => {
|
||||
const auth = requireAuth(ctx);
|
||||
const general = await ensureGeneral(ctx.db, auth.user.id);
|
||||
return openAuctionWithDaemon(ctx, general.id, { auctionType: 'BUY_RICE', ...input });
|
||||
}),
|
||||
openSellRice: authedProcedure.input(zOpenResourceInput).mutation(async ({ ctx, input }) => {
|
||||
const auth = requireAuth(ctx);
|
||||
const general = await ensureGeneral(ctx.db, auth.user.id);
|
||||
return openAuctionWithDaemon(ctx, general.id, { auctionType: 'SELL_RICE', ...input });
|
||||
}),
|
||||
openUnique: authedProcedure.input(zOpenUniqueInput).mutation(async ({ ctx, input }) => {
|
||||
const auth = requireAuth(ctx);
|
||||
const general = await ensureGeneral(ctx.db, auth.user.id);
|
||||
return openAuctionWithDaemon(ctx, general.id, {
|
||||
auctionType: 'UNIQUE_ITEM',
|
||||
itemKey: input.itemKey,
|
||||
amount: input.amount,
|
||||
});
|
||||
}),
|
||||
bidBuyRice: authedProcedure.input(zBidInput).mutation(async ({ ctx, input }) => {
|
||||
const auth = requireAuth(ctx);
|
||||
const general = await ensureGeneral(ctx.db, auth.user.id);
|
||||
@@ -176,6 +354,9 @@ export const auctionRouter = router({
|
||||
if (auction.closeAt <= now) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '경매가 종료되었습니다.' });
|
||||
}
|
||||
if (auction.hostGeneralId === general.id) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '자신이 연 경매에 입찰할 수 없습니다.' });
|
||||
}
|
||||
|
||||
const detail = parseDetail(auction.detail);
|
||||
const amount = detail.amount ?? 0;
|
||||
@@ -183,6 +364,9 @@ export const auctionRouter = router({
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '거래량 정보가 없습니다.' });
|
||||
}
|
||||
const isReverse = detail.isReverse === true;
|
||||
if (!isReverse && detail.finishBidAmount != null && input.amount > detail.finishBidAmount) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '즉시판매가보다 높을 수 없습니다.' });
|
||||
}
|
||||
const highestBid = await loadHighestBid(ctx.db, auction.id, isReverse);
|
||||
const myPrevBidRaw = await loadMyPrevBid(ctx.db, auction.id, general.id, isReverse);
|
||||
const myPrevBid = shouldUsePrevBid(highestBid, myPrevBidRaw);
|
||||
@@ -241,6 +425,9 @@ export const auctionRouter = router({
|
||||
if (auction.closeAt <= now) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '경매가 종료되었습니다.' });
|
||||
}
|
||||
if (auction.hostGeneralId === general.id) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '자신이 연 경매에 입찰할 수 없습니다.' });
|
||||
}
|
||||
|
||||
const detail = parseDetail(auction.detail);
|
||||
const amount = detail.amount ?? 0;
|
||||
@@ -248,6 +435,9 @@ export const auctionRouter = router({
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '거래량 정보가 없습니다.' });
|
||||
}
|
||||
const isReverse = detail.isReverse === true;
|
||||
if (!isReverse && detail.finishBidAmount != null && input.amount > detail.finishBidAmount) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '즉시판매가보다 높을 수 없습니다.' });
|
||||
}
|
||||
const highestBid = await loadHighestBid(ctx.db, auction.id, isReverse);
|
||||
const myPrevBidRaw = await loadMyPrevBid(ctx.db, auction.id, general.id, isReverse);
|
||||
const myPrevBid = shouldUsePrevBid(highestBid, myPrevBidRaw);
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
writeUserStateMeta,
|
||||
} from '../../services/inheritance.js';
|
||||
import type { GameApiContext, WorldStateRow } from '../../context.js';
|
||||
import { openAuctionWithDaemon } from '../../auction/open.js';
|
||||
|
||||
const BUFF_KEYS: InheritBuffType[] = [
|
||||
'warAvoidRatio',
|
||||
@@ -749,43 +750,19 @@ export const inheritRouter = router({
|
||||
if (input.amount < inheritConst.inheritItemUniqueMinPoint) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '입찰 포인트가 부족합니다.' });
|
||||
}
|
||||
const currentPoint = await readInheritancePoint(ctx.db, userId, 'previous');
|
||||
if (currentPoint < input.amount) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '유산 포인트가 부족합니다.' });
|
||||
}
|
||||
|
||||
const general = await ctx.db.general.findFirst({
|
||||
where: { userId },
|
||||
select: { id: true, meta: true },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!general) {
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' });
|
||||
}
|
||||
const meta = asRecord(general.meta);
|
||||
if (meta.inheritSpecificUnique) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 유니크 경매 신청이 있습니다.' });
|
||||
}
|
||||
|
||||
await patchGeneral(ctx, general.id, {
|
||||
meta: {
|
||||
...meta,
|
||||
inheritSpecificUnique: JSON.stringify({
|
||||
itemId: input.itemId,
|
||||
amount: input.amount,
|
||||
requestedAt: new Date().toISOString(),
|
||||
}),
|
||||
},
|
||||
const result = await openAuctionWithDaemon(ctx, general.id, {
|
||||
auctionType: 'UNIQUE_ITEM',
|
||||
itemKey: input.itemId,
|
||||
amount: input.amount,
|
||||
});
|
||||
|
||||
await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - input.amount);
|
||||
await appendInheritanceLog(
|
||||
ctx.db,
|
||||
userId,
|
||||
worldState.currentYear,
|
||||
worldState.currentMonth,
|
||||
`${input.amount} 포인트로 유니크 경매 신청`
|
||||
);
|
||||
return { ok: true };
|
||||
return { ok: true, ...result };
|
||||
}),
|
||||
checkOwner: authedProcedure
|
||||
.input(
|
||||
|
||||
@@ -2,7 +2,7 @@ import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
import type { WorldStateRow } from '../../context.js';
|
||||
import type { DatabaseClient, WorldStateRow } from '../../context.js';
|
||||
import { authedProcedure, router } from '../../trpc.js';
|
||||
import { asNumber, asRecord, asStringArray, LiteHashDRBG } from '@sammo-ts/common';
|
||||
import {
|
||||
@@ -361,7 +361,7 @@ export const joinRouter = router({
|
||||
? input.character
|
||||
: 'None';
|
||||
|
||||
return ctx.db.$transaction!(async (db) => {
|
||||
const createGeneral = async (db: DatabaseClient) => {
|
||||
const existing = await db.general.findFirst({ where: { userId } });
|
||||
if (existing) {
|
||||
throw new TRPCError({
|
||||
@@ -514,6 +514,7 @@ export const joinRouter = router({
|
||||
meta: {
|
||||
createdBy: 'join',
|
||||
ownerName: ctx.auth?.user.displayName ?? '',
|
||||
killturn: 24,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -538,7 +539,9 @@ export const joinRouter = router({
|
||||
}
|
||||
|
||||
return { ok: true, generalId: general.id };
|
||||
});
|
||||
};
|
||||
|
||||
return ctx.db.$transaction ? ctx.db.$transaction(createGeneral) : createGeneral(ctx.db);
|
||||
}),
|
||||
listPossessCandidates: authedProcedure
|
||||
.input(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { asNumber, asRecord } from '@sammo-ts/common';
|
||||
import { GamePrisma } from '@sammo-ts/infra';
|
||||
import type { DatabaseClient, WorldStateRow, InputJsonValue } from '../context.js';
|
||||
|
||||
export type InheritPointKey =
|
||||
@@ -116,18 +117,15 @@ export const readInheritancePoint = async (
|
||||
userId: string,
|
||||
key: InheritPointKey
|
||||
): Promise<number> => {
|
||||
const row = await db.inheritancePoint.findUnique({
|
||||
where: {
|
||||
userId_key: {
|
||||
userId,
|
||||
key,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
value: true,
|
||||
},
|
||||
});
|
||||
return row?.value ?? 0;
|
||||
const rows = await db.$queryRaw<Array<{ value: number }>>(
|
||||
GamePrisma.sql`
|
||||
SELECT value
|
||||
FROM inheritance_point
|
||||
WHERE user_id = ${userId} AND key = ${key}
|
||||
FOR UPDATE
|
||||
`
|
||||
);
|
||||
return rows[0]?.value ?? 0;
|
||||
};
|
||||
|
||||
export const setInheritancePoint = async (
|
||||
|
||||
@@ -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
|
||||
`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -4,6 +4,7 @@ import PublicView from '../views/PublicView.vue';
|
||||
import LoginView from '../views/LoginView.vue';
|
||||
import JoinView from '../views/JoinView.vue';
|
||||
import InheritView from '../views/InheritView.vue';
|
||||
import AuctionView from '../views/AuctionView.vue';
|
||||
import NationCitiesView from '../views/NationCitiesView.vue';
|
||||
import NationGeneralsView from '../views/NationGeneralsView.vue';
|
||||
import NationPersonnelView from '../views/NationPersonnelView.vue';
|
||||
@@ -70,6 +71,15 @@ const routes = [
|
||||
requiresGeneral: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/auction',
|
||||
name: 'auction',
|
||||
component: AuctionView,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresGeneral: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/nation/cities',
|
||||
name: 'nation-cities',
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import PanelCard from '../components/ui/PanelCard.vue';
|
||||
import SkeletonLines from '../components/ui/SkeletonLines.vue';
|
||||
import { formatLog } from '../utils/formatLog';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
type AuctionOverview = Awaited<ReturnType<typeof trpc.auction.getOverview.query>>;
|
||||
type ResourceAuction = AuctionOverview['resourceAuctions'][number];
|
||||
type UniqueAuction = AuctionOverview['uniqueAuctions'][number];
|
||||
type UniqueDetail = Awaited<ReturnType<typeof trpc.auction.getUniqueDetail.query>>;
|
||||
|
||||
const activeTab = ref<'resource' | 'unique'>('resource');
|
||||
const loading = ref(false);
|
||||
const actionBusy = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const message = ref<string | null>(null);
|
||||
const overview = ref<AuctionOverview | null>(null);
|
||||
const selectedResource = ref<ResourceAuction | null>(null);
|
||||
const selectedUnique = ref<UniqueAuction | null>(null);
|
||||
const uniqueDetail = ref<UniqueDetail | null>(null);
|
||||
const bidAmount = ref(0);
|
||||
|
||||
const openForm = reactive({
|
||||
type: 'BUY_RICE' as 'BUY_RICE' | 'SELL_RICE',
|
||||
amount: 1000,
|
||||
closeTurnCnt: 24,
|
||||
startBidAmount: 500,
|
||||
finishBidAmount: 2000,
|
||||
});
|
||||
|
||||
const resolveErrorMessage = (value: unknown): string => {
|
||||
if (value instanceof Error) {
|
||||
return value.message;
|
||||
}
|
||||
return typeof value === 'string' ? value : 'unknown_error';
|
||||
};
|
||||
|
||||
const formatNumber = (value: number | null | undefined): string => (value ?? 0).toLocaleString();
|
||||
const formatDate = (value: string): string =>
|
||||
new Intl.DateTimeFormat('ko-KR', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
}).format(new Date(value));
|
||||
|
||||
const resourceTitle = (auction: ResourceAuction): string =>
|
||||
auction.type === 'BUY_RICE' ? '쌀 구매' : '쌀 판매';
|
||||
const hostResource = (auction: ResourceAuction): string => (auction.type === 'BUY_RICE' ? '쌀' : '금');
|
||||
const bidResource = (auction: ResourceAuction): string => (auction.type === 'BUY_RICE' ? '금' : '쌀');
|
||||
|
||||
const resourceAuctions = computed(() => overview.value?.resourceAuctions ?? []);
|
||||
const uniqueAuctions = computed(() => overview.value?.uniqueAuctions ?? []);
|
||||
|
||||
const loadOverview = async (): Promise<void> => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
overview.value = await trpc.auction.getOverview.query();
|
||||
if (selectedResource.value) {
|
||||
selectedResource.value =
|
||||
overview.value.resourceAuctions.find((auction) => auction.id === selectedResource.value?.id) ?? null;
|
||||
}
|
||||
if (selectedUnique.value) {
|
||||
selectedUnique.value =
|
||||
overview.value.uniqueAuctions.find((auction) => auction.id === selectedUnique.value?.id) ?? null;
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = resolveErrorMessage(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const selectResource = (auction: ResourceAuction): void => {
|
||||
selectedResource.value = auction;
|
||||
bidAmount.value = auction.highestBid?.amount ?? auction.detail.startBidAmount ?? 0;
|
||||
};
|
||||
|
||||
const selectUnique = async (auction: UniqueAuction): Promise<void> => {
|
||||
selectedUnique.value = auction;
|
||||
error.value = null;
|
||||
try {
|
||||
uniqueDetail.value = await trpc.auction.getUniqueDetail.query({ auctionId: auction.id });
|
||||
const highest = uniqueDetail.value.bids[0]?.amount ?? auction.detail.startBidAmount ?? 0;
|
||||
bidAmount.value = Math.max(Math.ceil(highest * 1.01), highest + 10);
|
||||
} catch (err) {
|
||||
error.value = resolveErrorMessage(err);
|
||||
}
|
||||
};
|
||||
|
||||
const runAction = async (action: () => Promise<void>): Promise<void> => {
|
||||
if (actionBusy.value) {
|
||||
return;
|
||||
}
|
||||
actionBusy.value = true;
|
||||
error.value = null;
|
||||
message.value = null;
|
||||
try {
|
||||
await action();
|
||||
await loadOverview();
|
||||
} catch (err) {
|
||||
error.value = resolveErrorMessage(err);
|
||||
} finally {
|
||||
actionBusy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openResourceAuction = (): Promise<void> =>
|
||||
runAction(async () => {
|
||||
const input = {
|
||||
amount: openForm.amount,
|
||||
closeTurnCnt: openForm.closeTurnCnt,
|
||||
startBidAmount: openForm.startBidAmount,
|
||||
finishBidAmount: openForm.finishBidAmount,
|
||||
};
|
||||
const result =
|
||||
openForm.type === 'BUY_RICE'
|
||||
? await trpc.auction.openBuyRice.mutate(input)
|
||||
: await trpc.auction.openSellRice.mutate(input);
|
||||
message.value = `${result.auctionId}번 경매로 등록되었습니다.`;
|
||||
});
|
||||
|
||||
const bidResourceAuction = (): Promise<void> =>
|
||||
runAction(async () => {
|
||||
const auction = selectedResource.value;
|
||||
if (!auction) {
|
||||
return;
|
||||
}
|
||||
if (auction.isCallerHost) {
|
||||
throw new Error('자신이 연 경매에 입찰할 수 없습니다.');
|
||||
}
|
||||
if (auction.type === 'BUY_RICE') {
|
||||
await trpc.auction.bidBuyRice.mutate({ auctionId: auction.id, amount: bidAmount.value });
|
||||
} else {
|
||||
await trpc.auction.bidSellRice.mutate({ auctionId: auction.id, amount: bidAmount.value });
|
||||
}
|
||||
message.value = `${auction.id}번 경매에 입찰했습니다.`;
|
||||
});
|
||||
|
||||
const bidUniqueAuction = (): Promise<void> =>
|
||||
runAction(async () => {
|
||||
const auction = selectedUnique.value;
|
||||
if (!auction) {
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(`${auction.detail.title ?? auction.targetCode ?? '유니크'}에 ${bidAmount.value} 포인트를 입찰하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
await trpc.auction.bidUnique.mutate({
|
||||
auctionId: auction.id,
|
||||
amount: bidAmount.value,
|
||||
tryExtendCloseDate: true,
|
||||
});
|
||||
message.value = `${auction.id}번 유니크 경매에 입찰했습니다.`;
|
||||
await selectUnique(auction);
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
void loadOverview();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="auction-page">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>거래장</h1>
|
||||
<p>금·쌀 거래와 유니크 아이템 경매를 확인합니다.</p>
|
||||
</div>
|
||||
<button class="ghost" :disabled="loading" @click="loadOverview">새로고침</button>
|
||||
</header>
|
||||
|
||||
<nav class="tabs" aria-label="경매 종류">
|
||||
<button :class="{ active: activeTab === 'resource' }" @click="activeTab = 'resource'">금·쌀 경매</button>
|
||||
<button :class="{ active: activeTab === 'unique' }" @click="activeTab = 'unique'">유니크 경매</button>
|
||||
</nav>
|
||||
|
||||
<p v-if="error" class="notice error">{{ error }}</p>
|
||||
<p v-if="message" class="notice success">{{ message }}</p>
|
||||
<SkeletonLines v-if="loading && !overview" :lines="8" />
|
||||
|
||||
<template v-else-if="activeTab === 'resource'">
|
||||
<PanelCard title="진행 중인 금·쌀 경매" subtitle="행을 선택하면 아래에서 입찰할 수 있습니다.">
|
||||
<div class="auction-table resource-table">
|
||||
<div class="table-head">
|
||||
<span>번호</span><span>종류</span><span>판매자</span><span>수량</span><span>입찰자</span>
|
||||
<span>현재가</span><span>마감가</span><span>종료</span>
|
||||
</div>
|
||||
<button
|
||||
v-for="auction in resourceAuctions"
|
||||
:key="auction.id"
|
||||
class="table-row"
|
||||
:class="{ selected: selectedResource?.id === auction.id }"
|
||||
@click="selectResource(auction)"
|
||||
>
|
||||
<span>{{ auction.id }}</span>
|
||||
<span>{{ resourceTitle(auction) }}</span>
|
||||
<span>{{ auction.hostName }}</span>
|
||||
<span>{{ hostResource(auction) }} {{ formatNumber(auction.detail.amount) }}</span>
|
||||
<span>{{ auction.highestBid?.bidderName ?? '-' }}</span>
|
||||
<span>{{ bidResource(auction) }} {{ formatNumber(auction.highestBid?.amount ?? auction.detail.startBidAmount) }}</span>
|
||||
<span>{{ bidResource(auction) }} {{ formatNumber(auction.detail.finishBidAmount) }}</span>
|
||||
<span>{{ formatDate(auction.closeAt) }}</span>
|
||||
</button>
|
||||
<p v-if="resourceAuctions.length === 0" class="empty">진행 중인 경매가 없습니다.</p>
|
||||
</div>
|
||||
|
||||
<form v-if="selectedResource" class="bid-form" @submit.prevent="bidResourceAuction">
|
||||
<strong>{{ selectedResource.id }}번 {{ resourceTitle(selectedResource) }}</strong>
|
||||
<label>
|
||||
<span>입찰가 ({{ bidResource(selectedResource) }})</span>
|
||||
<input v-model.number="bidAmount" type="number" min="1" step="10" required />
|
||||
</label>
|
||||
<button :disabled="actionBusy || selectedResource.isCallerHost">입찰</button>
|
||||
</form>
|
||||
</PanelCard>
|
||||
|
||||
<PanelCard title="경매 등록" subtitle="레거시와 동일하게 한 장수는 자원 경매를 한 건만 진행할 수 있습니다.">
|
||||
<form class="open-form" @submit.prevent="openResourceAuction">
|
||||
<label>
|
||||
<span>매물</span>
|
||||
<select v-model="openForm.type">
|
||||
<option value="BUY_RICE">쌀</option>
|
||||
<option value="SELL_RICE">금</option>
|
||||
</select>
|
||||
</label>
|
||||
<label><span>수량</span><input v-model.number="openForm.amount" type="number" min="100" max="10000" step="10" /></label>
|
||||
<label><span>기간(턴)</span><input v-model.number="openForm.closeTurnCnt" type="number" min="1" max="24" /></label>
|
||||
<label><span>시작가</span><input v-model.number="openForm.startBidAmount" type="number" min="1" step="10" /></label>
|
||||
<label><span>마감가</span><input v-model.number="openForm.finishBidAmount" type="number" min="1" step="10" /></label>
|
||||
<button :disabled="actionBusy">등록</button>
|
||||
</form>
|
||||
</PanelCard>
|
||||
|
||||
<PanelCard title="이전 경매" subtitle="최근 경매 기록 20건">
|
||||
<ol class="log-list">
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<li v-for="log in overview?.recentLogs ?? []" :key="log.id" v-html="formatLog(log.text)" />
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
<li v-if="(overview?.recentLogs.length ?? 0) === 0" class="empty">경매 기록이 없습니다.</li>
|
||||
</ol>
|
||||
</PanelCard>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<PanelCard title="유니크 경매" :subtitle="`내 가명: ${overview?.callerAlias ?? '-'}`">
|
||||
<div class="auction-table unique-table">
|
||||
<div class="table-head">
|
||||
<span>번호</span><span>경매명</span><span>주최자</span><span>종료</span><span>1순위</span><span>포인트</span>
|
||||
</div>
|
||||
<button
|
||||
v-for="auction in uniqueAuctions"
|
||||
:key="auction.id"
|
||||
class="table-row"
|
||||
:class="{ selected: selectedUnique?.id === auction.id }"
|
||||
@click="selectUnique(auction)"
|
||||
>
|
||||
<span>{{ auction.id }}</span>
|
||||
<span>{{ auction.detail.title ?? auction.targetCode }}</span>
|
||||
<span :class="{ me: auction.isCallerHost }">{{ auction.hostName }}</span>
|
||||
<span>{{ formatDate(auction.closeAt) }}</span>
|
||||
<span :class="{ me: auction.highestBid?.isCaller }">{{ auction.highestBid?.bidderName ?? '-' }}</span>
|
||||
<span>{{ formatNumber(auction.highestBid?.amount ?? auction.detail.startBidAmount) }}</span>
|
||||
</button>
|
||||
<p v-if="uniqueAuctions.length === 0" class="empty">유니크 경매가 없습니다.</p>
|
||||
</div>
|
||||
</PanelCard>
|
||||
|
||||
<PanelCard v-if="uniqueDetail" title="유니크 경매 상세">
|
||||
<dl class="detail-grid">
|
||||
<dt>경매명</dt><dd>{{ uniqueDetail.auction.detail.title ?? uniqueDetail.auction.targetCode }}</dd>
|
||||
<dt>주최자(익명)</dt><dd :class="{ me: uniqueDetail.auction.isCallerHost }">{{ uniqueDetail.auction.hostName }}</dd>
|
||||
<dt>종료일시</dt><dd>{{ formatDate(uniqueDetail.auction.closeAt) }}</dd>
|
||||
<dt>잔여 포인트</dt><dd>{{ formatNumber(uniqueDetail.remainPoint) }}</dd>
|
||||
</dl>
|
||||
<div class="bid-history">
|
||||
<div v-for="bid in uniqueDetail.bids" :key="bid.id" class="bid-entry">
|
||||
<span :class="{ me: bid.isCaller }">{{ bid.bidderName }}</span>
|
||||
<strong>{{ formatNumber(bid.amount) }}</strong>
|
||||
<time>{{ formatDate(bid.eventAt) }}</time>
|
||||
</div>
|
||||
</div>
|
||||
<form v-if="uniqueDetail.auction.status === 'OPEN'" class="bid-form" @submit.prevent="bidUniqueAuction">
|
||||
<label><span>유산 포인트</span><input v-model.number="bidAmount" type="number" min="1" required /></label>
|
||||
<button :disabled="actionBusy">입찰</button>
|
||||
</form>
|
||||
</PanelCard>
|
||||
</template>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auction-page {
|
||||
min-height: 100%;
|
||||
padding: 18px;
|
||||
color: #e8ddc4;
|
||||
background: radial-gradient(circle at top, rgba(93, 57, 26, 0.25), transparent 42%), #080807;
|
||||
}
|
||||
.page-header, .tabs, .bid-form, .open-form, .detail-grid, .bid-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.page-header { justify-content: space-between; gap: 16px; margin-bottom: 12px; }
|
||||
.page-header h1 { margin: 0; font-size: 1.45rem; }
|
||||
.page-header p { margin: 4px 0 0; color: rgba(232, 221, 196, 0.7); }
|
||||
.tabs { gap: 6px; margin-bottom: 12px; }
|
||||
button, input, select {
|
||||
border: 1px solid rgba(201, 164, 90, 0.55);
|
||||
background: rgba(20, 17, 12, 0.95);
|
||||
color: #e8ddc4;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
button { cursor: pointer; }
|
||||
button:hover, button:focus-visible, button.active { background: rgba(201, 164, 90, 0.22); }
|
||||
button:disabled { cursor: not-allowed; opacity: 0.45; }
|
||||
.ghost { background: transparent; }
|
||||
.notice { padding: 9px 12px; border: 1px solid; }
|
||||
.notice.error { color: #ffb3a9; border-color: rgba(255, 90, 70, 0.45); }
|
||||
.notice.success { color: #b9e6af; border-color: rgba(94, 177, 75, 0.45); }
|
||||
.auction-page :deep(.panel-card) { margin-bottom: 12px; }
|
||||
.auction-table { overflow-x: auto; }
|
||||
.table-head, .table-row { display: grid; min-width: 820px; align-items: center; text-align: center; }
|
||||
.resource-table .table-head, .resource-table .table-row { grid-template-columns: 52px 84px 1fr 1fr 1fr 1fr 1fr 150px; }
|
||||
.unique-table .table-head, .unique-table .table-row { grid-template-columns: 52px 2fr 1fr 150px 1fr 110px; }
|
||||
.table-head { border-bottom: 1px solid rgba(232, 221, 196, 0.4); padding: 7px; color: rgba(232, 221, 196, 0.7); }
|
||||
.table-row { width: 100%; border: 0; border-bottom: 1px solid rgba(232, 221, 196, 0.12); background: transparent; }
|
||||
.table-row.selected { background: rgba(201, 164, 90, 0.18); }
|
||||
.table-row > span { padding: 8px 5px; }
|
||||
.bid-form { justify-content: center; gap: 12px; margin-top: 14px; flex-wrap: wrap; }
|
||||
.bid-form label, .open-form label { display: grid; gap: 5px; }
|
||||
.open-form { align-items: end; gap: 10px; flex-wrap: wrap; }
|
||||
.open-form label { min-width: 110px; flex: 1; }
|
||||
.empty { padding: 14px; text-align: center; color: rgba(232, 221, 196, 0.6); }
|
||||
.log-list { margin: 0; padding-left: 24px; }
|
||||
.log-list li { padding: 4px 0; }
|
||||
.detail-grid { display: grid; grid-template-columns: 130px 1fr 130px 1fr; gap: 1px; background: rgba(232, 221, 196, 0.18); }
|
||||
.detail-grid dt, .detail-grid dd { margin: 0; padding: 9px; background: #11100d; }
|
||||
.detail-grid dt { color: rgba(232, 221, 196, 0.65); }
|
||||
.bid-history { margin-top: 12px; }
|
||||
.bid-entry { justify-content: space-between; gap: 12px; padding: 7px 10px; border-bottom: 1px solid rgba(232, 221, 196, 0.14); }
|
||||
.bid-entry time { color: rgba(232, 221, 196, 0.65); }
|
||||
.me { color: aquamarine; font-weight: 700; }
|
||||
@media (max-width: 720px) {
|
||||
.auction-page { padding: 10px; }
|
||||
.page-header { align-items: flex-start; }
|
||||
.detail-grid { grid-template-columns: 110px 1fr; }
|
||||
}
|
||||
</style>
|
||||
@@ -106,6 +106,7 @@ watch(
|
||||
<RouterLink class="ghost" to="/battle-simulator">전투 시뮬레이터</RouterLink>
|
||||
<RouterLink class="ghost" to="/my-page">내 정보</RouterLink>
|
||||
<RouterLink class="ghost" to="/tournament">토너먼트</RouterLink>
|
||||
<RouterLink class="ghost" to="/auction">거래장</RouterLink>
|
||||
<RouterLink class="ghost" to="/survey">설문조사</RouterLink>
|
||||
<RouterLink class="ghost" to="/npc-control">NPC 정책</RouterLink>
|
||||
<RouterLink class="ghost" to="/inherit">유산 강화</RouterLink>
|
||||
|
||||
@@ -134,6 +134,7 @@ const ensureAdminGeneral = async (databaseUrl: string, adminUser: AdminSeedUser)
|
||||
turnTime,
|
||||
meta: {
|
||||
createdBy: 'admin-seed',
|
||||
killturn: 24,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,720 @@
|
||||
# 일반 장수 명령 차등 테스트 설계
|
||||
|
||||
## 상태
|
||||
|
||||
- 문서 상태: 구현 전 승인 가능한 설계
|
||||
- 비교 기준: `ref/sam`의 `ng_compare` 브랜치
|
||||
- 대상: 휴식과 `cr_건국`을 포함한 일반 장수 예약 명령 55개
|
||||
- 범위: 명령 결과, RNG 소비, 로그, 예약 턴 lifecycle, DB 영속화
|
||||
|
||||
이 문서는 테스트 구현 자체가 아니다. 아래의 파일, 실행기, 격리 스택과
|
||||
fixture가 구현되고 완료 기준을 통과하기 전까지 55개 명령의 동적 호환 상태를
|
||||
`확인`으로 올리지 않는다.
|
||||
|
||||
## 결정 요약
|
||||
|
||||
일반 장수 명령은 하나의 canonical fixture로 다음 세 결과를 만든다.
|
||||
|
||||
1. ref PHP가 격리된 MariaDB에서 실제 예약 턴을 실행한 결과
|
||||
2. core2026이 `InMemoryTurnWorld`에서 같은 예약 턴을 실행한 결과
|
||||
3. 2의 dirty state를 격리된 PostgreSQL에 flush하고 다시 읽은 결과
|
||||
|
||||
두 비교를 모두 통과해야 한다.
|
||||
|
||||
```text
|
||||
canonical fixture
|
||||
│
|
||||
├── ref fixture adapter ──> PHP full turn ──> MariaDB ──> ref projection
|
||||
│ │
|
||||
└── core fixture adapter ─> InMemory full turn ──────────┼─ exact semantic diff
|
||||
│ │
|
||||
└── DB hooks ─> PostgreSQL ─┘
|
||||
```
|
||||
|
||||
- `ref projection == core memory projection`은 호환 로직을 검증한다.
|
||||
- `core memory projection == core persisted projection`은 loader/flush를
|
||||
검증한다.
|
||||
- raw MariaDB dump와 raw PostgreSQL dump는 비교하지 않는다. 테이블 구조와
|
||||
필드 소유권이 다르므로 의미 필드로 정규화한 JSON을 비교한다.
|
||||
- 명령 결과와 RNG는 원칙적으로 exact 비교한다. 저장 레이아웃 차이만
|
||||
projection에서 제거한다.
|
||||
|
||||
## 목표와 비목표
|
||||
|
||||
### 목표
|
||||
|
||||
- 동일한 게임 상태, 명령, 인자, 시각과 seed로 ref/core를 실행한다.
|
||||
- 성공, 실행 중 실패, 제약 실패, alternative, 다중 턴과 전처리 경로를
|
||||
구분한다.
|
||||
- 장수·도시·국가·부대·외교·예약 큐·rank·로그 등 모든 관찰 가능한
|
||||
side effect를 비교한다.
|
||||
- RNG의 domain, 호출 순서, 연산, 인자와 결과를 비교한다.
|
||||
- core 메모리 결과가 실제 PostgreSQL에 같은 의미로 저장되고 재시작 후
|
||||
같은 상태로 로드되는지 검증한다.
|
||||
- 명령이 예상 밖의 raw DB 필드를 바꾸면 projection 누락으로 실패시킨다.
|
||||
- 각 명령의 테스트 근거와 아직 없는 경로를 기계적으로 집계한다.
|
||||
|
||||
### 비목표
|
||||
|
||||
- MariaDB와 PostgreSQL의 물리 schema, sequence, index 또는 내부 row ID를
|
||||
동일하게 만드는 일
|
||||
- 레거시 제품 브랜치 `devel`에 비교 endpoint를 추가하는 일
|
||||
- 운영/개발 DB를 초기화하거나 기존 ref DB를 fixture 저장소로 사용하는 일
|
||||
- 현재 구현을 정답으로 삼아 snapshot을 자동 승인하는 일
|
||||
- UI와 API 요청 형식 검증을 이 suite 하나로 대체하는 일
|
||||
|
||||
## 테스트 계층
|
||||
|
||||
### 1. core logic regression
|
||||
|
||||
기존 `InMemoryTurnWorld`와 예약 턴 handler를 사용한다. 빠른 테스트이며
|
||||
fixture의 core memory projection을 단언한다. DB 제약이나 직렬화는 보장하지
|
||||
않는다.
|
||||
|
||||
### 2. ref ↔ core differential integration
|
||||
|
||||
Docker의 ref PHP CLI와 격리 MariaDB를 호출하므로 integration test로
|
||||
분류한다. 두 엔진의 canonical 결과와 RNG trace를 비교한다.
|
||||
|
||||
### 3. core persistence integration
|
||||
|
||||
동일한 core 실행 결과를 `databaseHooks`로 격리 PostgreSQL에 flush하고
|
||||
새 connection으로 다시 load한다. 메모리 projection과 재조회 projection을
|
||||
비교한다.
|
||||
|
||||
### 4. 선택적 daemon system test
|
||||
|
||||
예약 API, command queue와 daemon lifecycle까지 필요한 대표 명령만 별도
|
||||
system test로 둔다. 55개 수치 호환성 검증을 이 느린 계층에 모두 넣지 않는다.
|
||||
|
||||
## 제안 파일 구조
|
||||
|
||||
구현 시 다음 경계를 사용한다.
|
||||
|
||||
```text
|
||||
core2026/
|
||||
tools/integration-tests/
|
||||
fixtures/general/
|
||||
manifest.json
|
||||
base/
|
||||
scenario-2.json
|
||||
che_화계/
|
||||
success-basic.json
|
||||
failure-probability.json
|
||||
probability-clamp-max.json
|
||||
injury-and-item.json
|
||||
src/general-command/
|
||||
fixtureSchema.ts
|
||||
canonicalSchema.ts
|
||||
compareCanonical.ts
|
||||
referenceRunner.ts
|
||||
referenceProjection.ts
|
||||
coreMemoryRunner.ts
|
||||
coreMemoryProjection.ts
|
||||
coreDatabaseRunner.ts
|
||||
coreDatabaseProjection.ts
|
||||
changedPathAudit.ts
|
||||
databaseSandbox.ts
|
||||
tracingRng.ts
|
||||
test/
|
||||
generalCommandDifferential.test.ts
|
||||
generalCommandPersistence.test.ts
|
||||
generalCommandComparator.test.ts
|
||||
|
||||
ref/sam/ # ng_compare 전용
|
||||
hwe/compare/
|
||||
general_command_trace.php
|
||||
GeneralCommandFixture.php
|
||||
GeneralCommandProjection.php
|
||||
ComparisonTracingRNG.php
|
||||
|
||||
docker_compose_files/
|
||||
general-command-differential/
|
||||
compose.yml
|
||||
.env.example
|
||||
README.md
|
||||
scripts/
|
||||
prepare-secrets.sh
|
||||
initialize-templates.sh
|
||||
run-fixture.sh
|
||||
verify-isolation.sh
|
||||
secrets/
|
||||
mariadb_password.example
|
||||
postgres_password.example
|
||||
```
|
||||
|
||||
기존 `battleDifferential.test.ts`의 workspace 탐색, `docker compose exec`,
|
||||
stdin JSON 전달과 tracing RNG 패턴을 재사용한다. 일반 명령용 코드는 전투
|
||||
fixture와 섞지 않는다.
|
||||
|
||||
integration Vitest는 case DB 수명주기를 예측할 수 있도록
|
||||
`fileParallelism: false`, 기본 `testTimeout: 120_000`을 유지한다. CI
|
||||
sharding은 별도 Compose project와 worker prefix를 받은 프로세스 사이에서만
|
||||
수행한다.
|
||||
|
||||
## 실행 격리
|
||||
|
||||
### 전용 Compose stack
|
||||
|
||||
`general-command-differential`은 개발, ref UI, input-event E2E와 수명주기가
|
||||
다르므로 별도 Compose stack으로 둔다.
|
||||
|
||||
필수 service:
|
||||
|
||||
- `ref-db`: 고정 버전 MariaDB, 외부 port 미공개
|
||||
- `ref-runner`: 기존 ref PHP image, CLI 명령만 허용
|
||||
- `core-db`: 고정 버전 PostgreSQL, 프로젝트 전용 loopback port
|
||||
- 선택 profile `system`: Redis와 core daemon runner
|
||||
|
||||
DB data directory는 suite 전용 `tmpfs`를 기본으로 한다. 테스트가 중단되어도
|
||||
운영·개발 volume을 가리킬 수 없게 Compose project, container, network와
|
||||
port 이름을 별도로 고정한다.
|
||||
|
||||
실제 비밀값은 Git에서 제외된 secret file로만 주입한다. ref의 생성된
|
||||
`d_setting/DB.php` overlay는 `/run` 또는 `mktemp` 아래에 mode `0600`으로
|
||||
만들어 container에 read-only mount하고 종료 시 삭제한다. JSON 결과,
|
||||
명령행과 보고서에는 credential을 넣지 않는다.
|
||||
|
||||
### ref는 transaction rollback을 사용하지 않는다
|
||||
|
||||
레거시 `general`, `city`, `general_turn`, `general_record`, `rank_data` 등
|
||||
핵심 테이블은 Aria engine이다. 따라서 transaction rollback은 fixture
|
||||
격리를 보장하지 못한다.
|
||||
|
||||
ref 격리는 다음 순서로 수행한다.
|
||||
|
||||
1. suite 시작 시 schema와 비교 기준 scenario config로 immutable template
|
||||
DB 두 개(root/HWE)를 만든다.
|
||||
2. case마다 검증된 prefix
|
||||
`sammo_gc_ref_<worker>_<caseHash>`의 DB를 새로 만든다.
|
||||
3. template dump를 case DB에 복원하고 fixture override를 적용한다.
|
||||
4. case DB를 가리키는 임시 `RootDB.php`/`DB.php` overlay로 PHP CLI를
|
||||
한 번 실행한다.
|
||||
5. canonical 결과를 읽은 뒤 case DB를 삭제한다.
|
||||
6. cleanup 대상 이름이 허용 prefix와 정확히 일치하지 않으면 삭제를
|
||||
거부한다.
|
||||
|
||||
이 흐름은 기존 `test-fast-forward-sandbox.sh`의 DB 복제, DB 이름 override,
|
||||
원본 DB 불변 검사 패턴을 재사용한다. 기존 `sammo_ref_hwe`는 읽거나
|
||||
복제 기준으로도 사용하지 않고, suite가 직접 만든 template만 사용한다.
|
||||
|
||||
### core DB 격리
|
||||
|
||||
suite 전용 PostgreSQL 안에 `public`과 `che` schema 및 migration을 적용한
|
||||
template database를 만든다. case마다 template에서 새 database를 만들고
|
||||
case 종료 후 검증된 prefix에 한해 삭제한다.
|
||||
|
||||
각 case는 다음을 보장한다.
|
||||
|
||||
- 새 Prisma connection 사용
|
||||
- 명시적인 profile/scenario
|
||||
- fixture에 없는 이전 row가 없음
|
||||
- flush 이후 connection을 닫고 새 connection으로 재조회
|
||||
- dirty-state acknowledge는 DB commit 이후에만 수행
|
||||
- 실패한 case도 case database만 정리
|
||||
|
||||
현재 `.env.ci`가 가리키는 개발 DB의 `public`/`che` schema를 truncate하는
|
||||
기존 initialization test는 이 suite의 backend로 사용하지 않는다.
|
||||
|
||||
## 기준 scenario와 base state
|
||||
|
||||
fixture는 양쪽에 공통으로 존재하는 `scenario_2`의 rule, map과 unit set을
|
||||
사용한다. 전체 scenario의 NPC와 국가를 그대로 seed하면 검색, 정렬과
|
||||
무작위 후보가 fixture 밖 row에 영향을 받으므로 다음 base를 별도로 만든다.
|
||||
|
||||
- scenario config, `GameConst`, map과 unit set은 `scenario_2`에서 로드
|
||||
- map의 모든 도시는 중립 기본 row로 생성
|
||||
- 장수, 국가, 부대, 외교와 예약 턴은 fixture가 명시한 것만 생성
|
||||
- game/root env는 명령 생성과 턴 실행에 필요한 key 전체를 명시
|
||||
- 시간은 UTC ISO 문자열과 게임 연·월을 함께 고정
|
||||
- 자동 턴은 기본적으로 끄고 fixture가 요구할 때만 켬
|
||||
- 국가 예약 명령은 기본 휴식으로 고정
|
||||
- actor만 due 상태로 두고 대상/보조 장수의 turn time은 실행 범위 밖으로 둠
|
||||
- fixture의 test hidden seed를 임시 `UniqueConst.php` overlay와 core world
|
||||
meta 양쪽에 같은 값으로 주입
|
||||
|
||||
base generator가 양쪽 입력을 따로 만들되, 기준 값은 하나의 canonical
|
||||
base JSON에서 가져온다.
|
||||
|
||||
## Fixture 계약
|
||||
|
||||
fixture는 구현 내부 객체가 아니라 게임 의미를 기술한다. Zod schema와
|
||||
JSON Schema를 함께 생성하고 resource validation에 포함한다.
|
||||
|
||||
개념 예시는 다음과 같다.
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "che_화계/success-basic",
|
||||
"scenario": "scenario_2",
|
||||
"execution": {
|
||||
"mode": "full-turn",
|
||||
"year": 200,
|
||||
"month": 1,
|
||||
"turnTime": "0200-01-01T00:00:00.000Z",
|
||||
"hiddenSeed": "general-differential-test-seed",
|
||||
"actorGeneralId": 101,
|
||||
"command": {
|
||||
"key": "che_화계",
|
||||
"args": { "destCityId": 2 }
|
||||
},
|
||||
"autorun": false
|
||||
},
|
||||
"world": {
|
||||
"generals": [],
|
||||
"cities": [],
|
||||
"nations": [],
|
||||
"troops": [],
|
||||
"diplomacy": [],
|
||||
"generalTurns": [],
|
||||
"nationTurns": [],
|
||||
"rank": [],
|
||||
"events": [],
|
||||
"rootUsers": []
|
||||
},
|
||||
"observe": {
|
||||
"generalIds": [101, 201],
|
||||
"cityIds": [1, 2],
|
||||
"nationIds": [1, 2],
|
||||
"metaKeys": ["intel_exp", "firenum", "killturn", "myset", "inherit_lived_month"],
|
||||
"collections": ["logs", "generalTurns", "rank"]
|
||||
},
|
||||
"expect": {
|
||||
"outcome": "success"
|
||||
},
|
||||
"evidence": {
|
||||
"legacyFiles": ["hwe/sammo/Command/General/che_화계.php", "hwe/func.php"],
|
||||
"contract": "화계 성공 기본 경로"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
실제 fixture에는 생략 없이 모든 필수 entity field를 넣는다. `hiddenSeed`는
|
||||
테스트 전용 공개값이며 운영 seed를 복사하지 않는다.
|
||||
|
||||
### Fixture 불변식
|
||||
|
||||
- 양쪽에서 같은 numeric ID를 사용한다.
|
||||
- 이름과 정렬 순서에 영향을 주는 문자열도 명시한다.
|
||||
- `undefined`를 사용하지 않는다. 값 없음은 `null`, collection 없음은 `[]`,
|
||||
object 없음은 `{}`로 표현한다.
|
||||
- 자동 증가 DB ID는 fixture의 의미 식별자로 사용하지 않는다.
|
||||
- 확률 결과를 임의 stub으로 강제하지 않는다. seed와 입력 상태로 원하는
|
||||
분기를 만들고 RNG trace를 고정한다.
|
||||
- fixture의 기대값은 core 실행 결과에서 생성하지 않는다. ref trace와
|
||||
레거시 코드 근거를 함께 기록한다.
|
||||
- fixture update는 별도 review 대상이며 snapshot 자동 갱신 명령을
|
||||
제공하지 않는다.
|
||||
|
||||
## 실행 모드
|
||||
|
||||
### `full-turn`
|
||||
|
||||
기본 모드다. 양쪽의 실제 예약 턴 실행 순서를 거친다.
|
||||
|
||||
- lived-month 증가
|
||||
- preprocess trigger, 부상 회복, 병력 군량
|
||||
- block
|
||||
- 필요 시 국가 명령
|
||||
- 일반 명령 제약, term stack, cooldown, alternative
|
||||
- 명령별 RNG
|
||||
- queue shift
|
||||
- killturn, myset, autorun limit
|
||||
- next turn time
|
||||
- retirement/deletion
|
||||
- DB flush와 로그 확정
|
||||
|
||||
55개 호환 판정은 이 모드의 결과로 한다.
|
||||
|
||||
### `command-only`
|
||||
|
||||
수식과 RNG 분기를 좁게 진단하는 보조 모드다. 전체 호환 판정의 근거로
|
||||
단독 사용하지 않는다. full-turn 실패가 preprocess/queue 문제인지 명령
|
||||
resolver 문제인지 분리할 때 사용한다.
|
||||
|
||||
## ref runner 계약
|
||||
|
||||
`general_command_trace.php`는 다음 조건을 모두 만족해야 한다.
|
||||
|
||||
- `PHP_SAPI === 'cli'`
|
||||
- 명시적인 `SAMMO_GENERAL_COMPARE=1` guard
|
||||
- stdin의 fixture 한 개만 처리
|
||||
- case 전용 DB 이름 외 연결 거부
|
||||
- fixture seed 후 `TurnExecutionHelper`의 실제 경로 호출
|
||||
- logger를 flush하고 DB 결과를 읽은 뒤 JSON 한 개 출력
|
||||
- stdout에는 JSON만, 진단은 stderr
|
||||
- 기존 명령 계산·정렬·RNG·DB mutation 순서를 바꾸지 않음
|
||||
- 종료 전 원본 template/main DB가 변하지 않았음을 runner가 검사
|
||||
|
||||
출력은 engine-specific raw state와 trace를 담는다. canonical 변환은
|
||||
`referenceProjection.ts`가 수행한다. PHP와 TS projection이 서로의
|
||||
오류를 그대로 복제하지 않도록 한 구현을 공유하지 않는다.
|
||||
|
||||
현재 `TurnExecutionHelper`는 내부에서 RNG를 직접 생성하므로 `ng_compare`에
|
||||
최소 test seam이 필요하다. 기본값은 기존 `new RandUtil(new
|
||||
LiteHashDRBG(seed))`를 그대로 사용하고, CLI guard가 활성화된 경우에만
|
||||
같은 DRBG를 tracing proxy로 감싸는 factory를 주입한다. observer on/off에서
|
||||
동일 fixture의 DB 결과가 같다는 계측 무영향 테스트를 ref에 둔다.
|
||||
|
||||
## core runner 계약
|
||||
|
||||
### memory runner
|
||||
|
||||
- fixture를 `TurnWorldSnapshot`, `TurnWorldState`,
|
||||
`InMemoryReservedTurnStore`로 변환
|
||||
- production `createReservedTurnHandler`와 `InMemoryTurnProcessor` 사용
|
||||
- fixture가 선언한 한 장수의 한 due turn만 실행
|
||||
- 실행 전후 world, dirty state, queue, logs와 RNG trace 반환
|
||||
|
||||
`reservedTurnHandler`도 RNG를 내부 생성하므로 production default를 보존하는
|
||||
선택적 `rngFactory(domain, seed)` test seam을 둔다. 옵션을 생략한 경로는
|
||||
현재 구현과 byte-for-byte 같은 DRBG를 만들고, 테스트만 tracing wrapper를
|
||||
반환한다. seed 구성 자체를 runner에서 다시 구현하지 않는다.
|
||||
|
||||
### database runner
|
||||
|
||||
- 같은 fixture를 case PostgreSQL에 seed
|
||||
- production loader로 새 `InMemoryTurnWorld` 생성
|
||||
- 같은 handler/processor 실행
|
||||
- production `databaseHooks`로 commit
|
||||
- 모든 connection을 닫고 새 loader/Prisma query로 결과 재조회
|
||||
- memory projection과 persisted projection 비교
|
||||
|
||||
테스트 전용 runner가 `buildCityUpdate` 같은 private production helper를
|
||||
복제해 직접 호출해서는 안 된다. 반드시 production hook 경계를 지나야
|
||||
`City.state`/`City.meta.state`와 같은 투영 오류를 검출할 수 있다.
|
||||
|
||||
## RNG trace
|
||||
|
||||
RNG trace entry는 다음 형태다.
|
||||
|
||||
```json
|
||||
{
|
||||
"domain": "generalCommand",
|
||||
"sequence": 3,
|
||||
"operation": "nextInt",
|
||||
"arguments": { "maxInclusive": 99 },
|
||||
"result": 42
|
||||
}
|
||||
```
|
||||
|
||||
domain은 최소 다음을 구분한다.
|
||||
|
||||
- `preprocess`
|
||||
- `nationCommand`
|
||||
- `generalCommand`
|
||||
- `uniqueLottery`
|
||||
- 명령이 추가로 분리한 명시적 child domain
|
||||
|
||||
비교 규칙:
|
||||
|
||||
- entry 수 exact
|
||||
- domain과 sequence exact
|
||||
- primitive RNG operation exact
|
||||
- arguments exact
|
||||
- integer/byte/bit result exact
|
||||
- float는 JSON number의 실제 값 exact
|
||||
|
||||
PHP/JavaScript의 동일 계산 결과가 표현 차이만 보이는 경우에도 RNG trace
|
||||
허용치를 넓히지 않는다. 게임 상태 수치에 불가피한 부동소수점 차이가 있으면
|
||||
필드별 compatibility rule을 근거와 함께 별도 등록한다.
|
||||
|
||||
## Canonical snapshot
|
||||
|
||||
결과 envelope:
|
||||
|
||||
```text
|
||||
schemaVersion
|
||||
fixtureId
|
||||
engine ref | core-memory | core-db
|
||||
execution
|
||||
requestedCommand
|
||||
resolvedCommand
|
||||
outcome success | command-failure | constraint-denied | fallback | error
|
||||
blockedReason
|
||||
nextTurnTime
|
||||
before
|
||||
after
|
||||
delta
|
||||
rng
|
||||
unmappedChanges
|
||||
```
|
||||
|
||||
`before`와 `after`는 다음 collection을 ID/복합 key로 정렬한다.
|
||||
|
||||
### 일반 상태
|
||||
|
||||
- world: year, month, tick/turn term, killturn 설정과 명령이 읽거나 바꾼 env
|
||||
- generals: scalar stats, 소속, 관직, 자원, 병력, 부상, 장비, 특기, 성격,
|
||||
능력 경험, turn time, lastTurn
|
||||
- general meta: killturn, myset, autorun/cooldown, 계승, command별 변경 key
|
||||
- item inventory: instance ID 자체보다 item key, slot, charges와 values
|
||||
- rank: `(generalId, type, value)`
|
||||
|
||||
### 도시·국가·관계
|
||||
|
||||
- cities: 소속, state, 인구와 최대치, 내정치와 최대치, 수비·성벽, 보급,
|
||||
전선, trust, trade, region, conflict
|
||||
- nations: 수도, 군주, 규모, 자원, 기술, power, type과 명령 관련 meta
|
||||
- troops: leader/id, nation, name와 membership에 의해 바뀐 장수 troopId
|
||||
- diplomacy: 양방향 row를 `(srcNationId, destNationId)`로 정렬하고 state,
|
||||
term, dead/showing과 의미 meta 비교
|
||||
|
||||
### side-effect collection
|
||||
|
||||
- generalTurns, nationTurns: logical key, action, args와 순서
|
||||
- logs: scope, category, subtype, year, month, 대상 ID와 최종 formatting text
|
||||
- messages
|
||||
- events
|
||||
- hall/archive rows
|
||||
- inheritance point/log/result
|
||||
- access-log 변경
|
||||
- 생성·삭제된 entity ID
|
||||
|
||||
DB auto ID, `createdAt`, `updatedAt`, connection별 sequence와 물리 JSON key
|
||||
순서는 제거한다. JSON object key는 정렬하지만 array 순서는 보존한다.
|
||||
core memory log는 production `finalizeLogEntry`를 같은 고정 year/month/time
|
||||
context로 통과시킨 뒤 persisted/ref log와 비교한다.
|
||||
|
||||
## 명령 seed 밖의 난수
|
||||
|
||||
사료 NPC 랜덤임관의 PHP 전역 `shuffle()`처럼 `RandUtil` 밖의 난수는
|
||||
명령 seed만으로 재현할 수 없다. 이 값을 무시하거나 fixture에서 해당
|
||||
분기를 제외하지 않는다.
|
||||
|
||||
비교 환경에서는 외부 비결정값을 명시적 input tape로 취급한다.
|
||||
|
||||
```json
|
||||
{
|
||||
"externalDecisions": [
|
||||
{
|
||||
"domain": "legacyGlobalShuffle",
|
||||
"input": [201, 202, 203],
|
||||
"output": [203, 201, 202]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- ref `ng_compare`는 CLI guard 아래에서만 해당 shuffle 호출을 작은 wrapper로
|
||||
통과시키고 tape의 permutation을 사용한다.
|
||||
- core runner도 같은 permutation을 입력으로 사용한다.
|
||||
- input/output 원소가 정확한 permutation이 아니면 실패한다.
|
||||
- guard가 꺼진 wrapper는 PHP builtin `shuffle()`을 그대로 한 번 호출한다.
|
||||
- 계측 on/off의 deterministic 경로 무영향 테스트와, guard-off shuffle의
|
||||
permutation property test를 별도로 둔다.
|
||||
- canonical trace에는 external decision의 소비 순서도 포함한다.
|
||||
|
||||
따라서 이 경로의 호환 의미는 “같은 외부 shuffle 결과가 주어졌을 때 이후
|
||||
후보 평가, `RandUtil` 소비와 선택 결과가 같다”이다. PHP 전역 RNG 자체를
|
||||
core command seed와 같다고 주장하지 않는다.
|
||||
|
||||
## 변경 경로 감사
|
||||
|
||||
fixture가 명시한 필드만 비교하면 예상하지 못한 side effect를 놓칠 수 있다.
|
||||
각 runner는 engine raw state의 before/after diff도 만든다.
|
||||
|
||||
- 알려진 raw path는 canonical mapping registry에 연결한다.
|
||||
- 명령이 바꾼 raw path가 canonical path나 명시적 ignore rule에 연결되지
|
||||
않으면 `unmappedChanges`에 넣고 실패한다.
|
||||
- ignore rule은 timestamp, auto ID처럼 게임 의미가 없는 필드만 허용한다.
|
||||
- ignore entry에는 engine, raw path pattern, 이유와 근거 파일을 기록한다.
|
||||
- broad wildcard로 `aux`, `meta` 또는 전체 table을 무시하지 않는다.
|
||||
|
||||
이 gate는 새로운 aux/meta key나 누락된 persistence table을 조용히
|
||||
통과시키지 않기 위한 것이다.
|
||||
|
||||
## 비교 규칙
|
||||
|
||||
기본은 deep exact equality다.
|
||||
|
||||
정규화 허용:
|
||||
|
||||
- snake_case ↔ camelCase
|
||||
- `intel` ↔ `intelligence`
|
||||
- ref scalar/aux/rank ↔ core typed field/meta/rank row
|
||||
- `None` ↔ `null`인 장비 없음 표현
|
||||
- DB가 부여한 ID와 timestamp 제거
|
||||
- JSON object key 정렬
|
||||
|
||||
정규화 금지:
|
||||
|
||||
- 반올림, truncation 또는 clamp 결과 변경
|
||||
- RNG 호출 추가/삭제/재정렬
|
||||
- collection 정렬로 실제 처리 순서 은폐
|
||||
- 로그 문구나 조사 차이를 임의로 제거
|
||||
- 누락 row를 기본값으로 만들어 일치시킴
|
||||
- 도시 `state`와 `meta.state`처럼 소유권이 다른 필드를 같은 값으로 간주
|
||||
|
||||
필드별 허용 차이는 `compatibility-rules.json`에 다음을 반드시 기록한다.
|
||||
|
||||
- fixture 또는 command
|
||||
- canonical path
|
||||
- 허용 조건
|
||||
- 레거시/core 근거 파일
|
||||
- 사용자 상태와 이후 턴에 영향이 없는 이유
|
||||
- 제거 예정 여부
|
||||
|
||||
## 화계 첫 acceptance matrix
|
||||
|
||||
설계 검증의 첫 명령은 `che_화계`로 한다. 최소 fixture:
|
||||
|
||||
| fixture | 보호할 계약 |
|
||||
| ------------------------ | ------------------------------------------------------------------------------------- |
|
||||
| `success-basic` | 비용, 성공, 농업·상업 피해, state 32, 경험·공헌·지력 경험·firenum, queue/LastTurn/log |
|
||||
| `failure-probability` | 실패 RNG, 피해·부상·아이템 소비 없음, 실패 경험/공헌 범위 |
|
||||
| `probability-clamp-zero` | 음수 계산 결과 0 clamp와 RNG 소비 |
|
||||
| `probability-clamp-max` | 0.5 상한, 거리 나눗셈 순서 |
|
||||
| `defence-population` | 대상국 장수만 포함, 최대 지력, 인원 log2, 보급·치안 보정 |
|
||||
| `injury-and-item` | 장수별 부상 판정/상한 80, crew/train/atmos 0.98 절삭, 일회용 아이템 소비 |
|
||||
| `damage-clamp` | 낮은 농업·상업에서 0 미만 방지 |
|
||||
| `constraint-denied` | 중립/같은 도시/자원/보급/불가침 제약과 queue fallback |
|
||||
|
||||
`success-basic`은 현재의 `City.meta.state`와 `City.state` 혼동을 반드시
|
||||
실패로 검출해야 한다. 이 fixture가 해당 production line을 고의로 잘못
|
||||
바꿨을 때 실패하고 복구하면 통과하는 것을 mutation audit에 기록한다.
|
||||
|
||||
## 55개 명령 coverage manifest
|
||||
|
||||
`manifest.json`은 PHP command inventory와 TS registry의 합집합을 기준으로
|
||||
생성 검증한다. 각 명령에는 다음 case class가 필요하다.
|
||||
|
||||
- 실행 가능한 기본 경로
|
||||
- full constraint 거부
|
||||
- 확률 분기가 있으면 성공과 실패
|
||||
- 값 clamp/상한/하한이 있으면 경계
|
||||
- 다중 턴이면 stack 중간과 완료/레거시 reset
|
||||
- alternative가 있으면 원 명령과 대체 명령
|
||||
- 생성/삭제/소속 변경이 있으면 관련 collection
|
||||
- 아이템/특기/국가/관직 보정이 있으면 최소 한 개
|
||||
- 명령별 외부 side effect가 있으면 해당 collection
|
||||
|
||||
적용 불가능한 case class는 `notApplicable`과 레거시 근거를 기록한다.
|
||||
fixture가 하나 있다는 이유만으로 명령을 covered로 세지 않는다.
|
||||
|
||||
inventory gate가 검증할 집계:
|
||||
|
||||
- ref command 수
|
||||
- core command 수
|
||||
- command별 필수 case class 충족
|
||||
- fixture schema validation
|
||||
- orphan fixture
|
||||
- skip/todo/notApplicable 근거
|
||||
- unmapped changed path 수
|
||||
|
||||
## 실패 출력과 artifact
|
||||
|
||||
실패 시 다음만 출력한다.
|
||||
|
||||
- fixture ID와 세 engine
|
||||
- 첫 canonical mismatch path와 양쪽 값
|
||||
- 전체 RNG trace에서 최초 divergence와 전후 제한된 window
|
||||
- unmapped raw changed path
|
||||
- 재현 명령
|
||||
|
||||
전체 DB dump, credential, 운영 hidden seed, 사용자 개인정보는 출력하거나
|
||||
artifact로 저장하지 않는다. 필요하면 canonical JSON만 Git 제외된
|
||||
`artifacts/general-command/<fixture>/`에 저장하고 민감 필드를 검사한다.
|
||||
|
||||
## 실행 명령 계약
|
||||
|
||||
구현 후 제공할 명령:
|
||||
|
||||
```bash
|
||||
# stack 준비와 격리 검증
|
||||
pnpm general-diff:prepare
|
||||
pnpm general-diff:verify-isolation
|
||||
|
||||
# 화계 한 fixture
|
||||
pnpm general-diff:test --fixture che_화계/success-basic
|
||||
|
||||
# 한 명령 전체
|
||||
pnpm general-diff:test --command che_화계
|
||||
|
||||
# 55개 coverage 및 전체 differential/persistence
|
||||
pnpm general-diff:check
|
||||
```
|
||||
|
||||
명령은 기존 개발 DB를 발견하거나 전용 stack marker가 없으면 실행을
|
||||
거부한다. `general-diff:check`는 skip이 있으면 실패한다. 진단용
|
||||
`--allow-skip`은 CI와 완료 판정에서 금지한다.
|
||||
|
||||
## CI 단계
|
||||
|
||||
### PR fast gate
|
||||
|
||||
- fixture/schema/manifest validation
|
||||
- comparator unit test
|
||||
- 변경된 명령과 공통 실행기 영향 명령의 differential
|
||||
- 해당 fixture의 core persistence
|
||||
|
||||
### compatibility gate
|
||||
|
||||
- 55개 manifest의 모든 필수 case
|
||||
- ref/core RNG 및 canonical state exact diff
|
||||
- core memory/persisted exact diff
|
||||
- unmapped changes 0
|
||||
- skip 0
|
||||
|
||||
명령별 case를 shard할 수 있지만 같은 case DB를 공유하지 않는다.
|
||||
|
||||
## Comparator 자체 검증
|
||||
|
||||
`generalCommandComparator.test.ts`는 실제 production 결과 없이도 다음
|
||||
synthetic mismatch를 각각 검출해야 한다.
|
||||
|
||||
- 숫자 1 차이
|
||||
- null과 누락
|
||||
- array 순서
|
||||
- RNG operation/result
|
||||
- 로그 대상과 format text
|
||||
- queue shift 방향
|
||||
- 생성 대신 수정
|
||||
- 삭제 누락
|
||||
- `City.state`와 `meta.state`
|
||||
- 알려지지 않은 aux/meta/raw DB 변경
|
||||
|
||||
protected behavior를 고의로 perturb한 mutation audit를 fixture review에
|
||||
포함한다. 단순히 현재 구현을 snapshot으로 저장하고 다시 읽는 테스트는
|
||||
호환 근거로 인정하지 않는다.
|
||||
|
||||
## 구현 순서
|
||||
|
||||
1. canonical fixture/schema와 comparator unit test
|
||||
2. 전용 Compose stack과 원본 DB 불변 isolation test
|
||||
3. ref `general_command_trace.php`를 `ng_compare`에 추가
|
||||
4. core memory runner
|
||||
5. core DB runner와 새 connection reload
|
||||
6. 화계 acceptance matrix 완성 및 현재 city state 결함 수정
|
||||
7. 계략 4종으로 공통 runner 검증
|
||||
8. side-effect family별 대표 명령 확장
|
||||
9. 55개 manifest 완성
|
||||
10. project-generated Prisma client를 사용하는 전용 connection readiness
|
||||
check 추가
|
||||
11. `pnpm general-diff:check`를 compatibility gate로 등록
|
||||
|
||||
ref 계측 commit, core test/infra commit, 제품 버그 수정 commit은 분리한다.
|
||||
|
||||
## 완료 기준
|
||||
|
||||
다음이 모두 증명되어야 이 설계의 구현을 완료로 본다.
|
||||
|
||||
- 전용 stack이 기존 ref/dev DB를 바꾸지 않는 isolation test 통과
|
||||
- ref runner가 CLI/test guard 밖에서 접근 불가
|
||||
- 화계 8개 acceptance fixture 통과
|
||||
- 화계 `state=32`가 core DB 재조회에도 유지됨
|
||||
- 각 fixture의 ref/core RNG trace exact 일치
|
||||
- 외부 비결정 경로는 input tape 소비와 이후 결과 exact 일치
|
||||
- 55개 명령의 필수 manifest case 충족
|
||||
- ref/core memory canonical diff 0
|
||||
- core memory/persisted canonical diff 0
|
||||
- unmapped changed path 0
|
||||
- skip/todo 0
|
||||
- comparator mutation audit 통과
|
||||
- 관련 typecheck, lint, build와 integration test 통과
|
||||
- `docs/ref-core2026-mapping.md`에서 동적 검증 근거와 미확인 항목 갱신
|
||||
- 실행 명령, 기준 commit과 fixture 목록을 `report/`에 기록
|
||||
|
||||
이 조건 전에는 정적 제약·로그 검사와 smoke test 통과만으로 일반 장수 명령
|
||||
전체를 `확인` 또는 이식 완료로 표시하지 않는다.
|
||||
@@ -116,7 +116,9 @@ Nation-level choices run only for NPCs (`npc >= 2`) or for autorun users:
|
||||
- **Resource distribution**
|
||||
- `do유저장포상`, `doNPC포상`, `doNPC몰수`
|
||||
- uses resource floors (`reqNation*`, `reqNPC*`, `reqHuman*`)
|
||||
- weighted by target general's deficit and recent activity.
|
||||
- sorts by each general's gold/rice, excludes inactive (`killturn <= 5`)
|
||||
targets, and preserves the legacy geometric-mean amounts and candidate
|
||||
weights.
|
||||
- **Diplomacy**
|
||||
- `do불가침제의`: respond to assistance requests with NAP offer.
|
||||
- `do선전포고`: probabilistic declaration when strong enough.
|
||||
@@ -131,8 +133,10 @@ General-level decisions are layered:
|
||||
2. Reserved command is honored if valid (unless `휴식`).
|
||||
3. Immediate recovery if `injury > cureThreshold`.
|
||||
4. Special cases:
|
||||
- NPC troop leaders (type 5) always `집합`.
|
||||
- wanderers decide on founding / moving / disbanding.
|
||||
- A nationless NPC troop leader shortens `killturn` and keeps its reserved
|
||||
command; an affiliated type-5 leader refreshes `killturn` and uses `집합`.
|
||||
- wandering lords decide on founding, one-edge movement toward a cached
|
||||
target, or disbanding.
|
||||
5. Iterate policy `priority`, invoking `do{Action}`.
|
||||
6. Fallback to `do중립`.
|
||||
|
||||
@@ -189,3 +193,17 @@ To port the AI to an in-memory state model without behavior drift:
|
||||
|
||||
These guidelines mirror the current "derive once, then select via priority"
|
||||
pattern and minimize resimulation deltas in the rewrite.
|
||||
|
||||
## Migrated decision-parity regression
|
||||
|
||||
`app/game-engine/test/generalAiLegacyDecisionParity.test.ts` records focused
|
||||
final-command expectations extracted from `ref/sam` `ng_compare@fe9ae978`.
|
||||
Its matrix varies diplomacy/war state, city development and population,
|
||||
technology/year ceilings, general gold/rice and casualty ranks, stats and
|
||||
affinity, reserved/special NPC state, nation treasury reserves, and command
|
||||
availability. It also asserts RNG-sensitive candidate weights where consuming
|
||||
the same random branch is part of the final decision.
|
||||
|
||||
This is compatibility evidence for the represented decision branches. The
|
||||
long-running NPC scenario suites remain smoke tests and are not a substitute
|
||||
for this branch-level matrix.
|
||||
|
||||
@@ -71,8 +71,36 @@ Event actions controlling lifecycle:
|
||||
|
||||
## Open Questions / Follow-ups
|
||||
|
||||
- The exact schedule for `registerAuction()` is outside this file; it is
|
||||
likely called from monthly or timed maintenance.
|
||||
- Unique-item auction close-date extension limits depend on
|
||||
`AuctionUniqueItem` constants and `turnterm`; verify scenarios that override
|
||||
auction timing.
|
||||
- `registerAuction()` is called from legacy `func_gamerule.php` after the
|
||||
logical month changes. Its RNG is seeded before that change with the previous
|
||||
year/month, then consumes one power roll per nation and the conditional
|
||||
tournament roll before auction generation.
|
||||
- Scenario-specific overrides of unique auction timing have not been found.
|
||||
Core2026 currently applies the legacy constants against `tickSeconds`.
|
||||
|
||||
## core2026 compatibility implementation (2026-07-25)
|
||||
|
||||
| Contract | core2026 path | Status |
|
||||
| -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| Resource auction list/open/bid | `game-frontend/AuctionView.vue` → `game-api/router/auction` → `auctionOpen`/`auctionBid` engine commands | Confirmed by integration test |
|
||||
| Resource reservation/refund/settlement | opener reserves host gold/rice; bidder reserves and refunds the previous top bid; finalizer transfers or returns resources | Confirmed by integration test |
|
||||
| Unique list/detail privacy | API replaces host and bidder identity with deterministic aliases and never returns unique-auction general IDs | Confirmed by API implementation and typecheck |
|
||||
| Unique open | engine validates configured quantity, slot ownership, one active host/item auction, and atomically inserts the host's first bid while deducting `inheritance_point.previous` | Confirmed by integration test |
|
||||
| Unique bid | row-locked auction, minimum `max(1%, 10)`, conditional point deduction, previous-top-bid refund, slot/top-bid conflict checks | Confirmed by integration test |
|
||||
| Unique finalization | requested extension, per-slot and total ownership-limit extension, host neutralization, item inventory update | Confirmed by integration test |
|
||||
| Timer/worker | API updates the Redis timer after open/bid; existing scheduler claims due rows and sends durable `auctionFinalize` commands | Confirmed by integration test for timer seed and durable command finalization |
|
||||
| Neutral merchant registration | `neutralRegistrar.ts` runs at the month boundary, reproduces the previous-month seed and preceding RNG consumption, and queues host `0` auctions in the same DB transaction as the month state | Confirmed by PHP differential, engine boundary, PostgreSQL idempotency, and full integration tests |
|
||||
|
||||
Unique auction mutations deliberately run in the turn-engine database
|
||||
transaction. Point reads use row locks and deductions use conditional updates,
|
||||
so simultaneous requests cannot spend the same previous-season inheritance
|
||||
points twice. A failed open command creates neither an auction nor an orphaned
|
||||
inheritance request marker.
|
||||
|
||||
Neutral registration intentionally counts every historical host-`0` resource
|
||||
auction, including finished rows, because the legacy probability denominator
|
||||
does not filter on `finished`. Average gold/rice includes only generals with
|
||||
`npcState < 2` and is clamped to 1,000..20,000. The month marker and generated
|
||||
auction rows are persisted together under a PostgreSQL advisory transaction
|
||||
lock, preventing the same month from producing duplicate merchant auctions
|
||||
during daemon retry.
|
||||
|
||||
+25
-23
@@ -4,7 +4,7 @@
|
||||
|
||||
- Audit date: 2026-07-25
|
||||
- Code baseline: `main@46ae79dbe7a0fb64aff9bdcc76eadafd75e10c9e`
|
||||
- Executed test sources: 66 TypeScript `*.test.ts` files
|
||||
- Executed test sources: 67 TypeScript `*.test.ts` files
|
||||
- Excluded from the source count: ignored `dist/` outputs and non-executable
|
||||
fixtures/helpers
|
||||
- Historical generated copies removed by this audit:
|
||||
@@ -61,26 +61,27 @@ environment-dependent check.
|
||||
|
||||
### `app/game-engine`
|
||||
|
||||
| Test source | Disposition | Layer | What it establishes |
|
||||
| ----------------------------------------------- | ----------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `test/crewTypeExecution.test.ts` | kept | compatibility | Crew action router ordering and legacy NPC arm-type weighting. |
|
||||
| `test/databaseCommandQueue.integration.test.ts` | kept | integration | PostgreSQL single claim across consumers, persisted result, and expired-versus-active lease recovery. Explicitly skipped without a DB URL. |
|
||||
| `test/turnDaemonLease.integration.test.ts` | kept | integration | PostgreSQL profile lease exclusivity, expiry takeover with epoch increment, stale-owner fencing rollback, and clean release handoff. Explicitly skipped without a DB URL. |
|
||||
| `test/inputEventAtomicity.test.ts` | kept | contract | Dirty-state retention, mutation/commit/respond ordering, and pause/no-ack behavior on handler or commit failure. |
|
||||
| `test/nationCollapseOnConquest.test.ts` | kept | smoke | Last-city conquest removes the nation and neutralizes its general. It is a focused engine scenario, not full battle parity. |
|
||||
| `test/nationTurnCompatibility.test.ts` | kept | compatibility | Legacy-backed 12-turn research accumulation and completion, diplomacy-message emission, and exact monthly strategy/diplomacy limit decay through the engine harness. |
|
||||
| `test/npcGeneralDomesticTurn.test.ts` | corrected | contract | Fixed seed chooses the security command, applies exactly `+50`, leaves other city values unchanged, and advances exactly one tick. |
|
||||
| `test/npcNationGrowthScenario.test.ts` | corrected | smoke | Long-running NPC growth invariants and collapse guards. Broad thresholds remain intentional smoke bounds; unconditional diagnostic output was removed. |
|
||||
| `test/npcNationTechResearch.test.ts` | kept | smoke | Long-running monotonic tech growth and higher later recruitment cost. |
|
||||
| `test/npcNationUprisingUnification.test.ts` | kept | smoke | Long-running founding, conquest, nation-count monotonicity, and unification terminal state. |
|
||||
| `test/npcNationWarDeclaration.test.ts` | kept | smoke | Declaration, war transition, preparation, conquest, unification, and dispatch occurrence in one end-to-end NPC scenario. |
|
||||
| `test/npcWarPrepTurns.test.ts` | kept | smoke | Training/morale actions occur in the named months and leave battle-ready generals. |
|
||||
| `test/reservedTurnExecution.test.ts` | kept | smoke | Multi-command engine application, invalid-argument and constraint fallbacks, uprising/founding transitions, and named failure constraints. Its large workflow scope is recorded as smoke rather than a unit test. |
|
||||
| `test/scenarioSeeder.test.ts` | kept | integration | Real schema row counts, diplomacy symmetry, and persisted install options. Reported as skipped when required tables are unavailable. |
|
||||
| `test/turnDaemonLifecycle.test.ts` | kept | contract | Queue-front versus scheduled-boundary selection and exact processor checkpoint arguments. |
|
||||
| `test/turnOrder.test.ts` | kept | contract | Stable `turnTime`, then ID, ordering independent of insertion order. |
|
||||
| `test/uniqueLotteryCommand.test.ts` | kept | contract | Fixed-seed eligible command awards the expected unique item and item log. |
|
||||
| `test/voteReward.test.ts` | corrected | contract | Gold, exact unique item, persisted reward metadata, log, and idempotent second application. |
|
||||
| Test source | Disposition | Layer | What it establishes |
|
||||
| ----------------------------------------------- | ----------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `test/crewTypeExecution.test.ts` | kept | compatibility | Crew action router ordering and legacy NPC arm-type weighting. |
|
||||
| `test/databaseCommandQueue.integration.test.ts` | kept | integration | PostgreSQL single claim across consumers, persisted result, and expired-versus-active lease recovery. Explicitly skipped without a DB URL. |
|
||||
| `test/generalAiLegacyDecisionParity.test.ts` | added | compatibility | Ref-backed final NPC command matrix across diplomacy, war readiness, city development/population, technology ceilings, gold/rice and casualty ranks, stats/affinity, special NPC state, treasury reserves, and command availability. |
|
||||
| `test/inputEventAtomicity.test.ts` | kept | contract | Dirty-state retention, mutation/commit/respond ordering, and pause/no-ack behavior on handler or commit failure. |
|
||||
| `test/nationCollapseOnConquest.test.ts` | kept | smoke | Last-city conquest removes the nation and neutralizes its general. It is a focused engine scenario, not full battle parity. |
|
||||
| `test/nationTurnCompatibility.test.ts` | kept | compatibility | Legacy-backed 12-turn research accumulation and completion, diplomacy-message emission, and exact monthly strategy/diplomacy limit decay through the engine harness. |
|
||||
| `test/npcGeneralDomesticTurn.test.ts` | corrected | contract | Fixed seed chooses the security command, applies exactly `+50`, leaves other city values unchanged, and advances exactly one tick. |
|
||||
| `test/npcNationGrowthScenario.test.ts` | corrected | smoke | Long-running NPC growth invariants and collapse guards. The implementation-specific early recruitment bound was removed because legacy AI forbids peace/declaration recruitment; remaining thresholds are smoke bounds. |
|
||||
| `test/npcNationTechResearch.test.ts` | corrected | smoke | Long-running monotonic tech growth. Net per-tick general gold is not treated as recruitment price because nation awards can occur in the same tick. |
|
||||
| `test/npcNationUprisingUnification.test.ts` | kept | smoke | Long-running founding, conquest, nation-count monotonicity, and unification terminal state. |
|
||||
| `test/npcNationWarDeclaration.test.ts` | corrected | smoke | Declaration, war transition, a battle-ready cohort, front deployment, conquest, unification, and dispatch occurrence without assuming every recruit is already fully trained. |
|
||||
| `test/npcWarPrepTurns.test.ts` | kept | smoke | Training/morale actions occur in the named months and leave battle-ready generals. |
|
||||
| `test/reservedTurnExecution.test.ts` | kept | smoke | Multi-command engine application, invalid-argument and constraint fallbacks, uprising/founding transitions, and named failure constraints. Its large workflow scope is recorded as smoke rather than a unit test. |
|
||||
| `test/scenarioSeeder.test.ts` | kept | integration | Real schema row counts, diplomacy symmetry, and persisted install options. Reported as skipped when required tables are unavailable. |
|
||||
| `test/turnDaemonLease.integration.test.ts` | kept | integration | PostgreSQL profile lease exclusivity, expiry takeover with epoch increment, stale-owner fencing rollback, and clean release handoff. Explicitly skipped without a DB URL. |
|
||||
| `test/turnDaemonLifecycle.test.ts` | kept | contract | Queue-front versus scheduled-boundary selection and exact processor checkpoint arguments. |
|
||||
| `test/turnOrder.test.ts` | kept | contract | Stable `turnTime`, then ID, ordering independent of insertion order. |
|
||||
| `test/uniqueLotteryCommand.test.ts` | kept | contract | Fixed-seed eligible command awards the expected unique item and item log. |
|
||||
| `test/voteReward.test.ts` | corrected | contract | Gold, exact unique item, persisted reward metadata, log, and idempotent second application. |
|
||||
|
||||
### `packages/logic`
|
||||
|
||||
@@ -151,8 +152,9 @@ to legacy-compatible:
|
||||
|
||||
- battle compatibility is supported only by the differential fixtures and the
|
||||
suites explicitly marked `compatibility`;
|
||||
- broad NPC and multi-command scenarios guard liveness/invariants, not exact
|
||||
monthly balance or complete side effects;
|
||||
- broad NPC and multi-command scenarios guard liveness/invariants, while
|
||||
`generalAiLegacyDecisionParity.test.ts` establishes exact final choices only
|
||||
for its explicitly represented input branches;
|
||||
- DB/Redis/PM2 claims require their integration suites to run rather than skip;
|
||||
- changing an implementation and observing a green unit suite is still not a
|
||||
substitute for a new PHP trace when compatibility-sensitive behavior changes.
|
||||
|
||||
@@ -78,6 +78,10 @@ Goal: verify state input -> state output and that flush behaves as expected.
|
||||
- Integration tests: execute the same command and confirm DB persistence.
|
||||
- Mock target: InMemory Repository (Fake).
|
||||
- "Send to DB" behavior is validated via real DB tests.
|
||||
- Cross-engine compatibility compares a canonical semantic snapshot rather than
|
||||
raw MariaDB/PostgreSQL dumps. General-turn commands use the three-way
|
||||
ref DB ↔ core InMemory ↔ core PostgreSQL design in
|
||||
[`architecture/general-command-differential-testing.md`](./architecture/general-command-differential-testing.md).
|
||||
|
||||
### 3) Turn Flow Tests
|
||||
|
||||
|
||||
@@ -80,6 +80,17 @@ export type TurnDaemonCommand =
|
||||
}
|
||||
| { type: 'dropItem'; requestId?: string; generalId: number; itemType: string }
|
||||
| { type: 'auctionFinalize'; requestId?: string; auctionId: number }
|
||||
| {
|
||||
type: 'auctionOpen';
|
||||
requestId?: string;
|
||||
generalId: number;
|
||||
auctionType: 'BUY_RICE' | 'SELL_RICE' | 'UNIQUE_ITEM';
|
||||
amount: number;
|
||||
closeTurnCnt?: number;
|
||||
startBidAmount?: number;
|
||||
finishBidAmount?: number;
|
||||
itemKey?: string;
|
||||
}
|
||||
| {
|
||||
type: 'changePermission';
|
||||
requestId?: string;
|
||||
@@ -226,6 +237,17 @@ export type TurnDaemonCommandResult =
|
||||
generalId: number;
|
||||
reason: string;
|
||||
}
|
||||
| {
|
||||
type: 'auctionOpen';
|
||||
ok: true;
|
||||
auctionId: number;
|
||||
closeAt: string;
|
||||
}
|
||||
| {
|
||||
type: 'auctionOpen';
|
||||
ok: false;
|
||||
reason: string;
|
||||
}
|
||||
| {
|
||||
type: 'troopJoin';
|
||||
ok: true;
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface DatabaseClient {
|
||||
nationTurn: GamePrisma.NationTurnDelegate;
|
||||
troop: GamePrisma.TroopDelegate;
|
||||
logEntry: GamePrisma.LogEntryDelegate;
|
||||
auction: GamePrisma.AuctionDelegate;
|
||||
auctionBid: GamePrisma.AuctionBidDelegate;
|
||||
inheritancePoint: GamePrisma.InheritancePointDelegate;
|
||||
inheritanceLog: GamePrisma.InheritanceLogDelegate;
|
||||
inheritanceResult: GamePrisma.InheritanceResultDelegate;
|
||||
|
||||
@@ -403,7 +403,11 @@ export class ActionDefinition<
|
||||
const defenderNation = defenderCity.nationId > 0 ? (nationMap.get(defenderCity.nationId) ?? null) : null;
|
||||
|
||||
const defenderGenerals = generals.filter(
|
||||
(general) => general.cityId === defenderCity.id && general.nationId === defenderCity.nationId
|
||||
(general) =>
|
||||
general.cityId === defenderCity.id &&
|
||||
general.nationId === defenderCity.nationId &&
|
||||
general.crew > 0 &&
|
||||
(unitSet.crewTypes?.some((crewType) => crewType.id === general.crewTypeId) ?? false)
|
||||
);
|
||||
|
||||
const battle = resolveWarBattle({
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||
|
||||
import { simpleSerialize } from '../war/utils.js';
|
||||
|
||||
const DEFAULT_FIRST_NAMES = [
|
||||
'가', '간', '감', '강', '고', '공', '공손', '곽', '관', '괴', '교', '금', '노', '뇌', '능', '도', '동',
|
||||
'두', '등', '마', '맹', '문', '미', '반', '방', '부', '비', '사', '사마', '서', '설', '성', '소', '손',
|
||||
'송', '순', '신', '심', '악', '안', '양', '엄', '여', '염', '오', '왕', '요', '우', '원', '위', '유',
|
||||
'육', '윤', '이', '장', '저', '전', '정', '제갈', '조', '종', '주', '진', '채', '태사', '하', '하후',
|
||||
'학', '한', '향', '허', '호', '화', '황', '공손', '손', '왕', '유', '장', '조',
|
||||
] as const;
|
||||
|
||||
const DEFAULT_LAST_NAMES = [
|
||||
'가', '간', '강', '거', '건', '검', '견', '경', '공', '광', '권', '규', '녕', '단', '대', '도', '등',
|
||||
'람', '량', '례', '로', '료', '모', '민', '박', '범', '보', '비', '사', '상', '색', '서', '소', '속',
|
||||
'송', '수', '순', '습', '승', '양', '연', '영', '온', '옹', '완', '우', '웅', '월', '위', '유', '윤',
|
||||
'융', '이', '익', '임', '정', '제', '조', '주', '준', '지', '찬', '책', '충', '탁', '택', '통', '패',
|
||||
'평', '포', '합', '해', '혁', '현', '화', '환', '회', '횡', '후', '훈', '휴', '흠', '흥',
|
||||
] as const;
|
||||
|
||||
const readNameParts = (value: unknown, fallback: readonly string[]): string[] => {
|
||||
if (!Array.isArray(value)) {
|
||||
return [...fallback];
|
||||
}
|
||||
const result = value.filter((entry): entry is string => typeof entry === 'string');
|
||||
return result.length > 0 ? result : [...fallback];
|
||||
};
|
||||
|
||||
export const buildAuctionAlias = (
|
||||
generalId: number,
|
||||
hiddenSeed: string | number,
|
||||
configConst: Record<string, unknown> = {}
|
||||
): string => {
|
||||
const firstNames = readNameParts(configConst.randGenFirstName, DEFAULT_FIRST_NAMES);
|
||||
const middleNames = readNameParts(configConst.randGenMiddleName, ['']);
|
||||
const lastNames = readNameParts(configConst.randGenLastName, DEFAULT_LAST_NAMES);
|
||||
const pool: string[] = [];
|
||||
for (const first of firstNames) {
|
||||
for (const middle of middleNames) {
|
||||
for (const last of lastNames) {
|
||||
pool.push(`${first}${middle}${last}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const shuffled = new RandUtil(
|
||||
new LiteHashDRBG(simpleSerialize(hiddenSeed, 'obfuscatedNamePool'))
|
||||
).shuffle(pool);
|
||||
const normalizedId = Math.max(0, Math.floor(generalId));
|
||||
const duplicateIndex = Math.floor(normalizedId / shuffled.length);
|
||||
const name = shuffled[normalizedId % shuffled.length] ?? `익명${normalizedId}`;
|
||||
return duplicateIndex === 0 ? name : `${name}${duplicateIndex}`;
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
import { LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||
|
||||
import { simpleSerialize } from '../war/utils.js';
|
||||
|
||||
export type NeutralResourceAuctionType = 'BUY_RICE' | 'SELL_RICE';
|
||||
|
||||
export interface NeutralAuctionPlanInput {
|
||||
hiddenSeed: string | number;
|
||||
seedYear: number;
|
||||
seedMonth: number;
|
||||
nationCount: number;
|
||||
consumeTournamentRoll: boolean;
|
||||
averageGold: number;
|
||||
averageRice: number;
|
||||
buyRiceAuctionCount: number;
|
||||
sellRiceAuctionCount: number;
|
||||
}
|
||||
|
||||
export interface NeutralResourceAuctionPlan {
|
||||
auctionType: NeutralResourceAuctionType;
|
||||
amount: number;
|
||||
startBidAmount: number;
|
||||
finishBidAmount: number;
|
||||
closeTurnCnt: number;
|
||||
}
|
||||
|
||||
const clamp = (value: number, min: number, max: number): number => {
|
||||
if (max < min) {
|
||||
return min;
|
||||
}
|
||||
return Math.min(max, Math.max(min, value));
|
||||
};
|
||||
|
||||
const roundToTens = (value: number): number => Math.round(value / 10) * 10;
|
||||
|
||||
const normalizeCount = (value: number): number => (Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0);
|
||||
|
||||
const normalizeAverage = (value: number): number => clamp(Number.isFinite(value) ? value : 0, 1_000, 20_000);
|
||||
|
||||
const canOpenResourceAuction = (plan: NeutralResourceAuctionPlan): boolean =>
|
||||
plan.closeTurnCnt >= 1 &&
|
||||
plan.closeTurnCnt <= 24 &&
|
||||
plan.amount >= 100 &&
|
||||
plan.amount <= 10_000 &&
|
||||
plan.startBidAmount >= plan.amount * 0.5 &&
|
||||
plan.startBidAmount <= plan.amount * 2 &&
|
||||
plan.finishBidAmount >= plan.amount * 1.1 &&
|
||||
plan.finishBidAmount <= plan.amount * 2 &&
|
||||
plan.finishBidAmount >= plan.startBidAmount * 1.1;
|
||||
|
||||
export const buildNeutralResourceAuctionPlan = (input: NeutralAuctionPlanInput): NeutralResourceAuctionPlan[] => {
|
||||
// ref TurnExecutionHelper는 날짜를 넘기기 전에 이전 연월로 monthly RNG를 만든다.
|
||||
const rng = new RandUtil(
|
||||
new LiteHashDRBG(simpleSerialize(input.hiddenSeed, 'monthly', input.seedYear, input.seedMonth))
|
||||
);
|
||||
|
||||
// ref postUpdateMonthly()의 국가 국력 보정이 registerAuction()보다 먼저 RNG를 소비한다.
|
||||
for (let nationIdx = 0; nationIdx < normalizeCount(input.nationCount); nationIdx += 1) {
|
||||
rng.nextRange(0.95, 1.05);
|
||||
}
|
||||
// 토너먼트가 없고 자동 개시가 켜진 경우 성공 여부와 무관하게 한 번 소비한다.
|
||||
if (input.consumeTournamentRoll) {
|
||||
rng.nextBool(0.4);
|
||||
}
|
||||
|
||||
const averageGold = normalizeAverage(input.averageGold);
|
||||
const averageRice = normalizeAverage(input.averageRice);
|
||||
const result: NeutralResourceAuctionPlan[] = [];
|
||||
|
||||
const buyRiceAuctionCount = normalizeCount(input.buyRiceAuctionCount);
|
||||
if (rng.nextBool(1 / (buyRiceAuctionCount + 5))) {
|
||||
const multiplier = rng.nextRangeInt(1, 5);
|
||||
const rawAmount = (averageRice / 20) * multiplier;
|
||||
const rawStartBid = clamp((averageGold / 20) * 0.9 * multiplier, rawAmount * 0.8, rawAmount * 1.2);
|
||||
const plan: NeutralResourceAuctionPlan = {
|
||||
auctionType: 'BUY_RICE',
|
||||
amount: roundToTens(rawAmount),
|
||||
startBidAmount: roundToTens(rawStartBid),
|
||||
finishBidAmount: roundToTens(rawAmount * 2),
|
||||
closeTurnCnt: rng.nextRangeInt(3, 12),
|
||||
};
|
||||
if (canOpenResourceAuction(plan)) {
|
||||
result.push(plan);
|
||||
}
|
||||
}
|
||||
|
||||
const sellRiceAuctionCount = normalizeCount(input.sellRiceAuctionCount);
|
||||
if (rng.nextBool(1 / (sellRiceAuctionCount + 5))) {
|
||||
const multiplier = rng.nextRangeInt(1, 5);
|
||||
const rawAmount = (averageGold / 20) * multiplier;
|
||||
const rawStartBid = clamp((averageRice / 20) * 1.1 * multiplier, rawAmount * 0.8, rawAmount * 1.2);
|
||||
const plan: NeutralResourceAuctionPlan = {
|
||||
auctionType: 'SELL_RICE',
|
||||
amount: roundToTens(rawAmount),
|
||||
startBidAmount: roundToTens(rawStartBid),
|
||||
finishBidAmount: roundToTens(rawAmount * 2),
|
||||
closeTurnCnt: rng.nextRangeInt(3, 12),
|
||||
};
|
||||
if (canOpenResourceAuction(plan)) {
|
||||
result.push(plan);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
@@ -1,6 +1,8 @@
|
||||
export * from './domain/entities.js';
|
||||
export type { RandomGenerator } from '@sammo-ts/common';
|
||||
export * from './actions/index.js';
|
||||
export * from './auction/alias.js';
|
||||
export * from './auction/neutral.js';
|
||||
export * from './constraints/index.js';
|
||||
export * from './crewType/index.js';
|
||||
export * from './diplomacy/index.js';
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildAuctionAlias } from '../src/auction/alias.js';
|
||||
|
||||
describe('buildAuctionAlias', () => {
|
||||
it('returns a stable alias for the same world seed and general id', () => {
|
||||
const first = buildAuctionAlias(17, 'legacy-compatible-seed');
|
||||
const second = buildAuctionAlias(17, 'legacy-compatible-seed');
|
||||
|
||||
expect(second).toBe(first);
|
||||
expect(first.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('uses scenario-specific name pools without exposing a general name', () => {
|
||||
const config = {
|
||||
randGenFirstName: ['청'],
|
||||
randGenMiddleName: ['운'],
|
||||
randGenLastName: ['객', '상'],
|
||||
};
|
||||
|
||||
expect(buildAuctionAlias(0, 'seed', config)).toMatch(/^청운(객|상)$/);
|
||||
expect(buildAuctionAlias(1, 'seed', config)).toMatch(/^청운(객|상)$/);
|
||||
expect(buildAuctionAlias(2, 'seed', config)).toMatch(/^청운(객|상)1$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildNeutralResourceAuctionPlan,
|
||||
type NeutralAuctionPlanInput,
|
||||
type NeutralResourceAuctionPlan,
|
||||
} from '../src/auction/neutral.js';
|
||||
|
||||
const refRoot = process.env.SAMMO_REF_ROOT;
|
||||
const oraclePath = fileURLToPath(new URL('../../../tools/legacy-oracles/neutral-auction.php', import.meta.url));
|
||||
|
||||
const runLegacyOracle = (input: NeutralAuctionPlanInput): NeutralResourceAuctionPlan[] => {
|
||||
if (!refRoot) {
|
||||
throw new Error('SAMMO_REF_ROOT is required');
|
||||
}
|
||||
const result = spawnSync('php', [oraclePath, refRoot, JSON.stringify(input)], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(result.stderr || `legacy oracle exited with ${result.status}`);
|
||||
}
|
||||
return JSON.parse(result.stdout) as NeutralResourceAuctionPlan[];
|
||||
};
|
||||
|
||||
const fixtures: Array<{ input: NeutralAuctionPlanInput; expectedTypes: string[] }> = [
|
||||
{
|
||||
expectedTypes: ['BUY_RICE'],
|
||||
input: {
|
||||
hiddenSeed: 'merchant-11',
|
||||
seedYear: 180,
|
||||
seedMonth: 1,
|
||||
nationCount: 3,
|
||||
consumeTournamentRoll: false,
|
||||
averageGold: 5_432,
|
||||
averageRice: 7_654,
|
||||
buyRiceAuctionCount: 0,
|
||||
sellRiceAuctionCount: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
expectedTypes: ['BUY_RICE', 'SELL_RICE'],
|
||||
input: {
|
||||
hiddenSeed: 'tournament-35',
|
||||
seedYear: 191,
|
||||
seedMonth: 12,
|
||||
nationCount: 8,
|
||||
consumeTournamentRoll: true,
|
||||
averageGold: 25_000,
|
||||
averageRice: 500,
|
||||
buyRiceAuctionCount: 2,
|
||||
sellRiceAuctionCount: 4,
|
||||
},
|
||||
},
|
||||
{
|
||||
expectedTypes: [],
|
||||
input: {
|
||||
hiddenSeed: 'merchant-32',
|
||||
seedYear: 203,
|
||||
seedMonth: 7,
|
||||
nationCount: 3,
|
||||
consumeTournamentRoll: false,
|
||||
averageGold: 5_432,
|
||||
averageRice: 7_654,
|
||||
buyRiceAuctionCount: 0,
|
||||
sellRiceAuctionCount: 0,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe.skipIf(!refRoot)('neutral auction legacy PHP differential', () => {
|
||||
for (const [index, fixture] of fixtures.entries()) {
|
||||
it(`matches legacy RNG timing and amounts for fixture ${index + 1}`, () => {
|
||||
const actual = buildNeutralResourceAuctionPlan(fixture.input);
|
||||
expect(actual).toEqual(runLegacyOracle(fixture.input));
|
||||
expect(actual.map((plan) => plan.auctionType)).toEqual(fixture.expectedTypes);
|
||||
});
|
||||
}
|
||||
|
||||
it('matches a seed, month, count, tournament, and average-resource matrix', () => {
|
||||
let generatedAuctions = 0;
|
||||
const generatedTypes = new Set<string>();
|
||||
for (let index = 0; index < 96; index += 1) {
|
||||
const input: NeutralAuctionPlanInput = {
|
||||
hiddenSeed: `neutral-matrix-${index}`,
|
||||
seedYear: 180 + (index % 17),
|
||||
seedMonth: (index % 12) + 1,
|
||||
nationCount: index % 11,
|
||||
consumeTournamentRoll: index % 2 === 0,
|
||||
averageGold: 500 + ((index * 1_337) % 25_000),
|
||||
averageRice: 500 + ((index * 2_111) % 25_000),
|
||||
buyRiceAuctionCount: index % 9,
|
||||
sellRiceAuctionCount: index % 13,
|
||||
};
|
||||
const actual = buildNeutralResourceAuctionPlan(input);
|
||||
expect(actual, `matrix fixture ${index}`).toEqual(runLegacyOracle(input));
|
||||
generatedAuctions += actual.length;
|
||||
for (const plan of actual) {
|
||||
generatedTypes.add(plan.auctionType);
|
||||
}
|
||||
}
|
||||
expect(generatedAuctions).toBeGreaterThan(0);
|
||||
expect(generatedTypes).toEqual(new Set(['BUY_RICE', 'SELL_RICE']));
|
||||
});
|
||||
});
|
||||
@@ -13,8 +13,7 @@ import {
|
||||
createGameApiServer,
|
||||
buildAuctionTimerKeys,
|
||||
seedAuctionTimers,
|
||||
buildTurnDaemonStreamKeys,
|
||||
RedisTurnDaemonTransport,
|
||||
DatabaseTurnDaemonTransport,
|
||||
} from '@sammo-ts/game-api';
|
||||
import { createTurnDaemonRuntime } from '@sammo-ts/game-engine';
|
||||
import {
|
||||
@@ -25,7 +24,7 @@ import {
|
||||
resolveRedisConfigFromEnv,
|
||||
GamePrisma,
|
||||
} from '@sammo-ts/infra';
|
||||
import { ItemLoader, ITEM_KEYS } from '@sammo-ts/logic';
|
||||
import { buildNeutralResourceAuctionPlan, ItemLoader, ITEM_KEYS } from '@sammo-ts/logic';
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const workspaceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||
@@ -221,6 +220,7 @@ describe('auction integration flow', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
await loadEnv();
|
||||
process.env.SCENARIO = '908';
|
||||
process.chdir(workspaceRoot);
|
||||
await resetDatabase();
|
||||
await resetRedis();
|
||||
@@ -271,15 +271,15 @@ describe('auction integration flow', () => {
|
||||
|
||||
await gatewayClient.admin.profiles.upsert.mutate({
|
||||
profile: 'che',
|
||||
scenario: '2',
|
||||
scenario: '908',
|
||||
apiPort: Number(process.env.GAME_API_PORT ?? 14000),
|
||||
status: 'RUNNING',
|
||||
});
|
||||
|
||||
await gatewayClient.admin.profiles.installNow.mutate({
|
||||
profileName: 'che:2',
|
||||
profileName: 'che:908',
|
||||
install: {
|
||||
scenarioId: 2,
|
||||
scenarioId: 908,
|
||||
turnTermMinutes: 1,
|
||||
sync: false,
|
||||
fiction: 0,
|
||||
@@ -309,7 +309,7 @@ describe('auction integration flow', () => {
|
||||
});
|
||||
const gatewayToken = await gatewayClient.auth.issueGameSession.mutate({
|
||||
sessionToken: login.sessionToken,
|
||||
profile: 'che:2',
|
||||
profile: 'che:908',
|
||||
});
|
||||
const access = await gameClient.auth.exchangeGatewayToken.mutate({
|
||||
gatewayToken: gatewayToken.gameToken,
|
||||
@@ -335,7 +335,7 @@ describe('auction integration flow', () => {
|
||||
const gatewayDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url;
|
||||
turnDaemon = await createTurnDaemonRuntime({
|
||||
profile: 'che',
|
||||
profileName: 'che:2',
|
||||
profileName: 'che:908',
|
||||
databaseUrl: gameDatabaseUrl,
|
||||
gatewayDatabaseUrl,
|
||||
});
|
||||
@@ -414,6 +414,9 @@ describe('auction integration flow', () => {
|
||||
where: { id: poorBidder.generalId },
|
||||
data: { gold: 50, rice: 1000 },
|
||||
});
|
||||
await prisma.worldState.updateMany({
|
||||
data: { currentMonth: 4 },
|
||||
});
|
||||
|
||||
if (turnDaemon) {
|
||||
await turnDaemon.lifecycle.stop('integration-test');
|
||||
@@ -424,31 +427,24 @@ describe('auction integration flow', () => {
|
||||
const gatewayDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url;
|
||||
turnDaemon = await createTurnDaemonRuntime({
|
||||
profile: 'che',
|
||||
profileName: 'che:2',
|
||||
profileName: 'che:908',
|
||||
databaseUrl: gameDatabaseUrl,
|
||||
gatewayDatabaseUrl,
|
||||
});
|
||||
turnDaemonLoop = turnDaemon.lifecycle.start();
|
||||
await sleep(500);
|
||||
|
||||
const now = new Date();
|
||||
const initialCloseAt = new Date(now.getTime() + 10_000);
|
||||
const auction = await prisma.auction.create({
|
||||
data: {
|
||||
type: 'BUY_RICE',
|
||||
targetCode: null,
|
||||
hostGeneralId: 0,
|
||||
hostName: '시스템',
|
||||
detail: {
|
||||
amount: 500,
|
||||
startBidAmount: 100,
|
||||
isReverse: false,
|
||||
availableLatestBidCloseDate: new Date(now.getTime() + 5 * 60_000).toISOString(),
|
||||
},
|
||||
status: 'OPEN',
|
||||
closeAt: initialCloseAt,
|
||||
},
|
||||
const hostClient = createGameClient(gameUrl, gameServer.config.trpcPath, {
|
||||
value: bidder3.accessToken,
|
||||
});
|
||||
const opened = await hostClient.auction.openBuyRice.mutate({
|
||||
amount: 500,
|
||||
closeTurnCnt: 1,
|
||||
startBidAmount: 250,
|
||||
finishBidAmount: 1000,
|
||||
});
|
||||
const auction = await prisma.auction.findUniqueOrThrow({ where: { id: opened.auctionId } });
|
||||
const initialCloseAt = auction.closeAt;
|
||||
|
||||
const keys = buildAuctionTimerKeys(gameServer.config.profileName);
|
||||
await seedAuctionTimers(prisma, redis, keys);
|
||||
@@ -458,7 +454,7 @@ describe('auction integration flow', () => {
|
||||
const bidder1Client = createGameClient(gameUrl, gameServer.config.trpcPath, {
|
||||
value: bidder1.accessToken,
|
||||
});
|
||||
await bidder1Client.auction.bidBuyRice.mutate({ auctionId: auction.id, amount: 200 });
|
||||
await bidder1Client.auction.bidBuyRice.mutate({ auctionId: auction.id, amount: 300 });
|
||||
|
||||
const bidRows = await prisma.auctionBid.findMany({ where: { auctionId: auction.id } });
|
||||
expect(bidRows).toHaveLength(1);
|
||||
@@ -466,9 +462,9 @@ describe('auction integration flow', () => {
|
||||
const poorClient = createGameClient(gameUrl, gameServer.config.trpcPath, {
|
||||
value: poorBidder.accessToken,
|
||||
});
|
||||
await expect(
|
||||
poorClient.auction.bidBuyRice.mutate({ auctionId: auction.id, amount: 500 })
|
||||
).rejects.toThrow('금이 부족합니다.');
|
||||
await expect(poorClient.auction.bidBuyRice.mutate({ auctionId: auction.id, amount: 500 })).rejects.toThrow(
|
||||
'금이 부족합니다.'
|
||||
);
|
||||
|
||||
const updatedAuction = await prisma.auction.findUnique({
|
||||
where: { id: auction.id },
|
||||
@@ -487,10 +483,7 @@ describe('auction integration flow', () => {
|
||||
});
|
||||
await redis.zAdd(keys.timerKey, [{ score: finalizeAt.getTime(), value: String(auction.id) }]);
|
||||
|
||||
const transport = new RedisTurnDaemonTransport(redis, {
|
||||
keys: buildTurnDaemonStreamKeys(gameServer.config.profileName),
|
||||
requestTimeoutMs: 10_000,
|
||||
});
|
||||
const transport = new DatabaseTurnDaemonTransport(prisma, 30_000);
|
||||
await prisma.$executeRaw(
|
||||
GamePrisma.sql`
|
||||
UPDATE auction
|
||||
@@ -500,7 +493,7 @@ describe('auction integration flow', () => {
|
||||
WHERE id = ${auction.id}
|
||||
`
|
||||
);
|
||||
const result = await transport.requestCommand({ type: 'auctionFinalize', auctionId: auction.id }, 10_000);
|
||||
const result = await transport.requestCommand({ type: 'auctionFinalize', auctionId: auction.id }, 30_000);
|
||||
expect(result?.ok).toBe(true);
|
||||
|
||||
const finished = await prisma.auction.findUnique({
|
||||
@@ -592,9 +585,9 @@ describe('auction integration flow', () => {
|
||||
const ownerClient = createGameClient(gameUrl, gameServer.config.trpcPath, {
|
||||
value: ownerBidder.accessToken,
|
||||
});
|
||||
await expect(
|
||||
ownerClient.auction.bidUnique.mutate({ auctionId: auction.id, amount: 300 })
|
||||
).rejects.toThrow('이미 다른 유니크를 가지고 있습니다.');
|
||||
await expect(ownerClient.auction.bidUnique.mutate({ auctionId: auction.id, amount: 300 })).rejects.toThrow(
|
||||
'이미 다른 유니크를 가지고 있습니다.'
|
||||
);
|
||||
|
||||
const validClient = createGameClient(gameUrl, gameServer.config.trpcPath, {
|
||||
value: validBidder.accessToken,
|
||||
@@ -622,7 +615,7 @@ describe('auction integration flow', () => {
|
||||
const gatewayDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url;
|
||||
turnDaemon = await createTurnDaemonRuntime({
|
||||
profile: 'che',
|
||||
profileName: 'che:2',
|
||||
profileName: 'che:908',
|
||||
databaseUrl: gameDatabaseUrl,
|
||||
gatewayDatabaseUrl,
|
||||
});
|
||||
@@ -636,10 +629,7 @@ describe('auction integration flow', () => {
|
||||
});
|
||||
await redis.zAdd(keys.timerKey, [{ score: finalizeAt.getTime(), value: String(auction.id) }]);
|
||||
|
||||
const transport = new RedisTurnDaemonTransport(redis, {
|
||||
keys: buildTurnDaemonStreamKeys(gameServer.config.profileName),
|
||||
requestTimeoutMs: 10_000,
|
||||
});
|
||||
const transport = new DatabaseTurnDaemonTransport(prisma, 30_000);
|
||||
await prisma.$executeRaw(
|
||||
GamePrisma.sql`
|
||||
UPDATE auction
|
||||
@@ -649,7 +639,7 @@ describe('auction integration flow', () => {
|
||||
WHERE id = ${auction.id}
|
||||
`
|
||||
);
|
||||
const result = await transport.requestCommand({ type: 'auctionFinalize', auctionId: auction.id }, 10_000);
|
||||
const result = await transport.requestCommand({ type: 'auctionFinalize', auctionId: auction.id }, 30_000);
|
||||
expect(result?.ok).toBe(false);
|
||||
|
||||
const reopened = await prisma.auction.findUnique({
|
||||
@@ -686,16 +676,20 @@ describe('auction integration flow', () => {
|
||||
where: { id: bidderB.generalId },
|
||||
data: { weaponCode: 'None', bookCode: 'None', horseCode: 'None', itemCode: 'None' },
|
||||
});
|
||||
await prisma.general.updateMany({
|
||||
where: { [slotField]: uniquePair.keyA } as GamePrisma.GeneralWhereInput,
|
||||
data: { [slotField]: 'None' } as GamePrisma.GeneralUpdateManyMutationInput,
|
||||
});
|
||||
|
||||
await prisma.inheritancePoint.upsert({
|
||||
where: { userId_key: { userId: bidderA.userId, key: 'previous' } },
|
||||
update: { value: 5000 },
|
||||
create: { userId: bidderA.userId, key: 'previous', value: 5000 },
|
||||
update: { value: 100_000 },
|
||||
create: { userId: bidderA.userId, key: 'previous', value: 100_000 },
|
||||
});
|
||||
await prisma.inheritancePoint.upsert({
|
||||
where: { userId_key: { userId: bidderB.userId, key: 'previous' } },
|
||||
update: { value: 5000 },
|
||||
create: { userId: bidderB.userId, key: 'previous', value: 5000 },
|
||||
update: { value: 100_000 },
|
||||
create: { userId: bidderB.userId, key: 'previous', value: 100_000 },
|
||||
});
|
||||
|
||||
if (turnDaemon) {
|
||||
@@ -707,7 +701,7 @@ describe('auction integration flow', () => {
|
||||
const gatewayDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url;
|
||||
turnDaemon = await createTurnDaemonRuntime({
|
||||
profile: 'che',
|
||||
profileName: 'che:2',
|
||||
profileName: 'che:908',
|
||||
databaseUrl: gameDatabaseUrl,
|
||||
gatewayDatabaseUrl,
|
||||
});
|
||||
@@ -729,32 +723,39 @@ describe('auction integration flow', () => {
|
||||
const keys = buildAuctionTimerKeys(gameServer.config.profileName);
|
||||
await redis.del(keys.timerKey);
|
||||
|
||||
const limitCloseAt = new Date(now.getTime() + 60_000);
|
||||
const auction = await prisma.auction.create({
|
||||
data: {
|
||||
type: 'UNIQUE_ITEM',
|
||||
targetCode: uniquePair.keyA,
|
||||
hostGeneralId: 0,
|
||||
hostName: '시스템',
|
||||
detail: {
|
||||
startBidAmount: 200,
|
||||
isReverse: false,
|
||||
availableLatestBidCloseDate: limitCloseAt.toISOString(),
|
||||
},
|
||||
status: 'OPEN',
|
||||
closeAt: new Date(now.getTime() + 2000),
|
||||
},
|
||||
const bidderAClient = createGameClient(gameUrl, gameServer.config.trpcPath, { value: bidderA.accessToken });
|
||||
const bidderBClient = createGameClient(gameUrl, gameServer.config.trpcPath, { value: bidderB.accessToken });
|
||||
const opened = await bidderAClient.auction.openUnique.mutate({
|
||||
itemKey: uniquePair.keyA,
|
||||
amount: 5000,
|
||||
});
|
||||
const limitCloseAt = new Date(now.getTime() + 60_000);
|
||||
await prisma.$executeRaw(
|
||||
GamePrisma.sql`
|
||||
UPDATE auction
|
||||
SET close_at = ${new Date(now.getTime() + 2000)},
|
||||
detail = jsonb_set(
|
||||
jsonb_set(
|
||||
detail,
|
||||
'{availableLatestBidCloseDate}',
|
||||
to_jsonb(${limitCloseAt.toISOString()}::text)
|
||||
),
|
||||
'{remainCloseDateExtensionCnt}',
|
||||
'0'::jsonb
|
||||
),
|
||||
updated_at = ${now}
|
||||
WHERE id = ${opened.auctionId}
|
||||
`
|
||||
);
|
||||
const auction = await prisma.auction.findUniqueOrThrow({ where: { id: opened.auctionId } });
|
||||
|
||||
await seedAuctionTimers(prisma, redis, keys);
|
||||
|
||||
const bidderAClient = createGameClient(gameUrl, gameServer.config.trpcPath, { value: bidderA.accessToken });
|
||||
const bidderBClient = createGameClient(gameUrl, gameServer.config.trpcPath, { value: bidderB.accessToken });
|
||||
|
||||
await bidderAClient.auction.bidUnique.mutate({ auctionId: auction.id, amount: 300, tryExtendCloseDate: true });
|
||||
await bidderBClient.auction.bidUnique.mutate({ auctionId: auction.id, amount: 350, tryExtendCloseDate: true });
|
||||
await bidderAClient.auction.bidUnique.mutate({ auctionId: auction.id, amount: 420, tryExtendCloseDate: true });
|
||||
await bidderBClient.auction.bidUnique.mutate({ auctionId: auction.id, amount: 500, tryExtendCloseDate: true });
|
||||
await bidderBClient.auction.bidUnique.mutate({ auctionId: auction.id, amount: 5100, tryExtendCloseDate: true });
|
||||
await bidderAClient.auction.bidUnique.mutate({ auctionId: auction.id, amount: 5200, tryExtendCloseDate: true });
|
||||
await bidderBClient.auction.bidUnique.mutate({ auctionId: auction.id, amount: 5300, tryExtendCloseDate: true });
|
||||
await bidderAClient.auction.bidUnique.mutate({ auctionId: auction.id, amount: 5400, tryExtendCloseDate: true });
|
||||
await bidderBClient.auction.bidUnique.mutate({ auctionId: auction.id, amount: 5500, tryExtendCloseDate: true });
|
||||
|
||||
const afterBids = await prisma.auction.findUnique({
|
||||
where: { id: auction.id },
|
||||
@@ -770,10 +771,7 @@ describe('auction integration flow', () => {
|
||||
});
|
||||
await redis.zAdd(keys.timerKey, [{ score: finalizeAt.getTime(), value: String(auction.id) }]);
|
||||
|
||||
const transport = new RedisTurnDaemonTransport(redis, {
|
||||
keys: buildTurnDaemonStreamKeys(gameServer.config.profileName),
|
||||
requestTimeoutMs: 10_000,
|
||||
});
|
||||
const transport = new DatabaseTurnDaemonTransport(prisma, 30_000);
|
||||
await prisma.$executeRaw(
|
||||
GamePrisma.sql`
|
||||
UPDATE auction
|
||||
@@ -783,7 +781,7 @@ describe('auction integration flow', () => {
|
||||
WHERE id = ${auction.id}
|
||||
`
|
||||
);
|
||||
const result = await transport.requestCommand({ type: 'auctionFinalize', auctionId: auction.id }, 10_000);
|
||||
const result = await transport.requestCommand({ type: 'auctionFinalize', auctionId: auction.id }, 30_000);
|
||||
expect(result?.ok).toBe(true);
|
||||
|
||||
const winner = await prisma.general.findUnique({
|
||||
@@ -793,4 +791,138 @@ describe('auction integration flow', () => {
|
||||
expect(winner).not.toBeNull();
|
||||
expect(Object.values(winner!)).toContain(uniquePair.keyA);
|
||||
}, 60_000);
|
||||
|
||||
it('opens the same neutral merchant auctions on the legacy monthly boundary', async () => {
|
||||
if (!gameConnector) {
|
||||
throw new Error('runtime not ready');
|
||||
}
|
||||
const prisma = gameConnector.prisma;
|
||||
if (turnDaemon) {
|
||||
await turnDaemon.lifecycle.stop('integration-test');
|
||||
await turnDaemon.close();
|
||||
await turnDaemonLoop;
|
||||
}
|
||||
|
||||
await prisma.auction.deleteMany({
|
||||
where: {
|
||||
hostGeneralId: 0,
|
||||
type: { in: ['BUY_RICE', 'SELL_RICE'] },
|
||||
},
|
||||
});
|
||||
const futureTurn = new Date(Date.now() + 60 * 60_000);
|
||||
await prisma.general.updateMany({
|
||||
where: { npcState: { lt: 2 } },
|
||||
data: {
|
||||
gold: 5_432,
|
||||
rice: 7_654,
|
||||
turnTime: futureTurn,
|
||||
},
|
||||
});
|
||||
const nationCount = await prisma.nation.count();
|
||||
let hiddenSeed = '';
|
||||
let expected = [] as ReturnType<typeof buildNeutralResourceAuctionPlan>;
|
||||
for (let index = 0; index < 1_000; index += 1) {
|
||||
hiddenSeed = `integration-neutral-${index}`;
|
||||
expected = buildNeutralResourceAuctionPlan({
|
||||
hiddenSeed,
|
||||
seedYear: 180,
|
||||
seedMonth: 1,
|
||||
nationCount,
|
||||
consumeTournamentRoll: false,
|
||||
averageGold: 5_432,
|
||||
averageRice: 7_654,
|
||||
buyRiceAuctionCount: 0,
|
||||
sellRiceAuctionCount: 0,
|
||||
});
|
||||
if (expected.length > 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(expected.length).toBeGreaterThan(0);
|
||||
|
||||
const worldState = await prisma.worldState.findFirstOrThrow();
|
||||
const worldMeta =
|
||||
worldState.meta && typeof worldState.meta === 'object' && !Array.isArray(worldState.meta)
|
||||
? worldState.meta
|
||||
: {};
|
||||
await prisma.worldState.update({
|
||||
where: { id: worldState.id },
|
||||
data: {
|
||||
currentYear: 180,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 60,
|
||||
meta: {
|
||||
...worldMeta,
|
||||
hiddenSeed,
|
||||
lastTurnTime: new Date(Date.now() - 61_000).toISOString(),
|
||||
neutralAuctionRegistrationKey: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const gameDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'che' }).url;
|
||||
const gatewayDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url;
|
||||
turnDaemon = await createTurnDaemonRuntime({
|
||||
profile: 'che',
|
||||
profileName: 'che:908',
|
||||
databaseUrl: gameDatabaseUrl,
|
||||
gatewayDatabaseUrl,
|
||||
});
|
||||
turnDaemonLoop = turnDaemon.lifecycle.start();
|
||||
|
||||
const deadline = Date.now() + 10_000;
|
||||
let rows = await prisma.auction.findMany({
|
||||
where: {
|
||||
hostGeneralId: 0,
|
||||
type: { in: ['BUY_RICE', 'SELL_RICE'] },
|
||||
},
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
while (rows.length < expected.length && Date.now() < deadline) {
|
||||
await sleep(100);
|
||||
rows = await prisma.auction.findMany({
|
||||
where: {
|
||||
hostGeneralId: 0,
|
||||
type: { in: ['BUY_RICE', 'SELL_RICE'] },
|
||||
},
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
expect(rows).toHaveLength(expected.length);
|
||||
for (const [index, plan] of expected.entries()) {
|
||||
const row = rows[index]!;
|
||||
const detail = row.detail as Record<string, unknown>;
|
||||
expect(row).toMatchObject({
|
||||
type: plan.auctionType,
|
||||
targetCode: String(plan.amount),
|
||||
hostGeneralId: 0,
|
||||
hostName: '상인',
|
||||
status: 'OPEN',
|
||||
});
|
||||
expect(detail).toMatchObject({
|
||||
amount: plan.amount,
|
||||
startBidAmount: plan.startBidAmount,
|
||||
finishBidAmount: plan.finishBidAmount,
|
||||
closeTurnCnt: plan.closeTurnCnt,
|
||||
seedYear: 180,
|
||||
seedMonth: 1,
|
||||
neutralRegistrationKey: '180-02',
|
||||
});
|
||||
expect(row.closeAt.getTime() - row.createdAt.getTime()).toBeGreaterThanOrEqual(
|
||||
plan.closeTurnCnt * 60_000 - 2_000
|
||||
);
|
||||
expect(row.closeAt.getTime() - row.createdAt.getTime()).toBeLessThanOrEqual(
|
||||
plan.closeTurnCnt * 60_000 + 2_000
|
||||
);
|
||||
}
|
||||
const persistedState = await prisma.worldState.findUniqueOrThrow({
|
||||
where: { id: worldState.id },
|
||||
});
|
||||
expect(persistedState).toMatchObject({
|
||||
currentYear: 180,
|
||||
currentMonth: 2,
|
||||
meta: expect.objectContaining({ neutralAuctionRegistrationKey: '180-02' }),
|
||||
});
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use sammo\LiteHashDRBG;
|
||||
use sammo\RandUtil;
|
||||
use sammo\Util;
|
||||
|
||||
if ($argc !== 3) {
|
||||
fwrite(STDERR, "usage: php neutral-auction.php <ref-root> <json-input>\n");
|
||||
exit(2);
|
||||
}
|
||||
|
||||
$refRoot = rtrim($argv[1], '/');
|
||||
require $refRoot . '/vendor/autoload.php';
|
||||
|
||||
$input = json_decode($argv[2], true, flags: JSON_THROW_ON_ERROR);
|
||||
$rng = new RandUtil(new LiteHashDRBG(Util::simpleSerialize(
|
||||
$input['hiddenSeed'],
|
||||
'monthly',
|
||||
$input['seedYear'],
|
||||
$input['seedMonth'],
|
||||
)));
|
||||
|
||||
for ($idx = 0; $idx < max(0, (int)$input['nationCount']); $idx++) {
|
||||
$rng->nextRange(0.95, 1.05);
|
||||
}
|
||||
if ($input['consumeTournamentRoll']) {
|
||||
$rng->nextBool(0.4);
|
||||
}
|
||||
|
||||
$avgGold = Util::valueFit($input['averageGold'], 1000, 20000);
|
||||
$avgRice = Util::valueFit($input['averageRice'], 1000, 20000);
|
||||
$result = [];
|
||||
$appendIfOpenable = static function (array $plan) use (&$result): void {
|
||||
if ($plan['closeTurnCnt'] < 1 || $plan['closeTurnCnt'] > 24) {
|
||||
return;
|
||||
}
|
||||
if ($plan['amount'] < 100 || $plan['amount'] > 10000) {
|
||||
return;
|
||||
}
|
||||
if ($plan['startBidAmount'] < $plan['amount'] * 0.5 || $plan['amount'] * 2 < $plan['startBidAmount']) {
|
||||
return;
|
||||
}
|
||||
if ($plan['finishBidAmount'] < $plan['amount'] * 1.1 || $plan['amount'] * 2 < $plan['finishBidAmount']) {
|
||||
return;
|
||||
}
|
||||
if ($plan['finishBidAmount'] < $plan['startBidAmount'] * 1.1) {
|
||||
return;
|
||||
}
|
||||
$result[] = $plan;
|
||||
};
|
||||
|
||||
$buyRiceCount = max(0, (int)$input['buyRiceAuctionCount']);
|
||||
if ($rng->nextBool(1 / ($buyRiceCount + 5))) {
|
||||
$mul = $rng->nextRangeInt(1, 5);
|
||||
$amount = $avgRice / 20 * $mul;
|
||||
$cost = $avgGold / 20 * 0.9 * $mul;
|
||||
$topv = $amount * 2;
|
||||
$cost = Util::valueFit($cost, $amount * 0.8, $amount * 1.2);
|
||||
$appendIfOpenable([
|
||||
'auctionType' => 'BUY_RICE',
|
||||
'amount' => Util::round($amount, -1),
|
||||
'startBidAmount' => Util::round($cost, -1),
|
||||
'finishBidAmount' => Util::round($topv, -1),
|
||||
'closeTurnCnt' => $rng->nextRangeInt(3, 12),
|
||||
]);
|
||||
}
|
||||
|
||||
$sellRiceCount = max(0, (int)$input['sellRiceAuctionCount']);
|
||||
if ($rng->nextBool(1 / ($sellRiceCount + 5))) {
|
||||
$mul = $rng->nextRangeInt(1, 5);
|
||||
$amount = $avgGold / 20 * $mul;
|
||||
$cost = $avgRice / 20 * 1.1 * $mul;
|
||||
$topv = $amount * 2;
|
||||
$cost = Util::valueFit($cost, $amount * 0.8, $amount * 1.2);
|
||||
$appendIfOpenable([
|
||||
'auctionType' => 'SELL_RICE',
|
||||
'amount' => Util::round($amount, -1),
|
||||
'startBidAmount' => Util::round($cost, -1),
|
||||
'finishBidAmount' => Util::round($topv, -1),
|
||||
'closeTurnCnt' => $rng->nextRangeInt(3, 12),
|
||||
]);
|
||||
}
|
||||
|
||||
echo json_encode($result, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), PHP_EOL;
|
||||
Reference in New Issue
Block a user