fix: Ref 게임 로직과 시나리오 풀 호환을 보정
월 경계, 전투 기술 상한, 연감과 베팅·설문·경매 정산 순서를 Ref 계약에 맞춘다.\n\n시나리오 일반 풀을 ENGINE mutation과 logical tick 기반으로 직렬화하고 914·915 catalog 및 조건부 100기 pool 실행 경계를 추가한다.\n\n경매 worker는 세대별 durable event만 만들고 ENGINE이 row lock 후 상태 전이와 정산을 단일 transaction으로 소유한다.
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@sammo-ts/infra', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
createGamePostgresConnector: vi.fn(() => ({
|
||||
connect: vi.fn(async () => undefined),
|
||||
disconnect: vi.fn(async () => undefined),
|
||||
prisma: {},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
buildAuctionOutbidRefundMessage,
|
||||
createAuctionBidder,
|
||||
hasAuctionBidClosePassed,
|
||||
hasAuctionClosePassed,
|
||||
hasEnoughResourceForAuctionBid,
|
||||
MIN_AUCTION_REMAINING_RESOURCE,
|
||||
} from '../src/auction/bidder.js';
|
||||
import { normalizeTurnDaemonCommand } from '../src/turn/commandRegistry.js';
|
||||
import type { TurnGeneral } from '../src/turn/types.js';
|
||||
|
||||
const bidder: TurnGeneral = {
|
||||
id: 7,
|
||||
name: '관우',
|
||||
nationId: 1,
|
||||
cityId: 1,
|
||||
troopId: 0,
|
||||
stats: { leadership: 80, strength: 90, intelligence: 70 },
|
||||
turnTime: new Date('0190-01-01T00:00:00.000Z'),
|
||||
recentWarTime: null,
|
||||
role: {
|
||||
items: { horse: null, weapon: null, book: null, item: null },
|
||||
personality: null,
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
},
|
||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
meta: { killturn: 24 },
|
||||
penalty: {},
|
||||
officerLevel: 1,
|
||||
experience: 0,
|
||||
dedication: 0,
|
||||
injury: 0,
|
||||
gold: 2_000,
|
||||
rice: 2_000,
|
||||
crew: 0,
|
||||
crewTypeId: 0,
|
||||
train: 0,
|
||||
atmos: 0,
|
||||
age: 30,
|
||||
npcState: 0,
|
||||
picture: 'generals/7.png',
|
||||
};
|
||||
|
||||
const runDelayedResourceBid = async (finishImmediately: boolean) => {
|
||||
const acceptedAt = new Date('0190-02-01T00:00:00.000Z');
|
||||
const processingAt = new Date(acceptedAt.getTime() + 30 * 60_000);
|
||||
const general = {
|
||||
...bidder,
|
||||
role: { ...bidder.role, items: { ...bidder.role.items } },
|
||||
meta: { ...bidder.meta },
|
||||
};
|
||||
const world = {
|
||||
getGameNow: () => processingAt,
|
||||
gameTickToDate: (tick: number) => new Date(acceptedAt.getTime() + (tick - 100) * 1_000),
|
||||
dateToGameTick: (date: Date) => 100 + Math.floor((date.getTime() - acceptedAt.getTime()) / 1_000),
|
||||
getState: () => ({ tickSeconds: 600 }),
|
||||
getGeneralById: (id: number) => (id === general.id ? general : null),
|
||||
updateGeneral: (_id: number, patch: Partial<TurnGeneral>) => Object.assign(general, patch),
|
||||
};
|
||||
const executeRaw = vi.fn(async (_query: unknown) => 1);
|
||||
const commandDb = {
|
||||
$queryRaw: vi.fn(async (query: { strings: readonly string[] }) => {
|
||||
const text = query.strings.join(' ');
|
||||
if (text.includes('FROM auction') && !text.includes('auction_bid')) {
|
||||
return [
|
||||
{
|
||||
id: 31,
|
||||
type: 'BUY_RICE',
|
||||
targetCode: '100',
|
||||
hostGeneralId: 88,
|
||||
detail: {
|
||||
title: '쌀 100 경매',
|
||||
amount: 100,
|
||||
isReverse: false,
|
||||
startBidAmount: 100,
|
||||
finishBidAmount: finishImmediately ? 500 : null,
|
||||
},
|
||||
status: 'OPEN',
|
||||
closeAt: new Date(acceptedAt.getTime() + 60_000),
|
||||
closeTick: 160n,
|
||||
latestEventId: 'previous-event',
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
$executeRaw: executeRaw,
|
||||
$executeRawUnsafe: vi.fn(async () => 1),
|
||||
};
|
||||
const auctionBidder = await createAuctionBidder({
|
||||
databaseUrl: 'postgresql://unused',
|
||||
world: world as unknown as Parameters<typeof createAuctionBidder>[0]['world'],
|
||||
});
|
||||
const amount = finishImmediately ? 500 : 200;
|
||||
const result = await auctionBidder.bid(
|
||||
{
|
||||
type: 'auctionBid',
|
||||
auctionId: 31,
|
||||
generalId: general.id,
|
||||
amount,
|
||||
acceptedGameTick: 100,
|
||||
},
|
||||
commandDb as any
|
||||
);
|
||||
await auctionBidder.close();
|
||||
|
||||
const statements = executeRaw.mock.calls.map(
|
||||
([query]) => query as { strings: readonly string[]; values: unknown[] }
|
||||
);
|
||||
const insert = statements.find((query) => query.strings.join(' ').includes('INSERT INTO auction_bid'));
|
||||
const update = statements.find((query) => query.strings.join(' ').includes('UPDATE auction'));
|
||||
return { acceptedAt, processingAt, result, insert, update };
|
||||
};
|
||||
|
||||
describe('resource auction Ref compatibility', () => {
|
||||
it('keeps the auction open through its exact close tick', () => {
|
||||
const closeAt = new Date('0190-02-01T00:00:00.000Z');
|
||||
const auction = { closeAt, closeTick: 72_000_000n };
|
||||
|
||||
expect(hasAuctionClosePassed(auction, closeAt, 72_000_000)).toBe(false);
|
||||
expect(hasAuctionClosePassed(auction, new Date(closeAt.getTime() + 1), 72_000_001)).toBe(true);
|
||||
expect(hasAuctionClosePassed({ closeAt, closeTick: null }, closeAt, null)).toBe(false);
|
||||
expect(hasAuctionClosePassed({ closeAt, closeTick: null }, new Date(closeAt.getTime() + 1), null)).toBe(true);
|
||||
});
|
||||
|
||||
it('uses the durable API acceptance tick when queue processing crosses the close boundary', () => {
|
||||
const closeAt = new Date('0190-02-01T00:00:00.000Z');
|
||||
const auction = { closeAt, closeTick: 72_000_000n };
|
||||
const world = {
|
||||
dateToGameTick: () => 72_000_001,
|
||||
gameTickToDate: (tick: number) => (tick === 72_000_000 ? closeAt : new Date(closeAt.getTime() + 1)),
|
||||
};
|
||||
const processingNow = new Date(closeAt.getTime() + 1);
|
||||
|
||||
expect(hasAuctionBidClosePassed(auction, world, processingNow, 72_000_000)).toBe(false);
|
||||
expect(hasAuctionBidClosePassed(auction, world, processingNow)).toBe(true);
|
||||
expect(
|
||||
normalizeTurnDaemonCommand({
|
||||
requestId: 'auction-bid-accepted-tick',
|
||||
sentAt: '2026-08-23T00:00:00.000Z',
|
||||
command: {
|
||||
type: 'auctionBid',
|
||||
auctionId: 31,
|
||||
generalId: 7,
|
||||
amount: 500,
|
||||
acceptedGameTick: 72_000_000,
|
||||
},
|
||||
})
|
||||
).toMatchObject({ acceptedGameTick: 72_000_000 });
|
||||
});
|
||||
|
||||
it('uses the accepted logical time for delayed extension and persisted bid timestamps', async () => {
|
||||
const { acceptedAt, processingAt, result, insert, update } = await runDelayedResourceBid(false);
|
||||
|
||||
expect(result).toMatchObject({ type: 'auctionBid', ok: true });
|
||||
expect(new Date(String(result && 'closeAt' in result ? result.closeAt : '')).getTime()).toBe(
|
||||
acceptedAt.getTime() + 100_000
|
||||
);
|
||||
expect(insert?.values.filter((value): value is Date => value instanceof Date)).toEqual([acceptedAt]);
|
||||
expect(update?.values.filter((value): value is Date => value instanceof Date)).toEqual([
|
||||
new Date(acceptedAt.getTime() + 100_000),
|
||||
acceptedAt,
|
||||
acceptedAt,
|
||||
]);
|
||||
expect(update?.values).not.toContain(processingAt);
|
||||
});
|
||||
|
||||
it('uses the accepted logical time for a delayed finish-price one-turn close', async () => {
|
||||
const { acceptedAt, result, update } = await runDelayedResourceBid(true);
|
||||
|
||||
expect(result).toMatchObject({ type: 'auctionBid', ok: true });
|
||||
expect(new Date(String(result && 'closeAt' in result ? result.closeAt : '')).getTime()).toBe(
|
||||
acceptedAt.getTime() + 10 * 60_000
|
||||
);
|
||||
expect(update?.values[0]).toEqual(new Date(acceptedAt.getTime() + 10 * 60_000));
|
||||
});
|
||||
|
||||
it('requires the bidder to retain the default 1000 resource', () => {
|
||||
expect(MIN_AUCTION_REMAINING_RESOURCE).toBe(1_000);
|
||||
expect(hasEnoughResourceForAuctionBid(1_500, 500)).toBe(true);
|
||||
expect(hasEnoughResourceForAuctionBid(1_499, 500)).toBe(false);
|
||||
expect(hasEnoughResourceForAuctionBid(2_000, 0)).toBe(false);
|
||||
});
|
||||
|
||||
it('builds the receiver-only system message used by Ref refundBid', () => {
|
||||
const time = new Date('0190-02-01T00:00:00.000Z');
|
||||
const message = buildAuctionOutbidRefundMessage({
|
||||
auctionId: 31,
|
||||
title: '쌀 100 경매',
|
||||
bidder,
|
||||
nation: { name: '촉', color: '#ff0000' },
|
||||
time,
|
||||
});
|
||||
|
||||
expect(message).toMatchObject({
|
||||
msgType: 'private',
|
||||
src: { generalId: 0, nationName: 'System' },
|
||||
dest: { generalId: 7, generalName: '관우', nationId: 1, nationName: '촉' },
|
||||
text: '31번 쌀 100 경매에 상회입찰자가 나타났습니다.',
|
||||
sendDestOnly: true,
|
||||
});
|
||||
expect(message.time).not.toBe(time);
|
||||
expect(message.time).toEqual(time);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,446 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
|
||||
|
||||
vi.mock('@sammo-ts/infra', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
createGamePostgresConnector: vi.fn(() => ({
|
||||
connect: vi.fn(async () => undefined),
|
||||
disconnect: vi.fn(async () => undefined),
|
||||
prisma: {},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
buildAuctionCancellationMessage,
|
||||
buildUniqueAuctionAwardLogs,
|
||||
buildUniqueAuctionInheritanceLogData,
|
||||
createAuctionFinalizer,
|
||||
hasAuctionFinalizeDeadlineArrived,
|
||||
isAuctionFinalizeGenerationCurrent,
|
||||
isUniqueAuctionSupplyExhausted,
|
||||
resolveAuctionResourceAmount,
|
||||
resolveUniqueSupplyRetryCloseAt,
|
||||
} from '../src/auction/finalizer.js';
|
||||
import { buildInitialUniqueAuctionBidMeta, openAuction } from '../src/auction/opener.js';
|
||||
import type { TurnGeneral } from '../src/turn/types.js';
|
||||
|
||||
describe('unique auction inheritance log compatibility', () => {
|
||||
it('keeps the authenticated UUID owner instead of coercing it to a legacy number', () => {
|
||||
const userId = '4c2f2f6d-8a37-4f22-a4f9-1a6f5e4c22ec';
|
||||
|
||||
expect(
|
||||
buildUniqueAuctionInheritanceLogData({
|
||||
userId,
|
||||
year: 193,
|
||||
month: 7,
|
||||
itemName: '논어(+7)',
|
||||
amount: 6_000,
|
||||
})
|
||||
).toEqual({
|
||||
userId,
|
||||
year: 193,
|
||||
month: 7,
|
||||
logType: 'inheritPoint',
|
||||
text: '유니크 논어(+7) 경매로 6000 포인트 사용',
|
||||
});
|
||||
});
|
||||
|
||||
it('tracks the complete opening bid so rollback can restore rank and point escrow', () => {
|
||||
expect(buildInitialUniqueAuctionBidMeta('익명의 수배자', 6_000)).toEqual({
|
||||
obfuscatedName: '익명의 수배자',
|
||||
tryExtendCloseDate: false,
|
||||
inheritSpentTrackedAmount: 6_000,
|
||||
});
|
||||
});
|
||||
|
||||
it('deducts and mirrors the initial unique bid in the same engine transaction', async () => {
|
||||
const general = {
|
||||
id: 7,
|
||||
name: '관우',
|
||||
nationId: 1,
|
||||
role: { items: { horse: null, weapon: null, book: null, item: null } },
|
||||
inheritancePoints: { previous: 7_000 },
|
||||
meta: { killturn: 24, inherit_spent_dyn: 0 },
|
||||
} as unknown as TurnGeneral;
|
||||
const captured: {
|
||||
auction: { bids: { create: { meta: unknown } } } | null;
|
||||
pointUpdate: { data: unknown } | null;
|
||||
} = { auction: null, pointUpdate: null };
|
||||
const executedSql: string[] = [];
|
||||
const db = {
|
||||
auction: {
|
||||
findFirst: async () => null,
|
||||
create: async ({ data }: { data: Record<string, unknown> }) => {
|
||||
captured.auction = data as unknown as NonNullable<typeof captured.auction>;
|
||||
return { id: 31 };
|
||||
},
|
||||
},
|
||||
inheritancePoint: {
|
||||
update: async (args: Record<string, unknown>) => {
|
||||
captured.pointUpdate = args as unknown as NonNullable<typeof captured.pointUpdate>;
|
||||
return args;
|
||||
},
|
||||
},
|
||||
$queryRaw: async (query: { strings: readonly string[] }) => {
|
||||
const text = query.strings.join(' ');
|
||||
if (text.includes('user_id as "userId"')) return [{ userId: 'owner-uuid' }];
|
||||
if (text.includes('FROM inheritance_point')) return [{ value: 7_000 }];
|
||||
return [];
|
||||
},
|
||||
$executeRaw: async (query: { strings: readonly string[] }) => {
|
||||
executedSql.push(query.strings.join(' '));
|
||||
return 1;
|
||||
},
|
||||
};
|
||||
const world = {
|
||||
getGeneralById: (id: number) => (id === general.id ? general : null),
|
||||
getScenarioConfig: () => ({
|
||||
const: {
|
||||
inheritItemUniqueMinPoint: 5_000,
|
||||
allItems: { weapon: { che_무기_12_칠성검: 1 } },
|
||||
},
|
||||
}),
|
||||
listGenerals: () => [general],
|
||||
getState: () => ({
|
||||
id: 1,
|
||||
currentYear: 193,
|
||||
currentMonth: 7,
|
||||
tickSeconds: 600,
|
||||
meta: { initYear: 190, initMonth: 1, hiddenSeed: 'seed' },
|
||||
}),
|
||||
getGameNow: () => new Date('0193-07-01T00:00:00.000Z'),
|
||||
dateToGameTick: (date: Date) => Math.floor(date.getTime() / 1_000),
|
||||
updateGeneral: (_id: number, patch: Partial<TurnGeneral>) => Object.assign(general, patch),
|
||||
pushLog: () => {},
|
||||
};
|
||||
|
||||
const result = await openAuction(
|
||||
{
|
||||
type: 'auctionOpen',
|
||||
auctionType: 'UNIQUE_ITEM',
|
||||
generalId: general.id,
|
||||
amount: 6_000,
|
||||
itemKey: 'che_무기_12_칠성검',
|
||||
},
|
||||
world as unknown as Parameters<typeof openAuction>[1],
|
||||
db as unknown as NonNullable<Parameters<typeof openAuction>[2]>
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ type: 'auctionOpen', ok: true, auctionId: 31 });
|
||||
expect(captured.auction?.bids.create.meta).toEqual(
|
||||
expect.objectContaining({ inheritSpentTrackedAmount: 6_000 })
|
||||
);
|
||||
expect(captured.pointUpdate?.data).toEqual({ value: 1_000 });
|
||||
expect(executedSql.some((text) => text.includes('INSERT INTO rank_data'))).toBe(true);
|
||||
expect(general.inheritancePoints?.previous).toBe(1_000);
|
||||
expect(general.meta.inherit_spent_dyn).toBe(6_000);
|
||||
});
|
||||
|
||||
it('recovers a resource amount from the legacy target field when detail is malformed', () => {
|
||||
expect(resolveAuctionResourceAmount(undefined, '1200')).toBe(1_200);
|
||||
expect(resolveAuctionResourceAmount(900, '1200')).toBe(900);
|
||||
expect(resolveAuctionResourceAmount(undefined, 'invalid')).toBeNull();
|
||||
});
|
||||
|
||||
it('uses the Ref-inclusive close boundary and the logical tick as the deadline generation', () => {
|
||||
const closeAt = new Date('0193-07-01T00:00:00.000Z');
|
||||
const auction = { closeAt, closeTick: 72_000_000n };
|
||||
|
||||
expect(hasAuctionFinalizeDeadlineArrived(auction, closeAt, 71_999_999)).toBe(false);
|
||||
expect(hasAuctionFinalizeDeadlineArrived(auction, closeAt, 72_000_000)).toBe(true);
|
||||
expect(
|
||||
isAuctionFinalizeGenerationCurrent(auction, {
|
||||
expectedCloseAt: new Date(closeAt.getTime() + 60_000).toISOString(),
|
||||
expectedCloseTick: 72_000_000,
|
||||
})
|
||||
).toBe(true);
|
||||
expect(isAuctionFinalizeGenerationCurrent(auction, { expectedCloseTick: 72_000_001 })).toBe(false);
|
||||
});
|
||||
|
||||
it('locks a due OPEN row, owns OPEN to FINALIZING, and settles through the same transaction client', async () => {
|
||||
const closeAt = new Date('0193-07-01T00:00:00.000Z');
|
||||
const queryTexts: string[] = [];
|
||||
const executeTexts: string[] = [];
|
||||
const commandDb = {
|
||||
$queryRaw: vi.fn(async (query: { strings: readonly string[] }) => {
|
||||
const text = query.strings.join(' ');
|
||||
queryTexts.push(text);
|
||||
if (text.includes('FROM auction_bid')) return [];
|
||||
return [
|
||||
{
|
||||
id: 31,
|
||||
type: 'BUY_RICE',
|
||||
targetCode: '100',
|
||||
hostGeneralId: 0,
|
||||
hostName: '(상인)',
|
||||
detail: { amount: 100 },
|
||||
status: 'OPEN',
|
||||
closeAt,
|
||||
closeTick: 72_000_000n,
|
||||
},
|
||||
];
|
||||
}),
|
||||
$executeRaw: vi.fn(async (query: { strings: readonly string[] }) => {
|
||||
executeTexts.push(query.strings.join(' '));
|
||||
return 1;
|
||||
}),
|
||||
};
|
||||
const world = {
|
||||
getGameNow: () => closeAt,
|
||||
dateToGameTick: () => 72_000_000,
|
||||
pushLog: vi.fn(),
|
||||
};
|
||||
const finalizer = await createAuctionFinalizer({
|
||||
databaseUrl: 'postgresql://unused',
|
||||
world: world as unknown as Parameters<typeof createAuctionFinalizer>[0]['world'],
|
||||
});
|
||||
|
||||
await expect(
|
||||
finalizer.finalize(
|
||||
{
|
||||
type: 'auctionFinalize',
|
||||
auctionId: 31,
|
||||
expectedCloseAt: closeAt.toISOString(),
|
||||
expectedCloseTick: 72_000_000,
|
||||
},
|
||||
commandDb as unknown as NonNullable<Parameters<typeof finalizer.finalize>[1]>
|
||||
)
|
||||
).resolves.toEqual({ type: 'auctionFinalize', ok: true, auctionId: 31 });
|
||||
expect(queryTexts[0]).toContain('FOR UPDATE');
|
||||
expect(executeTexts[0]).toContain("SET status = 'FINALIZING'");
|
||||
expect(executeTexts[1]).toContain('SET status =');
|
||||
expect(executeTexts[1]).toContain('finished_at');
|
||||
|
||||
await finalizer.close();
|
||||
});
|
||||
|
||||
it('leaves OPEN unchanged when the event generation is stale or the locked deadline is not due', async () => {
|
||||
const closeAt = new Date('0193-07-01T00:00:00.000Z');
|
||||
const executeRaw = vi.fn(async () => 1);
|
||||
const commandDb = {
|
||||
$queryRaw: vi.fn(async () => [
|
||||
{
|
||||
id: 31,
|
||||
type: 'BUY_RICE',
|
||||
targetCode: '100',
|
||||
hostGeneralId: 0,
|
||||
hostName: '(상인)',
|
||||
detail: { amount: 100 },
|
||||
status: 'OPEN',
|
||||
closeAt,
|
||||
closeTick: 72_000_000n,
|
||||
},
|
||||
]),
|
||||
$executeRaw: executeRaw,
|
||||
};
|
||||
let nowTick = 71_999_999;
|
||||
const world = {
|
||||
getGameNow: () => closeAt,
|
||||
dateToGameTick: () => nowTick,
|
||||
};
|
||||
const finalizer = await createAuctionFinalizer({
|
||||
databaseUrl: 'postgresql://unused',
|
||||
world: world as unknown as Parameters<typeof createAuctionFinalizer>[0]['world'],
|
||||
});
|
||||
const db = commandDb as unknown as NonNullable<Parameters<typeof finalizer.finalize>[1]>;
|
||||
|
||||
await expect(
|
||||
finalizer.finalize({ type: 'auctionFinalize', auctionId: 31, expectedCloseTick: 72_000_000 }, db)
|
||||
).resolves.toMatchObject({ ok: false, reason: '경매 마감 시각이 아직 지나지 않았습니다.' });
|
||||
nowTick = 72_000_000;
|
||||
await expect(
|
||||
finalizer.finalize({ type: 'auctionFinalize', auctionId: 31, expectedCloseTick: 71_999_999 }, db)
|
||||
).resolves.toMatchObject({ ok: false, reason: '경매 마감 세대가 변경되었습니다.' });
|
||||
expect(executeRaw).not.toHaveBeenCalled();
|
||||
|
||||
await finalizer.close();
|
||||
});
|
||||
|
||||
it('fails before settlement when the locked OPEN transition is not applied', async () => {
|
||||
const closeAt = new Date('0193-07-01T00:00:00.000Z');
|
||||
const queryRaw = vi.fn(async (query: { strings: readonly string[] }) => {
|
||||
if (query.strings.join(' ').includes('FROM auction_bid')) {
|
||||
throw new Error('settlement query must not run');
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: 31,
|
||||
type: 'BUY_RICE',
|
||||
targetCode: '100',
|
||||
hostGeneralId: 0,
|
||||
hostName: '(상인)',
|
||||
detail: { amount: 100 },
|
||||
status: 'OPEN',
|
||||
closeAt,
|
||||
closeTick: null,
|
||||
},
|
||||
];
|
||||
});
|
||||
const commandDb = { $queryRaw: queryRaw, $executeRaw: vi.fn(async () => 0) };
|
||||
const world = { getGameNow: () => closeAt, dateToGameTick: () => 72_000_000 };
|
||||
const finalizer = await createAuctionFinalizer({
|
||||
databaseUrl: 'postgresql://unused',
|
||||
world: world as unknown as Parameters<typeof createAuctionFinalizer>[0]['world'],
|
||||
});
|
||||
|
||||
await expect(
|
||||
finalizer.finalize(
|
||||
{ type: 'auctionFinalize', auctionId: 31, expectedCloseAt: closeAt.toISOString() },
|
||||
commandDb as unknown as NonNullable<Parameters<typeof finalizer.finalize>[1]>
|
||||
)
|
||||
).rejects.toThrow('경매 확정 상태 전이에 실패했습니다: 31');
|
||||
expect(queryRaw).toHaveBeenCalledTimes(1);
|
||||
|
||||
await finalizer.close();
|
||||
});
|
||||
|
||||
it('keeps both escrows and FINALIZING when a resource amount cannot be recovered', async () => {
|
||||
const bidder = {
|
||||
id: 7,
|
||||
name: '관우',
|
||||
nationId: 1,
|
||||
gold: 1_000,
|
||||
rice: 1_000,
|
||||
} as TurnGeneral;
|
||||
const host = {
|
||||
id: 8,
|
||||
name: '장비',
|
||||
nationId: 1,
|
||||
gold: 1_000,
|
||||
rice: 1_000,
|
||||
} as TurnGeneral;
|
||||
const updateGeneral = vi.fn();
|
||||
const queueMessage = vi.fn();
|
||||
const world = {
|
||||
getGameNow: () => new Date('0193-07-01T00:00:00.000Z'),
|
||||
getGeneralById: (id: number) => (id === bidder.id ? bidder : id === host.id ? host : null),
|
||||
getNationById: () => ({ name: '촉', color: '#ff0000' }),
|
||||
updateGeneral,
|
||||
queueMessage,
|
||||
};
|
||||
const executeRaw = vi.fn(async () => 1);
|
||||
const commandDb = {
|
||||
$queryRaw: vi.fn(async (query: { strings: readonly string[] }) =>
|
||||
query.strings.join(' ').includes('FROM auction_bid')
|
||||
? [{ id: 41, generalId: bidder.id, amount: 500, meta: {} }]
|
||||
: [
|
||||
{
|
||||
id: 31,
|
||||
type: 'BUY_RICE',
|
||||
targetCode: 'invalid',
|
||||
hostGeneralId: host.id,
|
||||
hostName: host.name,
|
||||
detail: { title: '손상된 경매', isReverse: false },
|
||||
status: 'FINALIZING',
|
||||
closeAt: new Date('0193-07-01T00:00:00.000Z'),
|
||||
closeTick: null,
|
||||
},
|
||||
]
|
||||
),
|
||||
$executeRaw: executeRaw,
|
||||
};
|
||||
const finalizer = await createAuctionFinalizer({
|
||||
databaseUrl: 'postgresql://unused',
|
||||
world: world as unknown as Parameters<typeof createAuctionFinalizer>[0]['world'],
|
||||
});
|
||||
|
||||
await expect(
|
||||
finalizer.finalize(
|
||||
{ type: 'auctionFinalize', auctionId: 31 },
|
||||
commandDb as unknown as NonNullable<Parameters<typeof finalizer.finalize>[1]>
|
||||
)
|
||||
).resolves.toMatchObject({
|
||||
type: 'auctionFinalize',
|
||||
ok: false,
|
||||
auctionId: 31,
|
||||
reason: '경매 거래량 정보가 없습니다.',
|
||||
});
|
||||
expect(executeRaw).not.toHaveBeenCalled();
|
||||
expect(updateGeneral).not.toHaveBeenCalled();
|
||||
expect(queueMessage).not.toHaveBeenCalled();
|
||||
expect(bidder.gold).toBe(1_000);
|
||||
expect(host.rice).toBe(1_000);
|
||||
|
||||
await finalizer.close();
|
||||
});
|
||||
|
||||
it('rejects a final award once the configured unique supply is occupied', () => {
|
||||
expect(isUniqueAuctionSupplyExhausted(2, 1)).toBe(false);
|
||||
expect(isUniqueAuctionSupplyExhausted(2, 2)).toBe(true);
|
||||
expect(resolveUniqueSupplyRetryCloseAt(new Date('0193-07-01T00:00:00.000Z'), 10).toISOString()).toBe(
|
||||
'0193-07-01T00:10:00.000Z'
|
||||
);
|
||||
});
|
||||
|
||||
it('builds all four Ref award logs with the original formats and labels', () => {
|
||||
const bidder = {
|
||||
id: 7,
|
||||
name: '관우',
|
||||
nationId: 1,
|
||||
} as TurnGeneral;
|
||||
|
||||
expect(
|
||||
buildUniqueAuctionAwardLogs({
|
||||
bidder,
|
||||
nationName: '촉',
|
||||
itemName: '칠성검(+12)',
|
||||
itemRawName: '칠성검',
|
||||
})
|
||||
).toEqual([
|
||||
expect.objectContaining({
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.MONTH,
|
||||
generalId: 7,
|
||||
text: '<C>칠성검(+12)</>을 습득했습니다!',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.HISTORY,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
generalId: 7,
|
||||
text: '<C>칠성검(+12)</>을 습득',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
scope: LogScope.SYSTEM,
|
||||
category: LogCategory.SUMMARY,
|
||||
format: LogFormat.MONTH,
|
||||
text: '<Y>관우</>가 <C>칠성검(+12)</>을 습득했습니다!',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
scope: LogScope.SYSTEM,
|
||||
category: LogCategory.HISTORY,
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
text: '<C><b>【보물수배】</b></><D><b>촉</b></>의 <Y>관우</>가 <C>칠성검(+12)</>을 습득했습니다!',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds the Ref cancellation refund message for the affected bidder only', () => {
|
||||
const bidder = {
|
||||
id: 7,
|
||||
name: '관우',
|
||||
nationId: 1,
|
||||
picture: 'generals/7.png',
|
||||
} as TurnGeneral;
|
||||
const time = new Date('0193-07-01T00:00:00.000Z');
|
||||
|
||||
expect(
|
||||
buildAuctionCancellationMessage({
|
||||
auctionId: 31,
|
||||
title: '논어 경매',
|
||||
bidder,
|
||||
nation: { name: '촉', color: '#ff0000' },
|
||||
time,
|
||||
})
|
||||
).toMatchObject({
|
||||
msgType: 'private',
|
||||
dest: { generalId: 7, nationId: 1, nationName: '촉' },
|
||||
text: '31번 논어 경매가 취소되었습니다.',
|
||||
sendDestOnly: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||
|
||||
import { loadGeneralPoolEntries } from '../src/scenario/generalPoolLoader.js';
|
||||
import {
|
||||
buildSelectPoolSeed,
|
||||
calculateSelectionCandidateWeight,
|
||||
claimWeightedSelectionCandidates,
|
||||
type SelectPoolCandidateInfo,
|
||||
} from '../src/turn/selectPoolService.js';
|
||||
|
||||
const toCandidate = (info: Record<string, unknown>): SelectPoolCandidateInfo => {
|
||||
const dex = info.dex;
|
||||
if (
|
||||
typeof info.uniqueName !== 'string' ||
|
||||
typeof info.generalName !== 'string' ||
|
||||
typeof info.leadership !== 'number' ||
|
||||
typeof info.strength !== 'number' ||
|
||||
typeof info.intel !== 'number' ||
|
||||
(info.specialDomestic !== null && typeof info.specialDomestic !== 'string') ||
|
||||
!Array.isArray(dex) ||
|
||||
dex.length !== 5 ||
|
||||
dex.some((value) => typeof value !== 'number') ||
|
||||
(info.imgsvr !== 0 && info.imgsvr !== 1) ||
|
||||
typeof info.picture !== 'string'
|
||||
) {
|
||||
throw new Error('invalid SPoolUnderU100 test entry');
|
||||
}
|
||||
return {
|
||||
uniqueName: info.uniqueName,
|
||||
generalName: info.generalName,
|
||||
leadership: info.leadership,
|
||||
strength: info.strength,
|
||||
intel: info.intel,
|
||||
specialDomestic: info.specialDomestic,
|
||||
dex: dex as [number, number, number, number, number],
|
||||
imgsvr: info.imgsvr,
|
||||
picture: info.picture,
|
||||
};
|
||||
};
|
||||
|
||||
describe('SPoolUnderU100 deterministic selection', () => {
|
||||
it('uses the Ref user/NPC weight contract including the zero-dex floor', () => {
|
||||
const candidate = toCandidate({
|
||||
uniqueName: 'A1000001',
|
||||
generalName: 'weight fixture',
|
||||
leadership: 70,
|
||||
strength: 60,
|
||||
intel: 60,
|
||||
specialDomestic: null,
|
||||
dex: [0, 0, 0, 0, 0],
|
||||
imgsvr: 0,
|
||||
picture: '0',
|
||||
});
|
||||
|
||||
expect(calculateSelectionCandidateWeight('SPoolUnderU100', candidate, false)).toBe(100_000);
|
||||
expect(calculateSelectionCandidateWeight('SPoolUnderU100', candidate, true)).toBe(150_000);
|
||||
});
|
||||
|
||||
it('keeps the fixed-seed 14-candidate draw stable', async () => {
|
||||
const entries = await loadGeneralPoolEntries('SPoolUnderU100');
|
||||
const rows = entries.map((entry, index) => ({ id: index + 1, ...entry }));
|
||||
const rng = new RandUtil(new LiteHashDRBG(buildSelectPoolSeed('s100-vector-hidden', 42, 72_000_000)));
|
||||
const draws: string[] = [];
|
||||
const selected = await claimWeightedSelectionCandidates({
|
||||
weighted: rows.map((row) => [
|
||||
row,
|
||||
calculateSelectionCandidateWeight('SPoolUnderU100', toCandidate(row.info), true),
|
||||
]),
|
||||
rng,
|
||||
count: 14,
|
||||
claim: async () => true,
|
||||
onDraw: (candidate) => draws.push(candidate.uniqueName),
|
||||
});
|
||||
|
||||
expect(selected.map((candidate) => candidate.uniqueName)).toEqual([
|
||||
'A1004478',
|
||||
'A1000583',
|
||||
'A1002480',
|
||||
'A1001485',
|
||||
'A1002714',
|
||||
'A1004544',
|
||||
'A1000918',
|
||||
'A1004549',
|
||||
'A1003871',
|
||||
'A1002678',
|
||||
'A1000531',
|
||||
'A1003379',
|
||||
'A1004275',
|
||||
'A1003449',
|
||||
]);
|
||||
expect(draws).toEqual(selected.map((candidate) => candidate.uniqueName));
|
||||
});
|
||||
|
||||
it('keeps duplicate draws in the RNG stream while claiming each row once', async () => {
|
||||
const candidates = [
|
||||
{ id: 1, uniqueName: 'first' },
|
||||
{ id: 2, uniqueName: 'second' },
|
||||
];
|
||||
const draws: string[] = [];
|
||||
const selected = await claimWeightedSelectionCandidates({
|
||||
weighted: [
|
||||
[candidates[0]!, 3],
|
||||
[candidates[1]!, 1],
|
||||
],
|
||||
rng: new RandUtil(new LiteHashDRBG('s100-duplicate-retry-vector')),
|
||||
count: 2,
|
||||
claim: async () => true,
|
||||
onDraw: (candidate) => draws.push(candidate.uniqueName),
|
||||
});
|
||||
|
||||
expect(draws).toEqual(['first', 'first', 'first', 'first', 'first', 'first', 'first', 'second']);
|
||||
expect(selected.map((candidate) => candidate.uniqueName)).toEqual(['first', 'second']);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { City, General, Nation } from '@sammo-ts/logic';
|
||||
import { loadItemModules, type City, type General, type Nation } from '@sammo-ts/logic';
|
||||
import { createRefOrderedActionStack } from '@sammo-ts/logic/actionModules/bundle.js';
|
||||
|
||||
import { GeneralAI, shouldUseNationAi } from '../src/turn/ai/generalAi.js';
|
||||
@@ -712,9 +712,7 @@ describe('legacy NPC user-chief promotion parity', () => {
|
||||
expect(run(2)).toEqual([]);
|
||||
expect(run(3)).toEqual([{ generalId: 2, officerLevel: 11, officerCity: 0, permission: 'ambassador' }]);
|
||||
expect(run(3, 0)).toEqual([]);
|
||||
expect(run(3, 0, 1)).toEqual([
|
||||
{ generalId: 2, officerLevel: 11, officerCity: 0, permission: 'ambassador' },
|
||||
]);
|
||||
expect(run(3, 0, 1)).toEqual([{ generalId: 2, officerLevel: 11, officerCity: 0, permission: 'ambassador' }]);
|
||||
});
|
||||
|
||||
it('keeps user-ruler duties individually disabled until each setting is enabled', () => {
|
||||
@@ -882,6 +880,27 @@ describe('legacy NPC AI final-decision parity', () => {
|
||||
effectiveLeadership: 85,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes scenario time and maximum tech level to year-scaling stat items', async () => {
|
||||
const [leadershipWine] = await loadItemModules(['che_능력치_통솔_보령압주']);
|
||||
expect(leadershipWine).toBeDefined();
|
||||
const modules = singleActionModuleStack(leadershipWine!);
|
||||
const world = {
|
||||
id: 1,
|
||||
currentYear: 200,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
lastTurnTime: new Date('0200-01-01T00:00:00Z'),
|
||||
meta: {},
|
||||
};
|
||||
|
||||
expect(
|
||||
resolveLegacyAiStatsWithModules(baseGeneral(), baseNation(), 100, modules, null, world, 180, 15)
|
||||
).toMatchObject({
|
||||
fullLeadership: 80,
|
||||
effectiveLeadership: 80,
|
||||
});
|
||||
});
|
||||
it.each([
|
||||
['Core scenario name', '강유'],
|
||||
['Ref stored name', 'ⓝ강유'],
|
||||
|
||||
@@ -62,3 +62,34 @@ describe('SPoolUnderU30 resource', () => {
|
||||
await expect(loadGeneralPoolEntries('SPoolUnknown')).rejects.toThrow('Unsupported general pool');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SPoolUnderU100 resource', () => {
|
||||
it('preserves all 4,682 historical candidates and Ref A100 identifiers', async () => {
|
||||
const entries = await loadGeneralPoolEntries('SPoolUnderU100');
|
||||
|
||||
expect(entries).toHaveLength(4_682);
|
||||
expect(new Set(entries.map((entry) => entry.uniqueName)).size).toBe(4_682);
|
||||
expect(entries[0]).toMatchObject({
|
||||
uniqueName: 'A1000001',
|
||||
info: {
|
||||
uniqueName: 'A1000001',
|
||||
generalName: '1·조민',
|
||||
leadership: 85,
|
||||
strength: 69,
|
||||
intel: 12,
|
||||
specialDomestic: 'che_event_무쌍',
|
||||
dex: [54_691, 398_024, 31_027, 89_301, 24_687],
|
||||
sourcePhase: 1,
|
||||
sourceServerId: 'che_180628_z9X4',
|
||||
sourceGeneralNo: 3,
|
||||
event100Growth: true,
|
||||
},
|
||||
});
|
||||
expect(entries.at(-1)).toMatchObject({
|
||||
uniqueName: 'A1004682',
|
||||
info: { generalName: '99·푸른양귀비', sourcePhase: 99 },
|
||||
});
|
||||
expect(entries.filter((entry) => (entry.info.dex as number[]).every((value) => value === 0))).toHaveLength(512);
|
||||
expect(entries.filter((entry) => entry.info.specialDomestic === null)).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ConstantRNG, RandUtil, SequenceRNG } from '@sammo-ts/common';
|
||||
import { parseScenarioGeneralPoolCandidate, readScenarioGeneralPoolClaim, type TurnSchedule } from '@sammo-ts/logic';
|
||||
|
||||
import type { TurnGeneral, TurnGeneralPoolEntry, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
import { createTurnTestHarness } from './helpers/turnTestHarness.js';
|
||||
|
||||
const start = new Date('0200-01-01T00:00:00.000Z');
|
||||
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
|
||||
const map = {
|
||||
id: 'general-pool-same-turn',
|
||||
name: '장수 pool 동일 턴 테스트',
|
||||
cities: [
|
||||
{
|
||||
id: 1,
|
||||
name: '테스트성',
|
||||
level: 1,
|
||||
region: 1,
|
||||
position: { x: 0, y: 0 },
|
||||
connections: [],
|
||||
max: {
|
||||
population: 50_000,
|
||||
agriculture: 1_000,
|
||||
commerce: 1_000,
|
||||
security: 1_000,
|
||||
defence: 1_000,
|
||||
wall: 1_000,
|
||||
},
|
||||
initial: {
|
||||
population: 10_000,
|
||||
agriculture: 500,
|
||||
commerce: 500,
|
||||
security: 500,
|
||||
defence: 500,
|
||||
wall: 500,
|
||||
},
|
||||
},
|
||||
],
|
||||
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
|
||||
};
|
||||
|
||||
const buildRuler = (): TurnGeneral => ({
|
||||
id: 1,
|
||||
userId: 'user-1',
|
||||
name: '군주',
|
||||
nationId: 1,
|
||||
cityId: 1,
|
||||
troopId: 0,
|
||||
stats: { leadership: 80, strength: 70, intelligence: 60 },
|
||||
experience: 0,
|
||||
dedication: 0,
|
||||
officerLevel: 12,
|
||||
role: {
|
||||
personality: null,
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
items: { horse: null, weapon: null, book: null, item: null },
|
||||
},
|
||||
injury: 0,
|
||||
gold: 2_000,
|
||||
rice: 2_000,
|
||||
crew: 0,
|
||||
crewTypeId: 1,
|
||||
train: 40,
|
||||
atmos: 40,
|
||||
age: 30,
|
||||
npcState: 0,
|
||||
bornYear: 170,
|
||||
deadYear: 260,
|
||||
affinity: 50,
|
||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
meta: { killturn: 24 },
|
||||
turnTime: start,
|
||||
});
|
||||
|
||||
const buildPoolEntry = (id: number): TurnGeneralPoolEntry => {
|
||||
const uniqueName = `후보${id}`;
|
||||
return {
|
||||
id,
|
||||
uniqueName,
|
||||
ownerUserId: null,
|
||||
generalId: null,
|
||||
reservedUntil: null,
|
||||
reservedUntilTick: null,
|
||||
candidate: parseScenarioGeneralPoolCandidate({
|
||||
id,
|
||||
uniqueName,
|
||||
info: {
|
||||
generalName: uniqueName,
|
||||
leadership: 70,
|
||||
strength: 70,
|
||||
intel: 10,
|
||||
dex: [10, 10, 10, 10, 10],
|
||||
imgsvr: 0,
|
||||
picture: 'default.jpg',
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const buildSnapshot = (): TurnWorldSnapshot => ({
|
||||
scenarioConfig: {
|
||||
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 },
|
||||
iconPath: '',
|
||||
map: { targetGeneralPool: 'SPoolUnderU30' },
|
||||
const: {
|
||||
develCost: 100,
|
||||
openingPartYear: 3,
|
||||
defaultMaxGeneral: 500,
|
||||
initialNationGenLimit: 10,
|
||||
defaultNpcGold: 1_000,
|
||||
defaultNpcRice: 1_000,
|
||||
defaultCrewTypeId: 1,
|
||||
retirementYear: 80,
|
||||
availablePersonality: ['che_안전'],
|
||||
},
|
||||
environment: { mapName: map.id, unitSet: 'test' },
|
||||
},
|
||||
scenarioMeta: {
|
||||
title: '장수 pool 동일 턴 테스트',
|
||||
startYear: 190,
|
||||
life: null,
|
||||
fiction: 0,
|
||||
history: [],
|
||||
ignoreDefaultEvents: false,
|
||||
},
|
||||
map,
|
||||
unitSet: { id: 'test', name: 'test', crewTypes: [] },
|
||||
nations: [
|
||||
{
|
||||
id: 1,
|
||||
name: '테스트국',
|
||||
color: '#000000',
|
||||
capitalCityId: 1,
|
||||
chiefGeneralId: 1,
|
||||
gold: 10_000,
|
||||
rice: 10_000,
|
||||
power: 0,
|
||||
level: 1,
|
||||
typeCode: 'che_중립',
|
||||
meta: {
|
||||
gennum: 1,
|
||||
tech: 0,
|
||||
strategic_cmd_limit: 0,
|
||||
turn_last_12: { command: '의병모집', arg: {}, term: 2 },
|
||||
},
|
||||
},
|
||||
],
|
||||
cities: [
|
||||
{
|
||||
id: 1,
|
||||
name: '테스트성',
|
||||
nationId: 1,
|
||||
level: 1,
|
||||
state: 0,
|
||||
population: 10_000,
|
||||
populationMax: 50_000,
|
||||
agriculture: 500,
|
||||
agricultureMax: 1_000,
|
||||
commerce: 500,
|
||||
commerceMax: 1_000,
|
||||
security: 500,
|
||||
securityMax: 1_000,
|
||||
supplyState: 1,
|
||||
frontState: 0,
|
||||
defence: 500,
|
||||
defenceMax: 1_000,
|
||||
wall: 500,
|
||||
wallMax: 1_000,
|
||||
meta: { trust: 50, trade: 100, region: 1 },
|
||||
},
|
||||
],
|
||||
generals: [buildRuler()],
|
||||
troops: [],
|
||||
diplomacy: [],
|
||||
events: [],
|
||||
initialEvents: [],
|
||||
generalPoolEntries: [1, 2, 3, 4].map(buildPoolEntry),
|
||||
});
|
||||
|
||||
const buildState = (): TurnWorldState => ({
|
||||
id: 1,
|
||||
currentYear: 200,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
lastTurnTime: start,
|
||||
meta: { killturn: 24, hiddenSeed: 'general-pool-same-turn' },
|
||||
});
|
||||
|
||||
describe('scenario general pool within one reserved turn', () => {
|
||||
it('does not let talent scouting reuse rows claimed earlier by volunteer recruitment', async () => {
|
||||
const harness = await createTurnTestHarness({
|
||||
snapshot: buildSnapshot(),
|
||||
state: buildState(),
|
||||
schedule,
|
||||
map,
|
||||
reservedTurnStoreOptions: { maxGeneralTurns: 10, maxNationTurns: 12 },
|
||||
commandRngFactory: ({ actionKey }) =>
|
||||
actionKey === 'che_의병모집'
|
||||
? new RandUtil(new SequenceRNG([0, 0.26, 0.51, 0.76]))
|
||||
: new RandUtil(new ConstantRNG(0)),
|
||||
});
|
||||
harness.reservedTurnStore.getNationTurns(1, 12)[0] = { action: 'che_의병모집', args: {} };
|
||||
harness.reservedTurnStore.getGeneralTurns(1)[0] = { action: 'che_인재탐색', args: {} };
|
||||
|
||||
await harness.runOneTick();
|
||||
|
||||
const created = harness.world.peekDirtyState().createdGenerals;
|
||||
const claims = created.map((general) => readScenarioGeneralPoolClaim(general.meta));
|
||||
expect(created.map((general) => general.npcState).sort()).toEqual([3, 4, 4, 4]);
|
||||
expect(claims.every(Boolean)).toBe(true);
|
||||
expect(new Set(claims.map((claim) => claim?.poolEntryId))).toEqual(new Set([1, 2, 3, 4]));
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { LEGACY_RANK_DATA_TYPES } from '@sammo-ts/common';
|
||||
import type { TurnSchedule } from '@sammo-ts/logic';
|
||||
import { finalizeLogEntry, type TurnSchedule } from '@sammo-ts/logic';
|
||||
|
||||
import { rankMetaKey } from '../src/turn/rankData.js';
|
||||
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
@@ -159,7 +159,7 @@ const makeState = (meta: Record<string, unknown> = {}): TurnWorldState => ({
|
||||
});
|
||||
|
||||
describe('legacy general turn lifecycle', () => {
|
||||
it('timestamps action logs with the executing general turn instead of the shared flush cursor', async () => {
|
||||
it('timestamps action logs with the executing turn before the same run advances the month', async () => {
|
||||
const flushCursor = new Date('0200-01-01T00:35:00.000Z');
|
||||
const generalTurnTime = new Date('0200-01-01T00:37:43.000Z');
|
||||
const harness = await createTurnTestHarness({
|
||||
@@ -175,7 +175,28 @@ describe('legacy general turn lifecycle', () => {
|
||||
const actionLog = harness.world
|
||||
.peekDirtyState()
|
||||
.logs.find((log) => log.text.includes('아무것도 실행하지 않았습니다.'));
|
||||
expect(actionLog?.occurredAt).toEqual(generalTurnTime);
|
||||
expect(harness.world.getState()).toMatchObject({ currentYear: 200, currentMonth: 2 });
|
||||
expect(actionLog).toMatchObject({
|
||||
year: 200,
|
||||
month: 1,
|
||||
occurredAt: generalTurnTime,
|
||||
});
|
||||
if (!actionLog) {
|
||||
throw new Error('expected the rest action log');
|
||||
}
|
||||
const finalState = harness.world.getState();
|
||||
expect(
|
||||
finalizeLogEntry(actionLog, {
|
||||
year: finalState.currentYear,
|
||||
month: finalState.currentMonth,
|
||||
at: finalState.lastTurnTime,
|
||||
})
|
||||
).toMatchObject({
|
||||
year: 200,
|
||||
month: 1,
|
||||
text: '<C>●</>1월:아무것도 실행하지 않았습니다.',
|
||||
createdAt: generalTurnTime,
|
||||
});
|
||||
});
|
||||
|
||||
it('emits legacy plain logs when command gains cross experience and dedication levels', async () => {
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { GAME_TICKS_PER_TURN } from '@sammo-ts/common';
|
||||
import { buildScenarioGeneralPoolClaimMeta, parseScenarioGeneralPoolCandidate, type City } from '@sammo-ts/logic';
|
||||
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import type { TurnGeneral, TurnGeneralPoolEntry, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
|
||||
const claimedAt = new Date('0200-05-01T00:00:00.000Z');
|
||||
|
||||
const buildCandidateEntry = (
|
||||
id: number,
|
||||
uniqueName: string,
|
||||
patch: Partial<TurnGeneralPoolEntry> = {}
|
||||
): TurnGeneralPoolEntry => ({
|
||||
id,
|
||||
uniqueName,
|
||||
ownerUserId: null,
|
||||
generalId: null,
|
||||
reservedUntil: null,
|
||||
reservedUntilTick: null,
|
||||
candidate: parseScenarioGeneralPoolCandidate({
|
||||
id,
|
||||
uniqueName,
|
||||
info: {
|
||||
generalName: uniqueName,
|
||||
leadership: 70,
|
||||
strength: 80,
|
||||
intel: 10,
|
||||
dex: [10, 20, 30, 40, 50],
|
||||
imgsvr: 0,
|
||||
picture: 'default.jpg',
|
||||
},
|
||||
}),
|
||||
...patch,
|
||||
});
|
||||
|
||||
const buildGeneral = (id: number, name: string, meta: TurnGeneral['meta']): TurnGeneral => ({
|
||||
id,
|
||||
userId: null,
|
||||
name,
|
||||
nationId: 0,
|
||||
cityId: 1,
|
||||
troopId: 0,
|
||||
stats: { leadership: 70, strength: 80, intelligence: 10 },
|
||||
experience: 2_000,
|
||||
dedication: 2_000,
|
||||
officerLevel: 0,
|
||||
role: {
|
||||
personality: null,
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
items: { horse: null, weapon: null, book: null, item: null },
|
||||
},
|
||||
injury: 0,
|
||||
gold: 1_000,
|
||||
rice: 1_000,
|
||||
crew: 0,
|
||||
crewTypeId: 0,
|
||||
train: 0,
|
||||
atmos: 0,
|
||||
age: 20,
|
||||
npcState: 0,
|
||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
turnTime: claimedAt,
|
||||
meta,
|
||||
});
|
||||
|
||||
const city: City = {
|
||||
id: 1,
|
||||
name: '도시',
|
||||
nationId: 0,
|
||||
level: 4,
|
||||
state: 0,
|
||||
population: 10_000,
|
||||
populationMax: 20_000,
|
||||
agriculture: 1_000,
|
||||
agricultureMax: 2_000,
|
||||
commerce: 1_000,
|
||||
commerceMax: 2_000,
|
||||
security: 1_000,
|
||||
securityMax: 2_000,
|
||||
supplyState: 1,
|
||||
frontState: 0,
|
||||
defence: 1_000,
|
||||
defenceMax: 2_000,
|
||||
wall: 1_000,
|
||||
wallMax: 2_000,
|
||||
meta: {},
|
||||
};
|
||||
|
||||
const buildWorld = (
|
||||
generalPoolEntries: TurnGeneralPoolEntry[],
|
||||
generals: TurnGeneral[] = [],
|
||||
stateOverride: Partial<TurnWorldState> = {}
|
||||
): InMemoryTurnWorld => {
|
||||
const state: TurnWorldState = {
|
||||
id: 1,
|
||||
currentYear: 200,
|
||||
currentMonth: 5,
|
||||
tickSeconds: 600,
|
||||
lastTurnTime: claimedAt,
|
||||
meta: {},
|
||||
...stateOverride,
|
||||
};
|
||||
const snapshot: TurnWorldSnapshot = {
|
||||
scenarioConfig: {
|
||||
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 },
|
||||
iconPath: '',
|
||||
map: { targetGeneralPool: 'SPoolUnderU30' },
|
||||
const: {},
|
||||
environment: { mapName: 'test', unitSet: 'default' },
|
||||
},
|
||||
map: {
|
||||
id: 'test',
|
||||
name: 'test',
|
||||
cities: [],
|
||||
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
|
||||
},
|
||||
generals,
|
||||
cities: [city],
|
||||
nations: [],
|
||||
troops: [],
|
||||
diplomacy: [],
|
||||
events: [],
|
||||
initialEvents: [],
|
||||
generalPoolEntries,
|
||||
};
|
||||
return new InMemoryTurnWorld(state, snapshot, {
|
||||
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
|
||||
});
|
||||
};
|
||||
|
||||
describe('in-memory scenario general pool availability', () => {
|
||||
it('uses synchronized reselection rows while excluding live, orphaned, and active reservations', () => {
|
||||
const oldEntry = buildCandidateEntry(1, '이전후보');
|
||||
const currentEntry = buildCandidateEntry(2, '현재후보');
|
||||
const legacyOccupiedEntry = buildCandidateEntry(3, '기존점유', { generalId: 2 });
|
||||
const expiredEntry = buildCandidateEntry(4, '만료예약', {
|
||||
ownerUserId: 'expired-user',
|
||||
reservedUntil: new Date(claimedAt.getTime() + 60_000),
|
||||
reservedUntilTick: -1,
|
||||
});
|
||||
const activeEntry = buildCandidateEntry(5, '활성예약', {
|
||||
ownerUserId: 'active-user',
|
||||
reservedUntil: new Date(claimedAt.getTime() - 60_000),
|
||||
reservedUntilTick: 1,
|
||||
});
|
||||
const orphanedEntry = buildCandidateEntry(6, '고아점유', { generalId: 999 });
|
||||
const exactDeadlineEntry = buildCandidateEntry(7, '동률예약', {
|
||||
ownerUserId: 'exact-user',
|
||||
reservedUntil: new Date(claimedAt.getTime() - 60_000),
|
||||
reservedUntilTick: 0,
|
||||
});
|
||||
const currentClaim = buildScenarioGeneralPoolClaimMeta(currentEntry.candidate, claimedAt);
|
||||
const world = buildWorld(
|
||||
[oldEntry, currentEntry, legacyOccupiedEntry, expiredEntry, activeEntry, orphanedEntry, exactDeadlineEntry],
|
||||
[
|
||||
buildGeneral(1, '현재후보', { killturn: 100, ...currentClaim }),
|
||||
buildGeneral(2, '기존점유', { killturn: 100 }),
|
||||
]
|
||||
);
|
||||
|
||||
expect(world.listGeneralPoolCandidates(claimedAt)?.map((candidate) => candidate.uniqueName)).toEqual([
|
||||
'이전후보',
|
||||
'만료예약',
|
||||
]);
|
||||
});
|
||||
|
||||
it('reuses a linked row only when its general was deleted in the same in-memory batch', () => {
|
||||
const linked = buildCandidateEntry(1, '삭제후보', { generalId: 1 });
|
||||
const world = buildWorld([linked], [buildGeneral(1, '삭제후보', { killturn: 100 })]);
|
||||
|
||||
expect(world.listGeneralPoolCandidates(claimedAt)).toEqual([]);
|
||||
expect(world.removeGeneral(1)).toBe(true);
|
||||
expect(world.listGeneralPoolCandidates(claimedAt)?.map((candidate) => candidate.uniqueName)).toEqual([
|
||||
'삭제후보',
|
||||
]);
|
||||
});
|
||||
|
||||
it('rebases reserved rows with the schedule and restores them on rollback', () => {
|
||||
const reservedUntil = new Date(claimedAt.getTime() + 5 * 60_000);
|
||||
const reserved = buildCandidateEntry(1, '예약후보', {
|
||||
ownerUserId: 'active-user',
|
||||
reservedUntil,
|
||||
reservedUntilTick: GAME_TICKS_PER_TURN / 2,
|
||||
});
|
||||
const unreserved = buildCandidateEntry(2, '미예약후보');
|
||||
const world = buildWorld([reserved, unreserved]);
|
||||
const before = world.captureState();
|
||||
const probeAfterOriginalExpiry = new Date(claimedAt.getTime() + 10 * 60_000);
|
||||
|
||||
world.shiftSchedule(15, claimedAt);
|
||||
|
||||
expect(world.captureState().generalPoolEntries).toMatchObject([
|
||||
{
|
||||
id: 1,
|
||||
reservedUntil: new Date(reservedUntil.getTime() + 15 * 60_000),
|
||||
reservedUntilTick: GAME_TICKS_PER_TURN / 2,
|
||||
},
|
||||
{ id: 2, reservedUntil: null, reservedUntilTick: null },
|
||||
]);
|
||||
expect(
|
||||
world.listGeneralPoolCandidates(probeAfterOriginalExpiry)?.map((candidate) => candidate.uniqueName)
|
||||
).toEqual(['미예약후보']);
|
||||
|
||||
world.restoreState(before);
|
||||
|
||||
expect(world.captureState().generalPoolEntries).toMatchObject([
|
||||
{ id: 1, reservedUntil, reservedUntilTick: GAME_TICKS_PER_TURN / 2 },
|
||||
{ id: 2, reservedUntil: null, reservedUntilTick: null },
|
||||
]);
|
||||
expect(
|
||||
world.listGeneralPoolCandidates(probeAfterOriginalExpiry)?.map((candidate) => candidate.uniqueName)
|
||||
).toEqual(['예약후보', '미예약후보']);
|
||||
});
|
||||
|
||||
it('rebases tick-owned reservations with a long realtime backlog and restores exact expiry semantics', () => {
|
||||
const originalReservedUntilTick = 2 * GAME_TICKS_PER_TURN;
|
||||
const originalReservedUntil = new Date(claimedAt.getTime() + 20 * 60_000);
|
||||
const reserved = buildCandidateEntry(1, '예약후보', {
|
||||
ownerUserId: 'active-user',
|
||||
reservedUntil: originalReservedUntil,
|
||||
reservedUntilTick: originalReservedUntilTick,
|
||||
});
|
||||
const world = buildWorld([reserved], [], {
|
||||
clockBaseTime: claimedAt,
|
||||
clockTick: 0,
|
||||
clockMode: 'realtime',
|
||||
clockWallAnchor: claimedAt,
|
||||
lastTurnTick: 0,
|
||||
});
|
||||
const before = world.captureState();
|
||||
const resumedAt = new Date(claimedAt.getTime() + 40 * 60_000);
|
||||
|
||||
expect(world.rebaseRealtimeBacklog(resumedAt)).toMatchObject({
|
||||
skippedTurns: 4,
|
||||
shiftedTicks: 4 * GAME_TICKS_PER_TURN,
|
||||
});
|
||||
const rebasedReservedUntilTick = 6 * GAME_TICKS_PER_TURN;
|
||||
const rebasedReservedUntil = world.gameTickToDate(rebasedReservedUntilTick);
|
||||
expect(world.captureState().generalPoolEntries).toMatchObject([
|
||||
{
|
||||
id: 1,
|
||||
reservedUntilTick: rebasedReservedUntilTick,
|
||||
reservedUntil: rebasedReservedUntil,
|
||||
},
|
||||
]);
|
||||
expect(world.listGeneralPoolCandidates(resumedAt)).toEqual([]);
|
||||
expect(world.listGeneralPoolCandidates(rebasedReservedUntil)).toEqual([]);
|
||||
expect(
|
||||
world
|
||||
.listGeneralPoolCandidates(new Date(rebasedReservedUntil.getTime() + 1))
|
||||
?.map((candidate) => candidate.uniqueName)
|
||||
).toEqual(['예약후보']);
|
||||
|
||||
world.restoreState(before);
|
||||
|
||||
expect(world.captureState().generalPoolEntries).toMatchObject([
|
||||
{
|
||||
id: 1,
|
||||
reservedUntilTick: originalReservedUntilTick,
|
||||
reservedUntil: originalReservedUntil,
|
||||
},
|
||||
]);
|
||||
expect(world.listGeneralPoolCandidates(originalReservedUntil)).toEqual([]);
|
||||
expect(
|
||||
world
|
||||
.listGeneralPoolCandidates(new Date(originalReservedUntil.getTime() + 1))
|
||||
?.map((candidate) => candidate.uniqueName)
|
||||
).toEqual(['예약후보']);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,12 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { readdir } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
MONTHLY_EVENT_ACTION_CATALOG,
|
||||
type MonthlyEventActionName,
|
||||
} from '../src/turn/monthlyEventHandler.js';
|
||||
import { resolveScenarioDefaultsPath } from '../src/scenario/scenarioLoader.js';
|
||||
import { MONTHLY_EVENT_ACTION_CATALOG, type MonthlyEventActionName } from '../src/turn/monthlyEventHandler.js';
|
||||
import { hasRefSourceRoot, resolveRefSourceRoot } from './refSourceRoot.js';
|
||||
|
||||
interface CatalogSegment {
|
||||
name: string;
|
||||
@@ -34,13 +35,7 @@ const segments = [
|
||||
{
|
||||
name: 'city-economy-boundaries',
|
||||
kind: 'single-boundary',
|
||||
actions: [
|
||||
'RaiseDisaster',
|
||||
'UpdateCitySupply',
|
||||
'UpdateNationLevel',
|
||||
'ProcessSemiAnnual',
|
||||
'ProcessWarIncome',
|
||||
],
|
||||
actions: ['RaiseDisaster', 'UpdateCitySupply', 'UpdateNationLevel', 'ProcessSemiAnnual', 'ProcessWarIncome'],
|
||||
coreEvidence: [
|
||||
'monthlyDisasterPersistence.integration.test.ts',
|
||||
'monthlyCitySupplyPersistence.integration.test.ts',
|
||||
@@ -84,11 +79,7 @@ const segments = [
|
||||
kind: 'multi-month',
|
||||
actions: ['RaiseInvader', 'AutoDeleteInvader', 'InvaderEnding'],
|
||||
coreEvidence: ['monthlyInvaderPersistence.integration.test.ts'],
|
||||
refEvidence: [
|
||||
'monthly_raise_invader.json',
|
||||
'monthly_auto_delete_invader.json',
|
||||
'monthly_invader_ending.json',
|
||||
],
|
||||
refEvidence: ['monthly_raise_invader.json', 'monthly_auto_delete_invader.json', 'monthly_invader_ending.json'],
|
||||
},
|
||||
{
|
||||
name: 'npc-troop-support',
|
||||
@@ -136,17 +127,62 @@ const segments = [
|
||||
coreEvidence: ['monthlyUniqueInheritPersistence.integration.test.ts'],
|
||||
refEvidence: ['monthly_lost_unique_item.json', 'monthly_merge_inherit_point_rank.json'],
|
||||
},
|
||||
{
|
||||
name: 'centennial-all-star-growth',
|
||||
kind: 'multi-month',
|
||||
actions: ['AdvanceCentennialAllStar'],
|
||||
coreEvidence: ['monthlyCentennialAllStarAction.test.ts'],
|
||||
refEvidence: ['CentennialAllStarGrowthTest.php', 'AdvanceCentennialAllStar.php'],
|
||||
},
|
||||
] as const satisfies readonly CatalogSegment[];
|
||||
|
||||
const KNOWN_MISSING_SCENARIO_RESOURCES = [] as const;
|
||||
const KNOWN_MISSING_MONTHLY_ACTIONS = [] as const;
|
||||
|
||||
const listBasenames = async (directory: string, pattern: RegExp): Promise<string[]> =>
|
||||
(await readdir(directory, { withFileTypes: true }))
|
||||
.filter((entry) => entry.isFile() && pattern.test(entry.name))
|
||||
.map((entry) => entry.name)
|
||||
.sort();
|
||||
|
||||
const difference = (left: readonly string[], right: readonly string[]): string[] => {
|
||||
const rightSet = new Set(right);
|
||||
return left.filter((value) => !rightSet.has(value)).sort();
|
||||
};
|
||||
|
||||
const refSourceIt = hasRefSourceRoot() ? it : it.skip;
|
||||
|
||||
describe('monthly event catalog coverage', () => {
|
||||
it('assigns every legacy action to exactly one dependency-safe segment', () => {
|
||||
it('assigns every Core monthly action to exactly one dependency-safe segment', () => {
|
||||
const covered = segments.flatMap((segment) => segment.actions);
|
||||
|
||||
expect(covered).toHaveLength(29);
|
||||
expect(new Set(covered).size).toBe(29);
|
||||
expect(new Set(covered).size).toBe(covered.length);
|
||||
expect(new Set(MONTHLY_EVENT_ACTION_CATALOG).size).toBe(MONTHLY_EVENT_ACTION_CATALOG.length);
|
||||
expect([...covered].sort()).toEqual([...MONTHLY_EVENT_ACTION_CATALOG].sort());
|
||||
});
|
||||
|
||||
refSourceIt('keeps the Core and Ref scenario resource catalogs complete', async () => {
|
||||
const refScenarioDirectory = path.join(resolveRefSourceRoot(), 'hwe', 'scenario');
|
||||
const coreScenarioDirectory = path.dirname(resolveScenarioDefaultsPath());
|
||||
const [refScenarios, coreScenarios] = await Promise.all([
|
||||
listBasenames(refScenarioDirectory, /^scenario_\d+\.json$/),
|
||||
listBasenames(coreScenarioDirectory, /^scenario_\d+\.json$/),
|
||||
]);
|
||||
|
||||
expect(difference(refScenarios, coreScenarios)).toEqual([...KNOWN_MISSING_SCENARIO_RESOURCES]);
|
||||
expect(difference(coreScenarios, refScenarios)).toEqual([]);
|
||||
});
|
||||
|
||||
refSourceIt('keeps the Core and Ref monthly action catalogs complete', async () => {
|
||||
const refActionDirectory = path.join(resolveRefSourceRoot(), 'hwe', 'sammo', 'Event', 'Action');
|
||||
const refActions = (await listBasenames(refActionDirectory, /\.php$/)).map((fileName) =>
|
||||
fileName.replace(/\.php$/, '')
|
||||
);
|
||||
|
||||
expect(difference(refActions, MONTHLY_EVENT_ACTION_CATALOG)).toEqual([...KNOWN_MISSING_MONTHLY_ACTIONS]);
|
||||
expect(difference(MONTHLY_EVENT_ACTION_CATALOG, refActions)).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps every core evidence file executable in this suite', () => {
|
||||
const testDirectory = fileURLToPath(new URL('.', import.meta.url));
|
||||
const evidenceFiles = new Set(segments.flatMap((segment) => segment.coreEvidence));
|
||||
@@ -170,6 +206,7 @@ describe('monthly event catalog coverage', () => {
|
||||
'InvaderEnding',
|
||||
'OpenNationBetting',
|
||||
'FinishNationBetting',
|
||||
'AdvanceCentennialAllStar',
|
||||
]);
|
||||
expect(specialDispositions).toEqual(['CreateAdminNPC', 'UnblockScoutAction']);
|
||||
expect(segments.every((segment) => segment.refEvidence.length > 0)).toBe(true);
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
CENTENNIAL_ALL_STAR_AUX_KEY,
|
||||
LogCategory,
|
||||
LogFormat,
|
||||
LogScope,
|
||||
calculateCentennialUserInitialStats,
|
||||
initialCentennialAllStarAux,
|
||||
type CentennialAllStarRules,
|
||||
type CentennialAllStarTarget,
|
||||
} from '@sammo-ts/logic';
|
||||
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import { createAdvanceCentennialAllStarHandler } from '../src/turn/monthlyCentennialAllStarAction.js';
|
||||
import type { TurnEvent, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
|
||||
const event: TurnEvent = {
|
||||
id: 1,
|
||||
targetCode: 'month',
|
||||
priority: 8_000,
|
||||
condition: true,
|
||||
action: ['AdvanceCentennialAllStar'],
|
||||
meta: {},
|
||||
};
|
||||
|
||||
const rules: CentennialAllStarRules = {
|
||||
defaultStatMin: 15,
|
||||
defaultStatMax: 80,
|
||||
defaultStatTotal: 165,
|
||||
maxStatLevel: 255,
|
||||
defaultSpecialDomestic: 'None',
|
||||
dexLimit: 1_000_000,
|
||||
};
|
||||
|
||||
const target: CentennialAllStarTarget = {
|
||||
uniqueName: 'A1000001',
|
||||
generalName: '1·조민',
|
||||
leadership: 100,
|
||||
strength: 80,
|
||||
intel: 10,
|
||||
dex: [900_000, 800_000, 700_000, 600_000, 500_000],
|
||||
specialDomestic: 'che_event_무쌍',
|
||||
};
|
||||
|
||||
const withTargetMeta = (
|
||||
userInitialStats: ReturnType<typeof calculateCentennialUserInitialStats> | null = null
|
||||
): TurnGeneral['meta'] => {
|
||||
const meta: TurnGeneral['meta'] = {
|
||||
killturn: 5,
|
||||
dex1: 0,
|
||||
dex2: 0,
|
||||
dex3: 0,
|
||||
dex4: 0,
|
||||
dex5: 0,
|
||||
};
|
||||
const mutable: Record<string, unknown> = meta;
|
||||
mutable[CENTENNIAL_ALL_STAR_AUX_KEY] = initialCentennialAllStarAux(target, rules, userInitialStats);
|
||||
return meta;
|
||||
};
|
||||
|
||||
const buildGeneral = (options: {
|
||||
id: number;
|
||||
npcState: number;
|
||||
stats: TurnGeneral['stats'];
|
||||
meta?: TurnGeneral['meta'];
|
||||
}): TurnGeneral => ({
|
||||
id: options.id,
|
||||
userId: options.npcState === 0 ? `user-${options.id}` : null,
|
||||
name: `장수${options.id}`,
|
||||
nationId: 0,
|
||||
cityId: 1,
|
||||
troopId: 0,
|
||||
stats: options.stats,
|
||||
experience: 0,
|
||||
dedication: 0,
|
||||
officerLevel: 0,
|
||||
role: {
|
||||
personality: 'che_안전',
|
||||
specialDomestic: 'None',
|
||||
specialWar: 'None',
|
||||
items: { horse: null, weapon: null, book: null, item: null },
|
||||
},
|
||||
injury: 0,
|
||||
gold: 1_000,
|
||||
rice: 1_000,
|
||||
crew: 0,
|
||||
crewTypeId: 1100,
|
||||
train: 0,
|
||||
atmos: 0,
|
||||
age: 20,
|
||||
startAge: 20,
|
||||
npcState: options.npcState,
|
||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
meta: options.meta ?? { killturn: 5 },
|
||||
lastTurn: { command: '휴식' },
|
||||
turnTime: new Date('0186-01-01T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
const buildWorld = (generals: TurnGeneral[]): InMemoryTurnWorld => {
|
||||
const state: TurnWorldState = {
|
||||
id: 1,
|
||||
currentYear: 186,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
lastTurnTime: new Date('0186-01-01T00:00:00.000Z'),
|
||||
meta: { hiddenSeed: 'monthly-centennial-fixture' },
|
||||
};
|
||||
const scenarioConfig: TurnWorldSnapshot['scenarioConfig'] = {
|
||||
stat: { total: 165, min: 15, max: 80, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 },
|
||||
iconPath: '.',
|
||||
map: { targetGeneralPool: 'SPoolUnderU100', centennialNpcDexTargetRatio: 0.4 },
|
||||
const: {
|
||||
maxLevel: 255,
|
||||
defaultSpecialDomestic: 'None',
|
||||
dexLimit: 1_000_000,
|
||||
},
|
||||
environment: { mapName: 'test', unitSet: 'default' },
|
||||
};
|
||||
return new InMemoryTurnWorld(
|
||||
state,
|
||||
{
|
||||
scenarioConfig,
|
||||
map: { id: 'test', name: 'test', cities: [] },
|
||||
generals,
|
||||
cities: [],
|
||||
nations: [],
|
||||
troops: [],
|
||||
diplomacy: [],
|
||||
events: [event],
|
||||
initialEvents: [],
|
||||
},
|
||||
{ schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] } }
|
||||
);
|
||||
};
|
||||
|
||||
describe('AdvanceCentennialAllStar monthly action', () => {
|
||||
it('advances a user target, unlocks its trait, persists aux, and emits one milestone pair', async () => {
|
||||
const initial = calculateCentennialUserInitialStats(target, rules);
|
||||
const world = buildWorld([
|
||||
buildGeneral({
|
||||
id: 1,
|
||||
npcState: 0,
|
||||
stats: {
|
||||
leadership: initial.leadership,
|
||||
strength: initial.strength,
|
||||
intelligence: initial.intel,
|
||||
},
|
||||
meta: withTargetMeta(initial),
|
||||
}),
|
||||
]);
|
||||
const environment = {
|
||||
year: 186,
|
||||
month: 1,
|
||||
startyear: 180,
|
||||
currentEventID: 1,
|
||||
turnTime: new Date('0186-01-01T00:00:00.000Z'),
|
||||
};
|
||||
|
||||
await createAdvanceCentennialAllStarHandler({ getWorld: () => world })([], environment, event);
|
||||
|
||||
const updated = world.getGeneralById(1)!;
|
||||
expect(updated.role.specialDomestic).toBe('che_event_무쌍');
|
||||
expect([updated.meta.dex1, updated.meta.dex2, updated.meta.dex3, updated.meta.dex4, updated.meta.dex5]).toEqual(
|
||||
[144_000, 128_000, 112_000, 96_000, 80_000]
|
||||
);
|
||||
expect(world.peekDirtyState().generals).toHaveLength(1);
|
||||
expect(world.peekDirtyState().logs).toEqual([
|
||||
expect.objectContaining({
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.ACTION,
|
||||
generalId: 1,
|
||||
text: '<L>올스타 동조율</>이 <C>40%</>에 도달했습니다!',
|
||||
format: LogFormat.PLAIN,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.HISTORY,
|
||||
generalId: 1,
|
||||
text: '<L>올스타 동조율 40% 달성</>',
|
||||
format: LogFormat.YEAR_MONTH,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the Ref .9 stat progress and .4 dex target only for generated NPCs', async () => {
|
||||
const world = buildWorld([
|
||||
buildGeneral({
|
||||
id: 2,
|
||||
npcState: 3,
|
||||
stats: { leadership: 15, strength: 15, intelligence: 10 },
|
||||
meta: withTargetMeta(),
|
||||
}),
|
||||
buildGeneral({
|
||||
id: 3,
|
||||
npcState: 2,
|
||||
stats: { leadership: 50, strength: 50, intelligence: 50 },
|
||||
}),
|
||||
buildGeneral({
|
||||
id: 4,
|
||||
npcState: 6,
|
||||
stats: { leadership: 15, strength: 15, intelligence: 10 },
|
||||
meta: withTargetMeta(),
|
||||
}),
|
||||
]);
|
||||
const environment = {
|
||||
year: 195,
|
||||
month: 1,
|
||||
startyear: 180,
|
||||
currentEventID: 1,
|
||||
turnTime: new Date('0195-01-01T00:00:00.000Z'),
|
||||
};
|
||||
|
||||
await createAdvanceCentennialAllStarHandler({ getWorld: () => world })([], environment, event);
|
||||
|
||||
const npc = world.getGeneralById(2)!;
|
||||
expect(npc.stats).toEqual({ leadership: 91, strength: 73, intelligence: 10 });
|
||||
expect([npc.meta.dex1, npc.meta.dex2, npc.meta.dex3, npc.meta.dex4, npc.meta.dex5]).toEqual([
|
||||
360_000, 320_000, 280_000, 240_000, 200_000,
|
||||
]);
|
||||
expect(world.getGeneralById(3)?.stats).toEqual({ leadership: 50, strength: 50, intelligence: 50 });
|
||||
const nationNpc = world.getGeneralById(4)!;
|
||||
expect(nationNpc.stats).toEqual({ leadership: 100, strength: 80, intelligence: 10 });
|
||||
expect([
|
||||
nationNpc.meta.dex1,
|
||||
nationNpc.meta.dex2,
|
||||
nationNpc.meta.dex3,
|
||||
nationNpc.meta.dex4,
|
||||
nationNpc.meta.dex5,
|
||||
]).toEqual([900_000, 800_000, 700_000, 600_000, 500_000]);
|
||||
expect(world.peekDirtyState().generals.map((entry) => entry.id)).toEqual([2, 4]);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,16 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { LEGACY_RANDOM_GENERAL_FIRST_NAMES, LEGACY_RANDOM_GENERAL_LAST_NAMES, type City } from '@sammo-ts/logic';
|
||||
import {
|
||||
LEGACY_RANDOM_GENERAL_FIRST_NAMES,
|
||||
LEGACY_RANDOM_GENERAL_LAST_NAMES,
|
||||
parseScenarioGeneralPoolCandidate,
|
||||
type City,
|
||||
} from '@sammo-ts/logic';
|
||||
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import { createCreateManyNpcHandler } from '../src/turn/monthlyCreateManyNpcAction.js';
|
||||
import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.js';
|
||||
import { buildCommandEnv } from '../src/turn/reservedTurnCommands.js';
|
||||
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
import type { TurnGeneral, TurnGeneralPoolEntry, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
|
||||
const buildCity = (id: number): City => ({
|
||||
id,
|
||||
@@ -64,7 +69,12 @@ const buildGeneral = (id: number, patch: Partial<TurnGeneral> = {}): TurnGeneral
|
||||
...patch,
|
||||
});
|
||||
|
||||
const buildHarness = (generals: TurnGeneral[] = [], cityCount = 2) => {
|
||||
const buildHarness = (
|
||||
generals: TurnGeneral[] = [],
|
||||
cityCount = 2,
|
||||
generalPoolEntries?: TurnGeneralPoolEntry[],
|
||||
poolName = 'SPoolUnderU30'
|
||||
) => {
|
||||
const state: TurnWorldState = {
|
||||
id: 1,
|
||||
currentYear: 200,
|
||||
@@ -75,9 +85,17 @@ const buildHarness = (generals: TurnGeneral[] = [], cityCount = 2) => {
|
||||
};
|
||||
const snapshot: TurnWorldSnapshot = {
|
||||
scenarioConfig: {
|
||||
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
|
||||
stat:
|
||||
poolName === 'SPoolUnderU100'
|
||||
? { total: 165, min: 15, max: 80, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }
|
||||
: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
|
||||
iconPath: '',
|
||||
map: {},
|
||||
map: generalPoolEntries
|
||||
? {
|
||||
targetGeneralPool: poolName,
|
||||
...(poolName === 'SPoolUnderU100' ? { centennialNpcDexTargetRatio: 0.4 } : {}),
|
||||
}
|
||||
: {},
|
||||
const: {
|
||||
defaultStatNPCTotal: 150,
|
||||
defaultStatNPCMin: 10,
|
||||
@@ -103,6 +121,7 @@ const buildHarness = (generals: TurnGeneral[] = [], cityCount = 2) => {
|
||||
diplomacy: [],
|
||||
events: [],
|
||||
initialEvents: [],
|
||||
...(generalPoolEntries ? { generalPoolEntries } : {}),
|
||||
};
|
||||
const world = new InMemoryTurnWorld(state, snapshot, {
|
||||
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
|
||||
@@ -132,6 +151,119 @@ const buildHarness = (generals: TurnGeneral[] = [], cityCount = 2) => {
|
||||
};
|
||||
|
||||
describe('CreateManyNPC monthly action', () => {
|
||||
it('uses and consumes an available U30 candidate without random-name or random-stat fallback', async () => {
|
||||
const info = {
|
||||
generalName: '풀장수',
|
||||
leadership: 69,
|
||||
strength: 12,
|
||||
intel: 80,
|
||||
specialDomestic: 'che_event_징병',
|
||||
dex: [10, 20, 30, 40, 50],
|
||||
imgsvr: 1,
|
||||
picture: 'pool.gif',
|
||||
};
|
||||
const entry: TurnGeneralPoolEntry = {
|
||||
id: 31,
|
||||
uniqueName: info.generalName,
|
||||
ownerUserId: null,
|
||||
generalId: null,
|
||||
reservedUntil: null,
|
||||
reservedUntilTick: null,
|
||||
candidate: parseScenarioGeneralPoolCandidate({ id: 31, uniqueName: info.generalName, info }),
|
||||
};
|
||||
const { world, handler, environment } = buildHarness([buildGeneral(1)], 2, [entry]);
|
||||
|
||||
await handler([1, 0], environment, {
|
||||
id: 1,
|
||||
targetCode: 'month',
|
||||
priority: 1,
|
||||
condition: true,
|
||||
action: [],
|
||||
meta: {},
|
||||
});
|
||||
|
||||
const created = world.peekDirtyState().createdGenerals[0]!;
|
||||
expect(created).toMatchObject({
|
||||
name: 'ⓜ풀장수',
|
||||
stats: { leadership: 69, strength: 12, intelligence: 80 },
|
||||
picture: 'pool.gif',
|
||||
imageServer: 1,
|
||||
role: {
|
||||
specialDomestic: 'che_event_징병',
|
||||
},
|
||||
meta: {
|
||||
dex1: 10,
|
||||
dex2: 20,
|
||||
dex3: 30,
|
||||
dex4: 40,
|
||||
dex5: 50,
|
||||
scenarioGeneralPoolClaim: {
|
||||
poolEntryId: 31,
|
||||
uniqueName: '풀장수',
|
||||
claimedAt: environment.turnTime.toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(world.listGeneralPoolCandidates(environment.turnTime)).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps the ordinary NPC RNG path before applying the S100 .9/.4 target', async () => {
|
||||
const info = {
|
||||
generalName: '100기후보',
|
||||
leadership: 100,
|
||||
strength: 80,
|
||||
intel: 10,
|
||||
specialDomestic: 'che_event_징병',
|
||||
dex: [900_000, 800_000, 700_000, 600_000, 500_000],
|
||||
imgsvr: 1,
|
||||
picture: 'centennial.gif',
|
||||
event100Growth: true,
|
||||
};
|
||||
const entry: TurnGeneralPoolEntry = {
|
||||
id: 100,
|
||||
uniqueName: 'A1000100',
|
||||
ownerUserId: null,
|
||||
generalId: null,
|
||||
reservedUntil: null,
|
||||
reservedUntilTick: null,
|
||||
candidate: parseScenarioGeneralPoolCandidate({ id: 100, uniqueName: 'A1000100', info }),
|
||||
};
|
||||
const { world, handler, environment } = buildHarness([buildGeneral(1)], 2, [entry], 'SPoolUnderU100');
|
||||
const currentEnvironment = { ...environment, year: 195, month: 1, startyear: 180 };
|
||||
|
||||
await handler([1, 0], currentEnvironment, {
|
||||
id: 1,
|
||||
targetCode: 'month',
|
||||
priority: 1,
|
||||
condition: true,
|
||||
action: [],
|
||||
meta: {},
|
||||
});
|
||||
|
||||
const created = world.peekDirtyState().createdGenerals[0]!;
|
||||
expect(created).toMatchObject({
|
||||
name: 'ⓜ100기후보',
|
||||
stats: { leadership: 93, strength: 73 },
|
||||
picture: 'centennial.gif',
|
||||
role: { specialDomestic: 'che_event_징병' },
|
||||
meta: {
|
||||
dex1: 360_000,
|
||||
dex2: 320_000,
|
||||
dex3: 280_000,
|
||||
dex4: 240_000,
|
||||
dex5: 200_000,
|
||||
scenarioGeneralPoolClaim: { poolEntryId: 100, uniqueName: 'A1000100' },
|
||||
event100_allstar: {
|
||||
targetId: 'A1000100',
|
||||
milestone: 4,
|
||||
dexTargetRatio: 0.4,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(created.stats.intelligence).toBeGreaterThanOrEqual(10);
|
||||
expect(created.stats).not.toEqual({ leadership: 100, strength: 80, intelligence: 10 });
|
||||
});
|
||||
|
||||
it('creates the legacy random-name NPC state and initializes all 30 reserved turns', async () => {
|
||||
const { world, reservedTurns, handler, environment } = buildHarness([buildGeneral(1)]);
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ const sourceEvent: TurnEvent = {
|
||||
meta: {},
|
||||
};
|
||||
|
||||
const buildWorld = () => {
|
||||
const buildWorld = (options: { promotedNeutral?: boolean; generals?: TurnGeneral[]; nations?: Nation[] } = {}) => {
|
||||
const state: TurnWorldState = {
|
||||
id: 1,
|
||||
currentYear: 200,
|
||||
@@ -111,9 +111,13 @@ const buildWorld = () => {
|
||||
{
|
||||
scenarioConfig,
|
||||
map: { id: 'test', name: 'test', cities: [] },
|
||||
generals: [buildGeneral(1), buildGeneral(2)],
|
||||
generals: options.generals ?? [buildGeneral(1), buildGeneral(2)],
|
||||
cities: [buildCity(1), buildCity(2)],
|
||||
nations: [buildNation(1, 100), buildNation(2, 300)],
|
||||
nations: options.nations ?? [
|
||||
...(options.promotedNeutral ? [{ ...buildNation(0, 0), name: '재야', level: 1 }] : []),
|
||||
buildNation(1, 100),
|
||||
buildNation(2, 300),
|
||||
],
|
||||
troops: [],
|
||||
diplomacy: [],
|
||||
events: [sourceEvent],
|
||||
@@ -146,25 +150,18 @@ describe('nation betting monthly actions', () => {
|
||||
closeYearMonth: 2_424,
|
||||
bonusPoint: 500,
|
||||
});
|
||||
expect(dirty.pendingNationBettingOpens[0]?.candidates.map((candidate) => candidate.aux.nation)).toEqual([
|
||||
2, 1,
|
||||
]);
|
||||
expect(dirty.pendingNationBettingOpens[0]?.candidates.map((candidate) => candidate.aux.nation)).toEqual([2, 1]);
|
||||
expect(dirty.createdEvents).toEqual([
|
||||
expect.objectContaining({
|
||||
targetCode: 'DESTROY_NATION',
|
||||
priority: 1_000,
|
||||
condition: ['RemainNation', '<=', 1],
|
||||
action: [
|
||||
['FinishNationBetting', 5],
|
||||
['DeleteEvent'],
|
||||
],
|
||||
action: [['FinishNationBetting', 5], ['DeleteEvent']],
|
||||
}),
|
||||
]);
|
||||
expect(dirty.logs).toHaveLength(1);
|
||||
expect(dirty.messages).toHaveLength(2);
|
||||
expect(dirty.messages[0]?.text).toBe(
|
||||
'새로운 천통국 내기가 열렸습니다. 천통국 베팅란을 확인해주세요.'
|
||||
);
|
||||
expect(dirty.messages[0]?.text).toBe('새로운 천통국 내기가 열렸습니다. 천통국 베팅란을 확인해주세요.');
|
||||
|
||||
await createFinishNationBettingHandler({ getWorld: () => world })([5], environment, sourceEvent);
|
||||
expect(world.peekDirtyState().pendingNationBettingFinishes).toEqual([
|
||||
@@ -177,4 +174,55 @@ describe('nation betting monthly actions', () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('never treats the synthetic neutral row as a nation-betting winner', async () => {
|
||||
const world = buildWorld({ promotedNeutral: true });
|
||||
const environment = {
|
||||
year: 200,
|
||||
month: 1,
|
||||
startyear: 190,
|
||||
currentEventID: 7,
|
||||
turnTime: new Date('0200-01-01T00:00:00.000Z'),
|
||||
};
|
||||
|
||||
await createFinishNationBettingHandler({ getWorld: () => world })([5], environment, sourceEvent);
|
||||
|
||||
expect(world.peekDirtyState().pendingNationBettingFinishes).toEqual([
|
||||
expect.objectContaining({ winnerNationIds: [1, 2] }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses stored gennum and excludes npc state 5 from the fallback candidate count', async () => {
|
||||
const nationOne = buildNation(1, 100);
|
||||
nationOne.meta = { ...nationOne.meta, gennum: 0 };
|
||||
const nationTwo = buildNation(2, 300);
|
||||
const nationTwoMeta = { ...nationTwo.meta };
|
||||
delete nationTwoMeta.gennum;
|
||||
nationTwo.meta = nationTwoMeta;
|
||||
const npcFiveNationOne = { ...buildGeneral(3), nationId: 1, npcState: 5 };
|
||||
const npcFiveNationTwo = { ...buildGeneral(4), nationId: 2, npcState: 5 };
|
||||
const world = buildWorld({
|
||||
generals: [buildGeneral(1), buildGeneral(2), npcFiveNationOne, npcFiveNationTwo],
|
||||
nations: [nationOne, nationTwo],
|
||||
});
|
||||
const environment = {
|
||||
year: 200,
|
||||
month: 1,
|
||||
startyear: 190,
|
||||
currentEventID: 7,
|
||||
turnTime: new Date('0200-01-01T00:00:00.000Z'),
|
||||
};
|
||||
|
||||
await createOpenNationBettingHandler({ getWorld: () => world })([1, 500], environment, sourceEvent);
|
||||
|
||||
const candidates = world.peekDirtyState().pendingNationBettingOpens[0]?.candidates;
|
||||
expect(candidates?.find((candidate) => candidate.aux.nation === 1)).toMatchObject({
|
||||
info: '국력: 100<br>장수 수: 0<br>도시 수: 1',
|
||||
aux: { gennum: 0 },
|
||||
});
|
||||
expect(candidates?.find((candidate) => candidate.aux.nation === 2)).toMatchObject({
|
||||
info: '국력: 300<br>장수 수: 1<br>도시 수: 1',
|
||||
aux: { gennum: 1 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { PERSONALITY_TRAIT_KEYS, type City, type MapDefinition, type Nation } from '@sammo-ts/logic';
|
||||
import {
|
||||
parseScenarioGeneralPoolCandidate,
|
||||
PERSONALITY_TRAIT_KEYS,
|
||||
type City,
|
||||
type MapDefinition,
|
||||
type Nation,
|
||||
} from '@sammo-ts/logic';
|
||||
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import { createRaiseNpcNationHandler } from '../src/turn/monthlyRaiseNpcNationAction.js';
|
||||
import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.js';
|
||||
import { buildCommandEnv } from '../src/turn/reservedTurnCommands.js';
|
||||
import type { TurnEvent, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
import type {
|
||||
TurnEvent,
|
||||
TurnGeneral,
|
||||
TurnGeneralPoolEntry,
|
||||
TurnWorldSnapshot,
|
||||
TurnWorldState,
|
||||
} from '../src/turn/types.js';
|
||||
|
||||
const buildCity = (id: number, nationId: number, level = 5): City => ({
|
||||
id,
|
||||
@@ -119,7 +131,13 @@ const event: TurnEvent = {
|
||||
meta: {},
|
||||
};
|
||||
|
||||
const buildHarness = (archivedNationMaxId = 0, hiddenSeed = 'raise-npc-nation-fixture') => {
|
||||
const buildHarness = (
|
||||
archivedNationMaxId = 0,
|
||||
hiddenSeed = 'raise-npc-nation-fixture',
|
||||
generalPoolEntries?: TurnGeneralPoolEntry[],
|
||||
additionalGeneralCount = 0,
|
||||
poolName = 'SPoolUnderU30'
|
||||
) => {
|
||||
const state: TurnWorldState = {
|
||||
id: 1,
|
||||
currentYear: 200,
|
||||
@@ -132,7 +150,12 @@ const buildHarness = (archivedNationMaxId = 0, hiddenSeed = 'raise-npc-nation-fi
|
||||
scenarioConfig: {
|
||||
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 },
|
||||
iconPath: '.',
|
||||
map: {},
|
||||
map: generalPoolEntries
|
||||
? {
|
||||
targetGeneralPool: poolName,
|
||||
...(poolName === 'SPoolUnderU100' ? { centennialNpcDexTargetRatio: 0.4 } : {}),
|
||||
}
|
||||
: {},
|
||||
const: {
|
||||
retirementYear: 80,
|
||||
availablePersonality: ['che_안전'],
|
||||
@@ -143,19 +166,22 @@ const buildHarness = (archivedNationMaxId = 0, hiddenSeed = 'raise-npc-nation-fi
|
||||
environment: { mapName: 'test', unitSet: 'default' },
|
||||
},
|
||||
map,
|
||||
generals: [buildGeneral()],
|
||||
cities: [
|
||||
buildCity(1, 1),
|
||||
buildCity(2, 0),
|
||||
buildCity(3, 0, 4),
|
||||
buildCity(4, 0),
|
||||
buildCity(5, 0, 4),
|
||||
generals: [
|
||||
buildGeneral(),
|
||||
...Array.from({ length: additionalGeneralCount }, (_, index) => ({
|
||||
...buildGeneral(),
|
||||
id: index + 2,
|
||||
name: `장수${index + 2}`,
|
||||
officerLevel: 1,
|
||||
})),
|
||||
],
|
||||
cities: [buildCity(1, 1), buildCity(2, 0), buildCity(3, 0, 4), buildCity(4, 0), buildCity(5, 0, 4)],
|
||||
nations: [buildNation(1)],
|
||||
troops: [],
|
||||
diplomacy: [],
|
||||
events: [event],
|
||||
initialEvents: [],
|
||||
...(generalPoolEntries ? { generalPoolEntries } : {}),
|
||||
};
|
||||
const world = new InMemoryTurnWorld(state, snapshot, {
|
||||
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
|
||||
@@ -202,6 +228,94 @@ describe('RaiseNPCNation monthly action', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('uses a U30 subordinate name/dex/special while preserving RaiseNPCNation random stats', async () => {
|
||||
const info = {
|
||||
generalName: '부장후보',
|
||||
leadership: 99,
|
||||
strength: 1,
|
||||
intel: 1,
|
||||
specialDomestic: 'che_event_징병',
|
||||
dex: [10, 20, 30, 40, 50],
|
||||
imgsvr: 1,
|
||||
picture: 'subordinate.gif',
|
||||
};
|
||||
const entry: TurnGeneralPoolEntry = {
|
||||
id: 41,
|
||||
uniqueName: info.generalName,
|
||||
ownerUserId: null,
|
||||
generalId: null,
|
||||
reservedUntil: null,
|
||||
reservedUntilTick: null,
|
||||
candidate: parseScenarioGeneralPoolCandidate({ id: 41, uniqueName: info.generalName, info }),
|
||||
};
|
||||
const { world, handler, environment } = buildHarness(0, 'raise-pool-fixture', [entry], 1);
|
||||
|
||||
await handler([], environment, event);
|
||||
|
||||
const subordinate = world.peekDirtyState().createdGenerals.find((general) => general.name === 'ⓤ부장후보');
|
||||
expect(subordinate).toMatchObject({
|
||||
name: 'ⓤ부장후보',
|
||||
picture: 'subordinate.gif',
|
||||
imageServer: 1,
|
||||
role: { specialDomestic: 'che_event_징병' },
|
||||
meta: {
|
||||
dex1: 10,
|
||||
dex2: 20,
|
||||
dex3: 30,
|
||||
dex4: 40,
|
||||
dex5: 50,
|
||||
scenarioGeneralPoolClaim: {
|
||||
poolEntryId: 41,
|
||||
uniqueName: '부장후보',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(subordinate?.stats).not.toEqual({ leadership: 99, strength: 1, intelligence: 1 });
|
||||
});
|
||||
|
||||
it('attaches the S100 target to a type-6 subordinate without applying current growth', async () => {
|
||||
const info = {
|
||||
generalName: '100기건국',
|
||||
leadership: 100,
|
||||
strength: 80,
|
||||
intel: 10,
|
||||
specialDomestic: 'che_event_징병',
|
||||
dex: [900_000, 800_000, 700_000, 600_000, 500_000],
|
||||
imgsvr: 1,
|
||||
picture: 'centennial-ruler.gif',
|
||||
event100Growth: true,
|
||||
};
|
||||
const entry: TurnGeneralPoolEntry = {
|
||||
id: 101,
|
||||
uniqueName: 'A1000101',
|
||||
ownerUserId: null,
|
||||
generalId: null,
|
||||
reservedUntil: null,
|
||||
reservedUntilTick: null,
|
||||
candidate: parseScenarioGeneralPoolCandidate({ id: 101, uniqueName: 'A1000101', info }),
|
||||
};
|
||||
const { world, handler, environment } = buildHarness(0, 'raise-s100-fixture', [entry], 1, 'SPoolUnderU100');
|
||||
|
||||
await handler([], environment, event);
|
||||
|
||||
const subordinate = world.peekDirtyState().createdGenerals.find((general) => general.name === 'ⓤ100기건국')!;
|
||||
expect(subordinate.stats).not.toEqual({ leadership: 100, strength: 80, intelligence: 10 });
|
||||
expect(subordinate.role.specialDomestic).toBeNull();
|
||||
expect(subordinate.meta).toMatchObject({
|
||||
dex1: 0,
|
||||
dex2: 0,
|
||||
dex3: 0,
|
||||
dex4: 0,
|
||||
dex5: 0,
|
||||
scenarioGeneralPoolClaim: { poolEntryId: 101, uniqueName: 'A1000101' },
|
||||
event100_allstar: {
|
||||
targetId: 'A1000101',
|
||||
progressMonth: -1,
|
||||
milestone: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('creates only distance-qualified NPC nations and initializes their ruler and turns', async () => {
|
||||
const { world, reservedTurns, handler, environment } = buildHarness();
|
||||
|
||||
@@ -265,12 +379,7 @@ describe('RaiseNPCNation monthly action', () => {
|
||||
});
|
||||
expect(world.getCityById(5)?.nationId).toBe(0);
|
||||
expect(reservedTurns.getGeneralTurns(2)).toHaveLength(30);
|
||||
expect(reservedTurns.peekDirtyState().nationInitializationKeys).toEqual([
|
||||
'2:12',
|
||||
'2:11',
|
||||
'2:10',
|
||||
'2:9',
|
||||
]);
|
||||
expect(reservedTurns.peekDirtyState().nationInitializationKeys).toEqual(['2:12', '2:11', '2:10', '2:9']);
|
||||
expect(dirty.logs).toEqual([
|
||||
expect.objectContaining({
|
||||
category: 'HISTORY',
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const CORE_REPOSITORY_ROOT = fileURLToPath(new URL('../../../', import.meta.url));
|
||||
|
||||
const refSourceCandidates = (): string[] => {
|
||||
if (process.env.SAMMO_REF_ROOT) {
|
||||
return [path.resolve(process.env.SAMMO_REF_ROOT)];
|
||||
}
|
||||
|
||||
const candidates = [path.resolve(CORE_REPOSITORY_ROOT, '..', 'ref', 'sam')];
|
||||
try {
|
||||
const commonDirectory = execFileSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], {
|
||||
cwd: CORE_REPOSITORY_ROOT,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
}).trim();
|
||||
candidates.push(path.resolve(path.dirname(commonDirectory), '..', 'ref', 'sam'));
|
||||
} catch {
|
||||
// The ordinary sibling checkout remains the fallback outside a Git worktree.
|
||||
}
|
||||
|
||||
return [...new Set(candidates)];
|
||||
};
|
||||
|
||||
export const hasRefSourceRoot = (): boolean => {
|
||||
if (process.env.SAMMO_REQUIRE_REF_SOURCE === '1' || process.env.SAMMO_REF_ROOT) {
|
||||
return true;
|
||||
}
|
||||
return refSourceCandidates().some((candidate) => existsSync(candidate));
|
||||
};
|
||||
|
||||
export const resolveRefSourceRoot = (): string => {
|
||||
const candidates = refSourceCandidates();
|
||||
const resolvedRoot = candidates.find((candidate) => existsSync(candidate));
|
||||
if (!resolvedRoot) {
|
||||
if (process.env.SAMMO_REF_ROOT) {
|
||||
throw new Error(`SAMMO_REF_ROOT does not exist: ${candidates[0]}`);
|
||||
}
|
||||
throw new Error(`Ref source checkout was not found. Checked: ${candidates.join(', ')}`);
|
||||
}
|
||||
return resolvedRoot;
|
||||
};
|
||||
@@ -21,6 +21,7 @@ const runtimeSettingsRequestId = 'integration:engine:runtime-game-settings';
|
||||
const runtimeSettingsActionId = 'c9f68480-dba9-4e03-a62b-499e6234f18a';
|
||||
const generalIds = [990_301, 990_302, 990_303, 990_304] as const;
|
||||
const runtimeSettingsLogText = 'runtime-settings-existing-log';
|
||||
const backlogPoolUniqueName = 'rebase-pool-990304';
|
||||
|
||||
const buildGeneral = (id: number, turnTime: Date): TurnGeneral =>
|
||||
({
|
||||
@@ -86,6 +87,7 @@ integration('runtime clock shift persistence', () => {
|
||||
await db.message.deleteMany({ where: { mailbox: generalIds[2] } });
|
||||
await db.logEntry.deleteMany({ where: { text: runtimeSettingsLogText } });
|
||||
await db.auction.deleteMany({ where: { hostGeneralId: { in: [...generalIds] } } });
|
||||
await db.selectPoolEntry.deleteMany({ where: { uniqueName: backlogPoolUniqueName } });
|
||||
await db.general.deleteMany({ where: { id: { in: [...generalIds] } } });
|
||||
await db.worldState.deleteMany({
|
||||
where: {
|
||||
@@ -100,6 +102,7 @@ integration('runtime clock shift persistence', () => {
|
||||
await db.message.deleteMany({ where: { mailbox: generalIds[2] } });
|
||||
await db.logEntry.deleteMany({ where: { text: runtimeSettingsLogText } });
|
||||
await db.auction.deleteMany({ where: { hostGeneralId: { in: [...generalIds] } } });
|
||||
await db.selectPoolEntry.deleteMany({ where: { uniqueName: backlogPoolUniqueName } });
|
||||
await db.general.deleteMany({ where: { id: { in: [...generalIds] } } });
|
||||
await db.worldState.deleteMany({
|
||||
where: {
|
||||
@@ -351,6 +354,26 @@ integration('runtime clock shift persistence', () => {
|
||||
})
|
||||
)
|
||||
);
|
||||
const poolEntry = await db.selectPoolEntry.create({
|
||||
data: {
|
||||
uniqueName: backlogPoolUniqueName,
|
||||
ownerUserId: 'rebase-pool-user',
|
||||
generalId: null,
|
||||
reservedUntil: new Date('2099-09-01T00:10:00.000Z'),
|
||||
reservedUntilTick: BigInt(2 * GAME_TICKS_PER_TURN),
|
||||
info: {
|
||||
uniqueName: backlogPoolUniqueName,
|
||||
generalName: '재개예약후보',
|
||||
leadership: 70,
|
||||
strength: 70,
|
||||
intel: 10,
|
||||
specialDomestic: null,
|
||||
dex: [10, 10, 10, 10, 10],
|
||||
imgsvr: 0,
|
||||
picture: 'default.jpg',
|
||||
} as GamePrisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
const world = new InMemoryTurnWorld(
|
||||
{
|
||||
id: row.id,
|
||||
@@ -422,8 +445,15 @@ integration('runtime clock shift persistence', () => {
|
||||
closeTick: BigInt(2 * GAME_TICKS_PER_TURN),
|
||||
closeAt: new Date('2099-09-01T00:10:00.000Z'),
|
||||
});
|
||||
expect(await db.selectPoolEntry.findUniqueOrThrow({ where: { id: poolEntry.id } })).toMatchObject({
|
||||
ownerUserId: 'rebase-pool-user',
|
||||
generalId: null,
|
||||
reservedUntilTick: BigInt(9 * GAME_TICKS_PER_TURN),
|
||||
reservedUntil: new Date('2099-09-01T00:45:00.000Z'),
|
||||
});
|
||||
|
||||
await db.auction.deleteMany({ where: { id: { in: [openAuction.id, finishedAuction.id] } } });
|
||||
await db.selectPoolEntry.delete({ where: { id: poolEntry.id } });
|
||||
await db.general.delete({ where: { id: general.id } });
|
||||
await db.worldState.delete({ where: { id: row.id } });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { parseScenarioGeneralPoolCandidate, readScenarioGeneralPoolClaim, type City } from '@sammo-ts/logic';
|
||||
|
||||
import { loadGeneralPoolEntries } from '../src/scenario/generalPoolLoader.js';
|
||||
import { loadScenarioDefinitionById } from '../src/scenario/scenarioLoader.js';
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import { createCreateManyNpcHandler } from '../src/turn/monthlyCreateManyNpcAction.js';
|
||||
import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.js';
|
||||
import { buildCommandEnv } from '../src/turn/reservedTurnCommands.js';
|
||||
import type { TurnGeneralPoolEntry, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
|
||||
const turnTime = new Date('0180-12-01T00:00:00.000Z');
|
||||
|
||||
const city: City = {
|
||||
id: 1,
|
||||
name: '테스트성',
|
||||
nationId: 0,
|
||||
level: 4,
|
||||
state: 0,
|
||||
population: 10_000,
|
||||
populationMax: 20_000,
|
||||
agriculture: 1_000,
|
||||
agricultureMax: 2_000,
|
||||
commerce: 1_000,
|
||||
commerceMax: 2_000,
|
||||
security: 1_000,
|
||||
securityMax: 2_000,
|
||||
supplyState: 1,
|
||||
frontState: 0,
|
||||
defence: 1_000,
|
||||
defenceMax: 2_000,
|
||||
wall: 1_000,
|
||||
wallMax: 2_000,
|
||||
meta: {},
|
||||
};
|
||||
|
||||
describe('scenario 903 general-pool composition', () => {
|
||||
it('feeds the tracked U30 pool into the first 100-NPC event without losing candidate fields', async () => {
|
||||
const [scenario, seeds] = await Promise.all([
|
||||
loadScenarioDefinitionById(903),
|
||||
loadGeneralPoolEntries('SPoolUnderU30'),
|
||||
]);
|
||||
expect(scenario.config.map.targetGeneralPool).toBe('SPoolUnderU30');
|
||||
const createEvent = scenario.events.find(
|
||||
(event): event is unknown[] =>
|
||||
Array.isArray(event) &&
|
||||
event[0] === 'month' &&
|
||||
event.some((action) => Array.isArray(action) && action[0] === 'CreateManyNPC')
|
||||
);
|
||||
expect(createEvent).toEqual([
|
||||
'month',
|
||||
1_000,
|
||||
['Date', '==', null, 12],
|
||||
['CreateManyNPC', 100, 0],
|
||||
['DeleteEvent'],
|
||||
]);
|
||||
const createAction = createEvent?.find(
|
||||
(action): action is unknown[] => Array.isArray(action) && action[0] === 'CreateManyNPC'
|
||||
);
|
||||
expect(createAction).toBeDefined();
|
||||
|
||||
const generalPoolEntries: TurnGeneralPoolEntry[] = seeds.map((seed, index) => ({
|
||||
id: index + 1,
|
||||
uniqueName: seed.uniqueName,
|
||||
ownerUserId: null,
|
||||
generalId: null,
|
||||
reservedUntil: null,
|
||||
reservedUntilTick: null,
|
||||
candidate: parseScenarioGeneralPoolCandidate({ id: index + 1, ...seed }),
|
||||
}));
|
||||
const map = {
|
||||
id: 'scenario-903-pool-composition',
|
||||
name: 'scenario 903 pool composition',
|
||||
cities: [],
|
||||
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
|
||||
};
|
||||
const state: TurnWorldState = {
|
||||
id: 1,
|
||||
currentYear: 180,
|
||||
currentMonth: 12,
|
||||
tickSeconds: 600,
|
||||
lastTurnTime: turnTime,
|
||||
meta: { hiddenSeed: 'scenario-903-pool-composition' },
|
||||
};
|
||||
const snapshot: TurnWorldSnapshot = {
|
||||
scenarioConfig: scenario.config,
|
||||
scenarioMeta: {
|
||||
title: scenario.title,
|
||||
startYear: scenario.startYear,
|
||||
life: scenario.life,
|
||||
fiction: scenario.fiction,
|
||||
history: scenario.history,
|
||||
ignoreDefaultEvents: scenario.ignoreDefaultEvents,
|
||||
},
|
||||
map,
|
||||
unitSet: { id: 'test', name: 'test', crewTypes: [] },
|
||||
generals: [],
|
||||
cities: [city],
|
||||
nations: [],
|
||||
troops: [],
|
||||
diplomacy: [],
|
||||
events: [],
|
||||
initialEvents: [],
|
||||
generalPoolEntries,
|
||||
};
|
||||
const world = new InMemoryTurnWorld(state, snapshot, {
|
||||
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
|
||||
});
|
||||
const reservedTurns = new InMemoryReservedTurnStore(
|
||||
{
|
||||
generalTurn: { findMany: vi.fn(), deleteMany: vi.fn(), createMany: vi.fn() },
|
||||
nationTurn: { findMany: vi.fn(), deleteMany: vi.fn(), createMany: vi.fn() },
|
||||
} as never,
|
||||
{ maxGeneralTurns: 30, maxNationTurns: 12 }
|
||||
);
|
||||
const handler = createCreateManyNpcHandler({
|
||||
getWorld: () => world,
|
||||
reservedTurns,
|
||||
env: buildCommandEnv(scenario.config),
|
||||
});
|
||||
|
||||
await handler(
|
||||
createAction!.slice(1),
|
||||
{
|
||||
year: 180,
|
||||
month: 12,
|
||||
startyear: 180,
|
||||
currentEventID: 1,
|
||||
turnTime,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
targetCode: 'month',
|
||||
priority: 1_000,
|
||||
condition: true,
|
||||
action: [],
|
||||
meta: {},
|
||||
}
|
||||
);
|
||||
|
||||
const candidatesById = new Map(generalPoolEntries.map((entry) => [entry.id, entry.candidate]));
|
||||
const created = world.peekDirtyState().createdGenerals;
|
||||
expect(created).toHaveLength(100);
|
||||
const claims = created.map((general) => readScenarioGeneralPoolClaim(general.meta));
|
||||
expect(new Set(claims.map((claim) => claim?.poolEntryId)).size).toBe(100);
|
||||
for (const [index, general] of created.entries()) {
|
||||
const claim = claims[index];
|
||||
expect(claim).not.toBeNull();
|
||||
const candidate = candidatesById.get(claim!.poolEntryId)!;
|
||||
expect(general).toMatchObject({
|
||||
name: `ⓜ${candidate.name}`,
|
||||
stats: candidate.stats,
|
||||
picture: candidate.picture,
|
||||
imageServer: candidate.imageServer,
|
||||
role: { specialDomestic: candidate.specialDomestic },
|
||||
meta: {
|
||||
dex1: candidate.dex?.[0],
|
||||
dex2: candidate.dex?.[1],
|
||||
dex3: candidate.dex?.[2],
|
||||
dex4: candidate.dex?.[3],
|
||||
dex5: candidate.dex?.[4],
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -5,9 +5,31 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { loadScenarioDefinitionById, resolveScenarioDefaultsPath } from '../src/scenario/scenarioLoader.js';
|
||||
import { buildCommandEnv } from '../src/turn/reservedTurnCommands.js';
|
||||
import { hasRefSourceRoot, resolveRefSourceRoot } from './refSourceRoot.js';
|
||||
|
||||
type LoadedScenario = Awaited<ReturnType<typeof loadScenarioDefinitionById>>;
|
||||
|
||||
interface ReferenceScenario914 {
|
||||
title: string;
|
||||
startYear: number;
|
||||
map: Record<string, unknown>;
|
||||
history: string[];
|
||||
const: {
|
||||
allItems: Record<string, Record<string, number>>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
events: unknown[];
|
||||
}
|
||||
|
||||
interface ReferenceScenario915 {
|
||||
title: string;
|
||||
startYear: number;
|
||||
map: Record<string, unknown>;
|
||||
history: string[];
|
||||
const: Record<string, unknown>;
|
||||
events: unknown[];
|
||||
}
|
||||
|
||||
const readItemSlot = (scenario: LoadedScenario, slot: string): Record<string, number> => {
|
||||
const allItems = scenario.config.const.allItems as Record<string, Record<string, number>> | undefined;
|
||||
return allItems?.[slot] ?? {};
|
||||
@@ -16,6 +38,8 @@ const readItemSlot = (scenario: LoadedScenario, slot: string): Record<string, nu
|
||||
const readAvailableSpecialWar = (scenario: LoadedScenario): string[] =>
|
||||
(scenario.config.const.availableSpecialWar as string[] | undefined) ?? [];
|
||||
|
||||
const refSourceIt = hasRefSourceRoot() ? it : it.skip;
|
||||
|
||||
describe('tracked scenario resources', () => {
|
||||
it('loads every scenario through its composed resource graph', async () => {
|
||||
const scenarioRoot = path.dirname(resolveScenarioDefaultsPath());
|
||||
@@ -26,11 +50,88 @@ describe('tracked scenario resources', () => {
|
||||
.map((match) => Number(match[1]))
|
||||
.sort((left, right) => left - right);
|
||||
|
||||
expect(scenarioIds).toHaveLength(80);
|
||||
expect(scenarioIds).toContain(914);
|
||||
expect(scenarioIds).toContain(915);
|
||||
const scenarios = await Promise.all(scenarioIds.map((scenarioId) => loadScenarioDefinitionById(scenarioId)));
|
||||
expect(scenarios.every((scenario) => scenario.title.length > 0)).toBe(true);
|
||||
});
|
||||
|
||||
refSourceIt('preserves the Ref scenario 915 S100 pool and event order exactly', async () => {
|
||||
const referencePath = path.join(resolveRefSourceRoot(), 'hwe', 'scenario', 'scenario_915.json');
|
||||
const [scenario, referenceSource] = await Promise.all([
|
||||
loadScenarioDefinitionById(915),
|
||||
fs.readFile(referencePath, 'utf8').then((raw) => JSON.parse(raw) as ReferenceScenario915),
|
||||
]);
|
||||
|
||||
expect(scenario.title).toBe(referenceSource.title);
|
||||
expect(scenario.startYear).toBe(referenceSource.startYear);
|
||||
expect(scenario.config.map).toEqual(referenceSource.map);
|
||||
expect(scenario.history).toEqual(referenceSource.history);
|
||||
expect(scenario.config.const).toEqual(referenceSource.const);
|
||||
expect(scenario.events).toEqual(referenceSource.events);
|
||||
expect(
|
||||
scenario.events
|
||||
.filter((entry): entry is unknown[] => Array.isArray(entry) && entry[0] === 'month')
|
||||
.map((entry) => ({ priority: entry[1], condition: entry[2], actions: entry.slice(3) }))
|
||||
).toEqual([
|
||||
{ priority: 8_000, condition: true, actions: [['AdvanceCentennialAllStar']] },
|
||||
{
|
||||
priority: 1_000,
|
||||
condition: ['Date', '==', null, 12],
|
||||
actions: [['CreateManyNPC', 100, 0], ['DeleteEvent']],
|
||||
},
|
||||
{
|
||||
priority: 1_000,
|
||||
condition: ['Date', '==', 181, 1],
|
||||
actions: [['RaiseNPCNation'], ['DeleteEvent']],
|
||||
},
|
||||
{
|
||||
priority: 999,
|
||||
condition: ['Date', '==', 181, 1],
|
||||
actions: [['OpenNationBetting', 4, 5_000], ['OpenNationBetting', 1, 2_000], ['DeleteEvent']],
|
||||
},
|
||||
{
|
||||
priority: 999,
|
||||
condition: ['and', ['Date', '>=', 183, 1], ['RemainNation', '<=', 8]],
|
||||
actions: [['OpenNationBetting', 1, 1_000], ['DeleteEvent']],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
refSourceIt('preserves the Ref scenario 914 item pool, monthly action order, and deletion markers', async () => {
|
||||
const referencePath = path.join(resolveRefSourceRoot(), 'hwe', 'scenario', 'scenario_914.json');
|
||||
const [scenario, referenceSource] = await Promise.all([
|
||||
loadScenarioDefinitionById(914),
|
||||
fs.readFile(referencePath, 'utf8').then((raw) => JSON.parse(raw) as ReferenceScenario914),
|
||||
]);
|
||||
|
||||
expect(scenario.title).toBe(referenceSource.title);
|
||||
expect(scenario.startYear).toBe(referenceSource.startYear);
|
||||
expect(scenario.config.map).toEqual(referenceSource.map);
|
||||
expect(scenario.history).toEqual(referenceSource.history);
|
||||
expect(scenario.config.const).toEqual(referenceSource.const);
|
||||
expect(scenario.config.const.allItems).toEqual(referenceSource.const.allItems);
|
||||
for (const [slot, items] of Object.entries(referenceSource.const.allItems)) {
|
||||
expect(Object.keys(readItemSlot(scenario, slot))).toEqual(Object.keys(items));
|
||||
}
|
||||
expect(scenario.events).toEqual(referenceSource.events);
|
||||
|
||||
const monthlyActionNames = scenario.events
|
||||
.filter((event): event is unknown[] => Array.isArray(event) && event[0] === 'month')
|
||||
.map((event) =>
|
||||
event
|
||||
.slice(3)
|
||||
.map((action) => (Array.isArray(action) && typeof action[0] === 'string' ? action[0] : null))
|
||||
);
|
||||
expect(monthlyActionNames).toEqual([
|
||||
['CreateManyNPC', 'DeleteEvent'],
|
||||
['RaiseNPCNation', 'DeleteEvent'],
|
||||
['OpenNationBetting', 'OpenNationBetting', 'DeleteEvent'],
|
||||
['ChangeCity'],
|
||||
['ChangeCity'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('opens nation betting in the first playable year of every scenario 29 variant', async () => {
|
||||
const scenarioIds = [2900, 2901, 2903, 2904];
|
||||
const scenarios = await Promise.all(scenarioIds.map((scenarioId) => loadScenarioDefinitionById(scenarioId)));
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
createGamePostgresConnector,
|
||||
type GamePrisma,
|
||||
type GamePrismaClient,
|
||||
} from '@sammo-ts/infra';
|
||||
import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
import { buildScenarioGeneralPoolClaimMeta } from '@sammo-ts/logic';
|
||||
|
||||
import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js';
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
@@ -13,6 +10,10 @@ import { loadTurnWorldFromDatabase } from '../src/turn/worldLoader.js';
|
||||
const databaseUrl = process.env.SELECT_POOL_DATABASE_URL;
|
||||
const integration = describe.skipIf(!databaseUrl);
|
||||
const generalId = 990_904;
|
||||
const claimedGeneralId = 990_905;
|
||||
const conflictedGeneralId = 990_906;
|
||||
const protectedGeneralId = 990_907;
|
||||
const laterGeneralId = 990_908;
|
||||
const cityId = 990_904;
|
||||
const scenarioCode = 'select-pool-release-integration';
|
||||
|
||||
@@ -36,9 +37,23 @@ integration('select pool release during general deletion', () => {
|
||||
|
||||
await db.$executeRawUnsafe('DROP TABLE IF EXISTS "select_pool_delete_blocker"');
|
||||
await db.selectPoolEntry.deleteMany({
|
||||
where: { uniqueName: 'release-candidate' },
|
||||
where: {
|
||||
uniqueName: {
|
||||
in: [
|
||||
'release-candidate',
|
||||
'claim-candidate',
|
||||
'conflict-candidate',
|
||||
'early-protected-candidate',
|
||||
'later-free-candidate',
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
await db.general.deleteMany({
|
||||
where: {
|
||||
id: { in: [generalId, claimedGeneralId, conflictedGeneralId, protectedGeneralId, laterGeneralId] },
|
||||
},
|
||||
});
|
||||
await db.general.deleteMany({ where: { id: generalId } });
|
||||
await db.city.deleteMany({ where: { id: cityId } });
|
||||
await db.worldState.deleteMany({ where: { scenarioCode } });
|
||||
|
||||
@@ -154,6 +169,25 @@ integration('select pool release during general deletion', () => {
|
||||
} as GamePrisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
await db.selectPoolEntry.createMany({
|
||||
data: ['claim-candidate', 'conflict-candidate'].map((uniqueName) => ({
|
||||
uniqueName,
|
||||
ownerUserId: null,
|
||||
generalId: null,
|
||||
reservedUntil: null,
|
||||
info: {
|
||||
uniqueName,
|
||||
generalName: uniqueName === 'claim-candidate' ? '점유후보' : '충돌후보',
|
||||
leadership: 70,
|
||||
strength: 80,
|
||||
intel: 10,
|
||||
specialDomestic: 'che_event_징병',
|
||||
dex: [10, 20, 30, 40, 50],
|
||||
imgsvr: 0,
|
||||
picture: 'default.jpg',
|
||||
} as GamePrisma.InputJsonValue,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -163,9 +197,23 @@ integration('select pool release during general deletion', () => {
|
||||
}
|
||||
await db.$executeRawUnsafe('DROP TABLE IF EXISTS "select_pool_delete_blocker"');
|
||||
await db.selectPoolEntry.deleteMany({
|
||||
where: { uniqueName: 'release-candidate' },
|
||||
where: {
|
||||
uniqueName: {
|
||||
in: [
|
||||
'release-candidate',
|
||||
'claim-candidate',
|
||||
'conflict-candidate',
|
||||
'early-protected-candidate',
|
||||
'later-free-candidate',
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
await db.general.deleteMany({
|
||||
where: {
|
||||
id: { in: [generalId, claimedGeneralId, conflictedGeneralId, protectedGeneralId, laterGeneralId] },
|
||||
},
|
||||
});
|
||||
await db.general.deleteMany({ where: { id: generalId } });
|
||||
await db.city.deleteMany({ where: { id: cityId } });
|
||||
await db.worldState.deleteMany({ where: { scenarioCode } });
|
||||
await closeDb?.();
|
||||
@@ -201,14 +249,222 @@ integration('select pool release during general deletion', () => {
|
||||
special2Code: 'che_무쌍',
|
||||
});
|
||||
const reloaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
|
||||
expect(
|
||||
reloaded.snapshot.generals.find((general) => general.id === generalId)?.role
|
||||
).toMatchObject({
|
||||
expect(reloaded.snapshot.generals.find((general) => general.id === generalId)?.role).toMatchObject({
|
||||
specialDomestic: 'che_event_신산',
|
||||
specialWar: 'che_무쌍',
|
||||
});
|
||||
});
|
||||
|
||||
it('claims a pool row in the same fenced transaction that creates the NPC', async () => {
|
||||
const loaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
|
||||
const world = new InMemoryTurnWorld(loaded.state, loaded.snapshot, {
|
||||
schedule: { entries: [{ startMinute: 0, tickMinutes: 5 }] },
|
||||
});
|
||||
const candidate = world
|
||||
.listGeneralPoolCandidates(loaded.state.lastTurnTime)
|
||||
?.find((entry) => entry.uniqueName === 'claim-candidate');
|
||||
expect(candidate).toBeDefined();
|
||||
const template = world.getGeneralById(generalId)!;
|
||||
expect(
|
||||
world.addGeneral({
|
||||
...structuredClone(template),
|
||||
id: claimedGeneralId,
|
||||
userId: null,
|
||||
name: 'ⓜ점유후보',
|
||||
npcState: 3,
|
||||
officerLevel: 0,
|
||||
meta: {
|
||||
...template.meta,
|
||||
...buildScenarioGeneralPoolClaimMeta(candidate!, loaded.state.lastTurnTime),
|
||||
},
|
||||
})
|
||||
).toBe(true);
|
||||
|
||||
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
|
||||
try {
|
||||
await hooks.hooks.flushChanges?.({
|
||||
lastTurnTime: loaded.state.lastTurnTime.toISOString(),
|
||||
processedGenerals: 0,
|
||||
processedTurns: 0,
|
||||
durationMs: 0,
|
||||
partial: false,
|
||||
});
|
||||
} finally {
|
||||
await hooks.close();
|
||||
}
|
||||
|
||||
await expect(db.general.findUnique({ where: { id: claimedGeneralId } })).resolves.not.toBeNull();
|
||||
await expect(
|
||||
db.selectPoolEntry.findUniqueOrThrow({ where: { uniqueName: 'claim-candidate' } })
|
||||
).resolves.toMatchObject({
|
||||
generalId: claimedGeneralId,
|
||||
ownerUserId: null,
|
||||
reservedUntil: null,
|
||||
reservedUntilTick: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('rolls back the NPC and pool mutation when a concurrent user reservation wins', async () => {
|
||||
const loaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
|
||||
const world = new InMemoryTurnWorld(loaded.state, loaded.snapshot, {
|
||||
schedule: { entries: [{ startMinute: 0, tickMinutes: 5 }] },
|
||||
});
|
||||
const candidate = world
|
||||
.listGeneralPoolCandidates(loaded.state.lastTurnTime)
|
||||
?.find((entry) => entry.uniqueName === 'conflict-candidate');
|
||||
expect(candidate).toBeDefined();
|
||||
const template = world.getGeneralById(generalId)!;
|
||||
expect(
|
||||
world.addGeneral({
|
||||
...structuredClone(template),
|
||||
id: conflictedGeneralId,
|
||||
userId: null,
|
||||
name: 'ⓜ충돌후보',
|
||||
npcState: 3,
|
||||
officerLevel: 0,
|
||||
meta: {
|
||||
...template.meta,
|
||||
...buildScenarioGeneralPoolClaimMeta(candidate!, loaded.state.lastTurnTime),
|
||||
},
|
||||
})
|
||||
).toBe(true);
|
||||
const reservedUntil = new Date(loaded.state.lastTurnTime.getTime() + 60_000);
|
||||
await db.selectPoolEntry.update({
|
||||
where: { uniqueName: 'conflict-candidate' },
|
||||
data: { ownerUserId: 'concurrent-user', reservedUntil },
|
||||
});
|
||||
|
||||
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
|
||||
try {
|
||||
await expect(
|
||||
hooks.hooks.flushChanges?.({
|
||||
lastTurnTime: loaded.state.lastTurnTime.toISOString(),
|
||||
processedGenerals: 0,
|
||||
processedTurns: 0,
|
||||
durationMs: 0,
|
||||
partial: false,
|
||||
})
|
||||
).rejects.toThrow('select_pool 후보를 점유하지 못했습니다: conflict-candidate');
|
||||
} finally {
|
||||
await hooks.close();
|
||||
}
|
||||
|
||||
await expect(db.general.findUnique({ where: { id: conflictedGeneralId } })).resolves.toBeNull();
|
||||
await expect(
|
||||
db.selectPoolEntry.findUniqueOrThrow({ where: { uniqueName: 'conflict-candidate' } })
|
||||
).resolves.toMatchObject({
|
||||
generalId: null,
|
||||
ownerUserId: 'concurrent-user',
|
||||
reservedUntil,
|
||||
});
|
||||
});
|
||||
|
||||
it('checks every NPC claim at its own claimedAt without clearing a later-expiring user reservation', async () => {
|
||||
const earlyClaimedAt = new Date('2026-07-30T12:00:00.000Z');
|
||||
const reservedUntil = new Date('2026-07-30T12:05:00.000Z');
|
||||
const laterClaimedAt = new Date('2026-07-30T12:10:00.000Z');
|
||||
await db.selectPoolEntry.createMany({
|
||||
data: [
|
||||
{
|
||||
uniqueName: 'early-protected-candidate',
|
||||
ownerUserId: 'protected-user',
|
||||
generalId: null,
|
||||
reservedUntil,
|
||||
info: {
|
||||
uniqueName: 'early-protected-candidate',
|
||||
generalName: '보호후보',
|
||||
leadership: 70,
|
||||
strength: 80,
|
||||
intel: 10,
|
||||
specialDomestic: 'che_event_징병',
|
||||
dex: [10, 20, 30, 40, 50],
|
||||
imgsvr: 0,
|
||||
picture: 'default.jpg',
|
||||
} as GamePrisma.InputJsonValue,
|
||||
},
|
||||
{
|
||||
uniqueName: 'later-free-candidate',
|
||||
ownerUserId: null,
|
||||
generalId: null,
|
||||
reservedUntil: null,
|
||||
info: {
|
||||
uniqueName: 'later-free-candidate',
|
||||
generalName: '후행후보',
|
||||
leadership: 70,
|
||||
strength: 80,
|
||||
intel: 10,
|
||||
specialDomestic: 'che_event_징병',
|
||||
dex: [10, 20, 30, 40, 50],
|
||||
imgsvr: 0,
|
||||
picture: 'default.jpg',
|
||||
} as GamePrisma.InputJsonValue,
|
||||
},
|
||||
],
|
||||
});
|
||||
const loaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
|
||||
const world = new InMemoryTurnWorld(loaded.state, loaded.snapshot, {
|
||||
schedule: { entries: [{ startMinute: 0, tickMinutes: 5 }] },
|
||||
});
|
||||
const entries = world.listGeneralPoolEntries()!;
|
||||
const protectedCandidate = entries.find((entry) => entry.uniqueName === 'early-protected-candidate')!.candidate;
|
||||
const laterCandidate = entries.find((entry) => entry.uniqueName === 'later-free-candidate')!.candidate;
|
||||
const template = world.getGeneralById(generalId)!;
|
||||
expect(
|
||||
world.addGeneral({
|
||||
...structuredClone(template),
|
||||
id: protectedGeneralId,
|
||||
userId: null,
|
||||
name: 'ⓜ보호후보',
|
||||
npcState: 3,
|
||||
meta: {
|
||||
...template.meta,
|
||||
...buildScenarioGeneralPoolClaimMeta(protectedCandidate, earlyClaimedAt),
|
||||
},
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
world.addGeneral({
|
||||
...structuredClone(template),
|
||||
id: laterGeneralId,
|
||||
userId: null,
|
||||
name: 'ⓜ후행후보',
|
||||
npcState: 3,
|
||||
meta: {
|
||||
...template.meta,
|
||||
...buildScenarioGeneralPoolClaimMeta(laterCandidate, laterClaimedAt),
|
||||
},
|
||||
})
|
||||
).toBe(true);
|
||||
|
||||
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
|
||||
try {
|
||||
await expect(
|
||||
hooks.hooks.flushChanges?.({
|
||||
lastTurnTime: loaded.state.lastTurnTime.toISOString(),
|
||||
processedGenerals: 0,
|
||||
processedTurns: 0,
|
||||
durationMs: 0,
|
||||
partial: false,
|
||||
})
|
||||
).rejects.toThrow('select_pool 후보를 점유하지 못했습니다: early-protected-candidate');
|
||||
} finally {
|
||||
await hooks.close();
|
||||
}
|
||||
|
||||
await expect(db.general.findUnique({ where: { id: protectedGeneralId } })).resolves.toBeNull();
|
||||
await expect(db.general.findUnique({ where: { id: laterGeneralId } })).resolves.toBeNull();
|
||||
await expect(
|
||||
db.selectPoolEntry.findUniqueOrThrow({ where: { uniqueName: 'early-protected-candidate' } })
|
||||
).resolves.toMatchObject({
|
||||
generalId: null,
|
||||
ownerUserId: 'protected-user',
|
||||
reservedUntil,
|
||||
});
|
||||
await expect(
|
||||
db.selectPoolEntry.findUniqueOrThrow({ where: { uniqueName: 'later-free-candidate' } })
|
||||
).resolves.toMatchObject({ generalId: null, ownerUserId: null, reservedUntil: null });
|
||||
});
|
||||
|
||||
it('rolls back a failed flush, then releases all Ref fields before deleting the general', async () => {
|
||||
const loaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
|
||||
const world = new InMemoryTurnWorld(loaded.state, loaded.snapshot, {
|
||||
@@ -224,9 +480,7 @@ integration('select pool release during general deletion', () => {
|
||||
REFERENCES "general"("id") ON DELETE RESTRICT
|
||||
)
|
||||
`);
|
||||
await db.$executeRawUnsafe(
|
||||
`INSERT INTO "select_pool_delete_blocker" ("general_id") VALUES (${generalId})`
|
||||
);
|
||||
await db.$executeRawUnsafe(`INSERT INTO "select_pool_delete_blocker" ("general_id") VALUES (${generalId})`);
|
||||
|
||||
await expect(
|
||||
hooks.hooks.flushChanges?.({
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { GAME_TICKS_PER_TURN } from '@sammo-ts/common';
|
||||
import { parseScenarioGeneralPoolCandidate } from '@sammo-ts/logic';
|
||||
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import { reserveSelectionPool } from '../src/turn/selectPoolService.js';
|
||||
import type { TurnGeneralPoolEntry, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
|
||||
interface TestPoolRow {
|
||||
id: number;
|
||||
uniqueName: string;
|
||||
ownerUserId: string | null;
|
||||
generalId: number | null;
|
||||
reservedUntil: Date | null;
|
||||
reservedUntilTick: bigint | null;
|
||||
info: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface PoolWhere {
|
||||
id?: { in: number[] };
|
||||
ownerUserId?: string | null;
|
||||
generalId?: number | null;
|
||||
reservedUntil?: null | { lt?: Date; gte?: Date };
|
||||
reservedUntilTick?: null | { lt?: bigint; gte?: bigint };
|
||||
OR?: PoolWhere[];
|
||||
}
|
||||
|
||||
const acceptedAt = new Date('0200-05-01T00:00:00.000Z');
|
||||
|
||||
const buildRows = (): TestPoolRow[] =>
|
||||
Array.from({ length: 29 }, (_, index) => {
|
||||
const uniqueName = `P${String(index + 1).padStart(2, '0')}`;
|
||||
return {
|
||||
id: index + 1,
|
||||
uniqueName,
|
||||
ownerUserId: index === 0 ? 'existing-user' : null,
|
||||
generalId: null,
|
||||
reservedUntil: index === 0 ? new Date(acceptedAt.getTime() + 30 * 60_000) : null,
|
||||
reservedUntilTick: index === 0 ? 3_000_000n : null,
|
||||
info: {
|
||||
uniqueName,
|
||||
generalName: uniqueName,
|
||||
leadership: 70,
|
||||
strength: 80,
|
||||
intel: 10,
|
||||
specialDomestic: null,
|
||||
dex: [100 + index, 0, 0, 0, 0],
|
||||
imgsvr: 0,
|
||||
picture: 'default.jpg',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const buildWorld = (rows: TestPoolRow[]): InMemoryTurnWorld => {
|
||||
const generalPoolEntries: TurnGeneralPoolEntry[] = rows.map((row) => ({
|
||||
id: row.id,
|
||||
uniqueName: row.uniqueName,
|
||||
ownerUserId: row.ownerUserId,
|
||||
generalId: row.generalId,
|
||||
reservedUntil: row.reservedUntil,
|
||||
reservedUntilTick: row.reservedUntilTick === null ? null : Number(row.reservedUntilTick),
|
||||
candidate: parseScenarioGeneralPoolCandidate(row),
|
||||
}));
|
||||
const state: TurnWorldState = {
|
||||
id: 1,
|
||||
currentYear: 200,
|
||||
currentMonth: 5,
|
||||
tickSeconds: 300,
|
||||
lastTurnTime: acceptedAt,
|
||||
meta: { hiddenSeed: 'selection-reservation-test' },
|
||||
};
|
||||
const snapshot: TurnWorldSnapshot = {
|
||||
scenarioConfig: {
|
||||
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 },
|
||||
iconPath: '',
|
||||
map: { targetGeneralPool: 'SPoolUnderU30' },
|
||||
const: {},
|
||||
environment: { mapName: 'test', unitSet: 'default' },
|
||||
},
|
||||
map: {
|
||||
id: 'test',
|
||||
name: 'test',
|
||||
cities: [],
|
||||
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
|
||||
},
|
||||
generals: [],
|
||||
cities: [],
|
||||
nations: [],
|
||||
troops: [],
|
||||
diplomacy: [],
|
||||
events: [],
|
||||
initialEvents: [],
|
||||
generalPoolEntries,
|
||||
};
|
||||
return new InMemoryTurnWorld(state, snapshot, {
|
||||
schedule: { entries: [{ startMinute: 0, tickMinutes: 5 }] },
|
||||
});
|
||||
};
|
||||
|
||||
const matchesPoolWhere = (row: TestPoolRow, where: PoolWhere): boolean => {
|
||||
if (where.id && !where.id.in.includes(row.id)) {
|
||||
return false;
|
||||
}
|
||||
if (where.ownerUserId !== undefined && row.ownerUserId !== where.ownerUserId) {
|
||||
return false;
|
||||
}
|
||||
if (where.generalId !== undefined && row.generalId !== where.generalId) {
|
||||
return false;
|
||||
}
|
||||
if (where.reservedUntil !== undefined) {
|
||||
if (where.reservedUntil === null) {
|
||||
if (row.reservedUntil !== null) {
|
||||
return false;
|
||||
}
|
||||
} else if (
|
||||
row.reservedUntil === null ||
|
||||
(where.reservedUntil.lt !== undefined && row.reservedUntil >= where.reservedUntil.lt) ||
|
||||
(where.reservedUntil.gte !== undefined && row.reservedUntil < where.reservedUntil.gte)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (where.reservedUntilTick !== undefined) {
|
||||
if (where.reservedUntilTick === null) {
|
||||
if (row.reservedUntilTick !== null) {
|
||||
return false;
|
||||
}
|
||||
} else if (
|
||||
row.reservedUntilTick === null ||
|
||||
(where.reservedUntilTick.lt !== undefined && row.reservedUntilTick >= where.reservedUntilTick.lt) ||
|
||||
(where.reservedUntilTick.gte !== undefined && row.reservedUntilTick < where.reservedUntilTick.gte)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return where.OR === undefined || where.OR.some((alternative) => matchesPoolWhere(row, alternative));
|
||||
};
|
||||
|
||||
const buildDb = (rows: TestPoolRow[]) => ({
|
||||
$executeRaw: async () => 0,
|
||||
general: {
|
||||
findFirst: async () => null,
|
||||
},
|
||||
selectPoolEntry: {
|
||||
findMany: async () => structuredClone(rows),
|
||||
updateMany: async (input: { where: PoolWhere; data: Partial<TestPoolRow> }) => {
|
||||
let count = 0;
|
||||
for (const row of rows) {
|
||||
if (!matchesPoolWhere(row, input.where)) {
|
||||
continue;
|
||||
}
|
||||
Object.assign(row, input.data);
|
||||
count += 1;
|
||||
}
|
||||
return { count };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const worldState = {
|
||||
currentYear: 200,
|
||||
currentMonth: 5,
|
||||
tickSeconds: 300,
|
||||
config: {
|
||||
npcMode: 2,
|
||||
turnTermMinutes: 5,
|
||||
map: { targetGeneralPool: 'SPoolUnderU30' },
|
||||
},
|
||||
meta: { hiddenSeed: 'selection-reservation-test' },
|
||||
};
|
||||
|
||||
describe('selection-pool reservation command state', () => {
|
||||
it('excludes current reservations and keeps serialized users disjoint in DB and memory', async () => {
|
||||
const rows = buildRows();
|
||||
const world = buildWorld(rows);
|
||||
const db = buildDb(rows);
|
||||
const reserve = (userId: string, acceptedGameTick: number) =>
|
||||
reserveSelectionPool({
|
||||
db: db as never,
|
||||
world,
|
||||
worldState: worldState as never,
|
||||
userId,
|
||||
seedOwnerIdentity: userId,
|
||||
now: acceptedAt,
|
||||
acceptedGameTick,
|
||||
});
|
||||
|
||||
const first = await reserve('first-user', 0);
|
||||
const retried = await reserve('first-user', 1);
|
||||
const second = await reserve('second-user', 2);
|
||||
|
||||
expect(retried).toEqual(first);
|
||||
expect(first.candidates).toHaveLength(14);
|
||||
expect(second.candidates).toHaveLength(14);
|
||||
expect(new Set(first.candidates.map((candidate) => candidate.uniqueName))).not.toContain('P01');
|
||||
expect(
|
||||
first.candidates.some((candidate) =>
|
||||
second.candidates.some((other) => other.uniqueName === candidate.uniqueName)
|
||||
)
|
||||
).toBe(false);
|
||||
expect(rows.filter((row) => row.ownerUserId === 'first-user')).toHaveLength(14);
|
||||
expect(rows.filter((row) => row.ownerUserId === 'second-user')).toHaveLength(14);
|
||||
expect(rows.find((row) => row.ownerUserId === 'first-user')?.reservedUntilTick).toBe(
|
||||
BigInt(2 * GAME_TICKS_PER_TURN)
|
||||
);
|
||||
expect(first.validUntil).toBe(world.gameTickToDate(2 * GAME_TICKS_PER_TURN).toISOString());
|
||||
expect(world.listGeneralPoolCandidates(acceptedAt)).toEqual([]);
|
||||
});
|
||||
|
||||
it('uses Ref nowTick equality and resynchronizes DB expiry into the in-memory pool', async () => {
|
||||
const rows = buildRows();
|
||||
rows[0]!.ownerUserId = 'stale-user';
|
||||
rows[0]!.reservedUntil = new Date(acceptedAt.getTime() + 60 * 60_000);
|
||||
rows[0]!.reservedUntilTick = -1n;
|
||||
rows[1]!.ownerUserId = 'exact-user';
|
||||
rows[1]!.reservedUntil = new Date(acceptedAt.getTime() - 60_000);
|
||||
rows[1]!.reservedUntilTick = 0n;
|
||||
const world = buildWorld(rows);
|
||||
const db = buildDb(rows);
|
||||
const reserve = (userId: string, acceptedGameTick: number) =>
|
||||
reserveSelectionPool({
|
||||
db: db as never,
|
||||
world,
|
||||
worldState: worldState as never,
|
||||
userId,
|
||||
seedOwnerIdentity: userId,
|
||||
now: acceptedAt,
|
||||
acceptedGameTick,
|
||||
});
|
||||
|
||||
const first = await reserve('first-user', 0);
|
||||
|
||||
expect(rows[0]!.ownerUserId).not.toBe('stale-user');
|
||||
expect(rows[1]).toMatchObject({ ownerUserId: 'exact-user', reservedUntilTick: 0n });
|
||||
expect(first.candidates.map((candidate) => candidate.uniqueName)).not.toContain('P02');
|
||||
expect(first.validUntil).toBe(world.gameTickToDate(2 * GAME_TICKS_PER_TURN).toISOString());
|
||||
|
||||
await reserve('second-user', 1);
|
||||
|
||||
expect(rows[1]!.ownerUserId).not.toBe('exact-user');
|
||||
const synchronizedById = new Map(world.listGeneralPoolEntries()?.map((entry) => [entry.id, entry]));
|
||||
for (const row of rows) {
|
||||
expect(synchronizedById.get(row.id)).toMatchObject({
|
||||
ownerUserId: row.ownerUserId,
|
||||
generalId: row.generalId,
|
||||
reservedUntil: row.reservedUntil,
|
||||
reservedUntilTick: row.reservedUntilTick === null ? null : Number(row.reservedUntilTick),
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import { buildPersistedRankRows } from '../src/turn/rankData.js';
|
||||
|
||||
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
|
||||
|
||||
const buildGeneral = (id: number, meta: Record<string, number> = {}): TurnGeneral => ({
|
||||
const buildGeneral = (id: number, meta: Record<string, number> = {}, npcState = 0): TurnGeneral => ({
|
||||
id,
|
||||
name: `장수${id}`,
|
||||
nationId: 1,
|
||||
@@ -39,7 +39,7 @@ const buildGeneral = (id: number, meta: Record<string, number> = {}): TurnGenera
|
||||
train: 0,
|
||||
atmos: 0,
|
||||
age: 30,
|
||||
npcState: 0,
|
||||
npcState,
|
||||
});
|
||||
|
||||
const buildWorld = (
|
||||
@@ -106,10 +106,7 @@ describe('tournament world commands', () => {
|
||||
});
|
||||
|
||||
it('updates the persisted tt rank keys for a tournament match', async () => {
|
||||
const world = buildWorld([
|
||||
buildGeneral(1, { ttg: 10, ttw: 2 }),
|
||||
buildGeneral(2, { ttg: 5, ttl: 1 }),
|
||||
]);
|
||||
const world = buildWorld([buildGeneral(1, { ttg: 10, ttw: 2 }), buildGeneral(2, { ttg: 5, ttl: 1 })]);
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
|
||||
await expect(
|
||||
@@ -135,6 +132,7 @@ describe('tournament world commands', () => {
|
||||
handler.handle({
|
||||
type: 'tournamentBettingPayout',
|
||||
bettingId: 1,
|
||||
tournamentType: 0,
|
||||
payouts: [{ generalId: 1, amount: 500 }],
|
||||
})
|
||||
).resolves.toMatchObject({ ok: true, totalPayout: 500 });
|
||||
@@ -144,6 +142,48 @@ describe('tournament world commands', () => {
|
||||
meta: { betwin: 3, betwingold: 600 },
|
||||
});
|
||||
expect(world.getGeneralById(1)?.meta).not.toHaveProperty('rank_betwin');
|
||||
expect(world.peekDirtyState().logs).toContainEqual(
|
||||
expect.objectContaining({
|
||||
generalId: 1,
|
||||
category: 'ACTION',
|
||||
text: '<C>전력전</>의 베팅 당첨 보상으로 <C>500</>의 <S>금</> 획득!',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('pays every winner but records betting ranks only for Ref-eligible generals', async () => {
|
||||
const world = buildWorld([
|
||||
buildGeneral(1, {}, 0),
|
||||
buildGeneral(2, { betgold: 100 }, 1),
|
||||
buildGeneral(3, { betgold: 100 }, 2),
|
||||
]);
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
|
||||
await expect(
|
||||
handler.handle({
|
||||
type: 'tournamentBettingPayout',
|
||||
bettingId: 1,
|
||||
tournamentType: 3,
|
||||
payouts: [
|
||||
{ generalId: 1, amount: 2_000 },
|
||||
{ generalId: 2, amount: 2_000 },
|
||||
{ generalId: 3, amount: 2_000 },
|
||||
],
|
||||
})
|
||||
).resolves.toMatchObject({ ok: true, processed: 3, totalPayout: 6_000 });
|
||||
|
||||
expect(world.getGeneralById(1)).toMatchObject({ gold: 3_000, meta: { betwin: 1, betwingold: 2_000 } });
|
||||
expect(world.getGeneralById(2)).toMatchObject({
|
||||
gold: 3_000,
|
||||
meta: { betgold: 100, betwin: 1, betwingold: 2_000 },
|
||||
});
|
||||
expect(world.getGeneralById(3)).toMatchObject({ gold: 3_000, meta: { betgold: 100 } });
|
||||
expect(world.getGeneralById(3)?.meta).not.toHaveProperty('betwin');
|
||||
expect(world.peekDirtyState().logs.map((entry) => entry.text)).toEqual([
|
||||
'<C>설전</>의 베팅 당첨 보상으로 <C>2,000</>의 <S>금</> 획득!',
|
||||
'<C>설전</>의 베팅 당첨 보상으로 <C>2,000</>의 <S>금</> 획득!',
|
||||
'<C>설전</>의 베팅 당첨 보상으로 <C>2,000</>의 <S>금</> 획득!',
|
||||
]);
|
||||
});
|
||||
|
||||
it('records all four tournament types and NPC betting for at least ten generals', async () => {
|
||||
@@ -174,6 +214,7 @@ describe('tournament world commands', () => {
|
||||
await handler.handle({
|
||||
type: 'tournamentBettingPayout',
|
||||
bettingId: 1,
|
||||
tournamentType: 0,
|
||||
payouts: generals.map((general) => ({ generalId: general.id, amount: 2_000 })),
|
||||
});
|
||||
|
||||
|
||||
@@ -241,6 +241,49 @@ describe('TurnDaemonLifecycle', () => {
|
||||
expect(observedTargets[0]?.toISOString()).toBe('2042-01-01T02:59:59.999Z');
|
||||
});
|
||||
|
||||
it('limits an explicit manual run target to the next monthly boundary', async () => {
|
||||
const lastTurnTime = new Date('2042-01-01T00:00:00.000Z');
|
||||
const requestedTarget = addMinutes(lastTurnTime, 180);
|
||||
const queue = new InMemoryControlQueue();
|
||||
const processor: TurnProcessor = {
|
||||
run: vi.fn(async (target): Promise<TurnRunResult> => {
|
||||
queue.enqueue({ type: 'shutdown', reason: 'verified' });
|
||||
return {
|
||||
lastTurnTime: target.toISOString(),
|
||||
processedGenerals: 1,
|
||||
processedTurns: 1,
|
||||
durationMs: 0,
|
||||
partial: false,
|
||||
};
|
||||
}),
|
||||
};
|
||||
const lifecycle = new TurnDaemonLifecycle(
|
||||
{
|
||||
clock: new ManualClock(lastTurnTime.getTime()),
|
||||
controlQueue: queue,
|
||||
getNextTickTime: (value) => addMinutes(value, 60),
|
||||
stateStore: {
|
||||
loadLastTurnTime: async () => lastTurnTime,
|
||||
loadNextGeneralTurnTime: async () => addMinutes(lastTurnTime, 30),
|
||||
saveLastTurnTime: async () => {},
|
||||
loadCheckpoint: async () => undefined,
|
||||
saveCheckpoint: async () => {},
|
||||
},
|
||||
processor,
|
||||
},
|
||||
{
|
||||
profile: 'manual-explicit-boundary',
|
||||
defaultBudget: { budgetMs: 100, maxGenerals: 10, catchUpCap: 1 },
|
||||
}
|
||||
);
|
||||
|
||||
lifecycle.requestRun('manual', requestedTarget);
|
||||
await lifecycle.start();
|
||||
|
||||
expect(processor.run).toHaveBeenCalledOnce();
|
||||
expect((processor.run as ReturnType<typeof vi.fn>).mock.calls[0]?.[0]).toEqual(addMinutes(lastTurnTime, 60));
|
||||
});
|
||||
|
||||
it('produces the same command, RNG, and resource state in realtime and manual modes', async () => {
|
||||
const start = new Date('2042-01-01T00:00:00.000Z');
|
||||
const runMode = async (mode: 'realtime' | 'manual') => {
|
||||
|
||||
@@ -193,7 +193,16 @@ describe('unification handler', () => {
|
||||
await world.advanceMonth(new Date('0190-07-01T00:00:00.000Z'));
|
||||
|
||||
expect(observed).toEqual([{ isUnited: undefined, unifier: 2007, previous: 150, spent: 20 }]);
|
||||
expect(world.getState().meta).toMatchObject({ isUnited: 2, isunited: 2, refreshLimit: 200 });
|
||||
expect(world.getState().meta).toMatchObject({
|
||||
isUnited: 2,
|
||||
isunited: 2,
|
||||
refreshLimit: 200,
|
||||
dynastyStatistics: {
|
||||
maxNationCount: 1,
|
||||
maxGeneralCount: 1,
|
||||
currentGeneralCount: 1,
|
||||
},
|
||||
});
|
||||
expect(world.getGeneralById(1)).toMatchObject({
|
||||
inheritancePoints: { previous: 150, unifier: 2007, tournament: 11 },
|
||||
meta: { inherit_earned_dyn: 2162.1, inherit_earned: 2167.1, inherit_spent: 20 },
|
||||
|
||||
@@ -12,8 +12,9 @@ import {
|
||||
} from '@sammo-ts/logic';
|
||||
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import { normalizeTurnDaemonCommand } from '../src/turn/commandRegistry.js';
|
||||
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
|
||||
import { createTurnDaemonCommandHandler, hasVotePollDeadlinePassed } from '../src/turn/worldCommandHandler.js';
|
||||
|
||||
const buildGeneral = (id: number): TurnGeneral => ({
|
||||
id,
|
||||
@@ -46,6 +47,39 @@ const buildGeneral = (id: number): TurnGeneral => ({
|
||||
});
|
||||
|
||||
describe('voteReward command', () => {
|
||||
it('keeps the wall-time fallback open at exact deadline equality', () => {
|
||||
const deadline = new Date('0180-01-01T00:00:00.000Z');
|
||||
|
||||
expect(hasVotePollDeadlinePassed({ endAt: deadline, endTick: null, closedAt: null }, deadline, 0)).toBe(false);
|
||||
expect(
|
||||
hasVotePollDeadlinePassed(
|
||||
{ endAt: deadline, endTick: null, closedAt: null },
|
||||
new Date(deadline.getTime() + 1),
|
||||
0
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves the server-accepted game tick through durable command normalization', () => {
|
||||
expect(
|
||||
normalizeTurnDaemonCommand({
|
||||
requestId: 'vote-accepted-tick',
|
||||
sentAt: '2026-08-23T00:00:00.000Z',
|
||||
command: {
|
||||
type: 'voteReward',
|
||||
voteId: 1,
|
||||
generalId: 1,
|
||||
selection: [0],
|
||||
acceptedGameTick: 100,
|
||||
},
|
||||
})
|
||||
).toMatchObject({
|
||||
type: 'voteReward',
|
||||
requestId: 'vote-accepted-tick',
|
||||
acceptedGameTick: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it('applies gold, unique item, logs, and idempotency', async () => {
|
||||
const generals = [buildGeneral(1)];
|
||||
const snapshot: TurnWorldSnapshot = {
|
||||
@@ -117,6 +151,7 @@ describe('voteReward command', () => {
|
||||
iconPath: '',
|
||||
map: {},
|
||||
const: {
|
||||
develCost: 100,
|
||||
allItems: {
|
||||
weapon: {
|
||||
che_무기_12_칠성검: 1,
|
||||
@@ -141,12 +176,18 @@ describe('voteReward command', () => {
|
||||
currentMonth: 1,
|
||||
tickSeconds: 3600,
|
||||
lastTurnTime: new Date('0180-01-01T00:00:00Z'),
|
||||
clockBaseTime: new Date('0180-01-01T00:00:00Z'),
|
||||
clockTick: 10_000,
|
||||
clockMode: 'manual',
|
||||
clockWallAnchor: new Date('2026-08-23T00:00:00Z'),
|
||||
meta: {
|
||||
hiddenSeed: 'seed',
|
||||
scenarioId: 200,
|
||||
initYear: 180,
|
||||
initMonth: 1,
|
||||
scenarioMeta: { startYear: 180 },
|
||||
// Simulate ENGINE processing after the yearly develcost update.
|
||||
develcost: 120,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -179,19 +220,42 @@ describe('voteReward command', () => {
|
||||
|
||||
expect(itemKey).toBe('che_무기_12_칠성검');
|
||||
|
||||
let voteInserted = false;
|
||||
let voteQueryCount = 0;
|
||||
const commandDb = {
|
||||
auction: {
|
||||
findMany: async () => [],
|
||||
},
|
||||
$queryRaw: async (query: { strings: readonly string[] }) => {
|
||||
voteQueryCount += 1;
|
||||
if (query.strings.join(' ').includes('SELECT options')) {
|
||||
return [
|
||||
{
|
||||
options: ['찬성'],
|
||||
multipleOptions: 1,
|
||||
endAt: null,
|
||||
endTick: 0n,
|
||||
closedAt: null,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (voteInserted) return [];
|
||||
voteInserted = true;
|
||||
return [{ id: 11 }];
|
||||
},
|
||||
};
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
const command = {
|
||||
type: 'voteReward' as const,
|
||||
voteId: 1,
|
||||
generalId: 1,
|
||||
goldReward: 500,
|
||||
unique: {
|
||||
expected: true,
|
||||
itemKey,
|
||||
},
|
||||
selection: [0],
|
||||
// Ref accepts the request at exact equality. Engine processing may
|
||||
// occur after the logical clock has advanced beyond the deadline.
|
||||
acceptedGameTick: 0,
|
||||
};
|
||||
|
||||
const result = await handler.handle(command);
|
||||
const result = await handler.handle(command, { db: commandDb as any });
|
||||
expect(result && result.type).toBe('voteReward');
|
||||
if (!result || result.type !== 'voteReward' || !result.ok) {
|
||||
throw new Error('voteReward result missing');
|
||||
@@ -199,7 +263,9 @@ describe('voteReward command', () => {
|
||||
expect(result.awardedUnique).toBe(true);
|
||||
|
||||
const updated = world.getGeneralById(1);
|
||||
expect(updated?.gold).toBe(1500);
|
||||
// ENGINE is the single reward linearization point, so it uses the
|
||||
// processing world's develcost (120 * 5), not an API projection.
|
||||
expect(updated?.gold).toBe(1600);
|
||||
expect(updated?.role.items.weapon).toBe('che_무기_12_칠성검');
|
||||
const meta = updated?.meta as Record<string, unknown>;
|
||||
expect(meta.voteRewards).toMatchObject({
|
||||
@@ -213,7 +279,7 @@ describe('voteReward command', () => {
|
||||
const logTexts = diff.logs.map((entry) => entry.text);
|
||||
expect(logTexts.some((text) => text.includes('【설문조사】'))).toBe(true);
|
||||
|
||||
const second = await handler.handle(command);
|
||||
const second = await handler.handle(command, { db: commandDb as any });
|
||||
expect(second && second.type).toBe('voteReward');
|
||||
if (!second || second.type !== 'voteReward' || !second.ok) {
|
||||
throw new Error('voteReward second result missing');
|
||||
@@ -221,7 +287,108 @@ describe('voteReward command', () => {
|
||||
expect(second.alreadyApplied).toBe(true);
|
||||
expect(second.itemKey).toBe('che_무기_12_칠성검');
|
||||
const afterSecond = world.getGeneralById(1);
|
||||
expect(afterSecond?.gold).toBe(1500);
|
||||
expect(afterSecond?.gold).toBe(1600);
|
||||
expect(voteQueryCount).toBe(2);
|
||||
|
||||
const duplicateWorld = new InMemoryTurnWorld(
|
||||
{ ...state, meta: { ...state.meta } },
|
||||
{ ...snapshot, generals: [buildGeneral(1)] as any },
|
||||
{ schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] } }
|
||||
);
|
||||
const duplicateHandler = createTurnDaemonCommandHandler({ world: duplicateWorld });
|
||||
const duplicateResult = await duplicateHandler.handle(command, {
|
||||
db: {
|
||||
auction: { findMany: async () => [] },
|
||||
$queryRaw: async (query: { strings: readonly string[] }) => {
|
||||
const text = query.strings.join(' ');
|
||||
if (text.includes('SELECT options')) {
|
||||
return [
|
||||
{
|
||||
options: ['찬성'],
|
||||
multipleOptions: 1,
|
||||
endAt: null,
|
||||
endTick: 0n,
|
||||
closedAt: null,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (text.includes('SELECT selection')) return [{ selection: [0] }];
|
||||
return [];
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
expect(duplicateResult).toMatchObject({
|
||||
type: 'voteReward',
|
||||
ok: true,
|
||||
awardedUnique: true,
|
||||
});
|
||||
expect(duplicateWorld.getGeneralById(1)?.gold).toBe(1600);
|
||||
expect(duplicateWorld.getGeneralById(1)?.role.items.weapon).toBe('che_무기_12_칠성검');
|
||||
|
||||
const mismatchWorld = new InMemoryTurnWorld(
|
||||
{ ...state, meta: { ...state.meta } },
|
||||
{ ...snapshot, generals: [buildGeneral(1)] as any },
|
||||
{ schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] } }
|
||||
);
|
||||
const mismatchHandler = createTurnDaemonCommandHandler({ world: mismatchWorld });
|
||||
const mismatchResult = await mismatchHandler.handle(command, {
|
||||
db: {
|
||||
auction: { findMany: async () => [] },
|
||||
$queryRaw: async (query: { strings: readonly string[] }) => {
|
||||
const text = query.strings.join(' ');
|
||||
if (text.includes('SELECT options')) {
|
||||
return [
|
||||
{
|
||||
options: ['찬성'],
|
||||
multipleOptions: 1,
|
||||
endAt: null,
|
||||
endTick: 0n,
|
||||
closedAt: null,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (text.includes('SELECT selection')) return [{ selection: [1] }];
|
||||
return [];
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
expect(mismatchResult).toMatchObject({
|
||||
type: 'voteReward',
|
||||
ok: false,
|
||||
reason: '이미 설문조사를 완료하였습니다.',
|
||||
});
|
||||
expect(mismatchWorld.getGeneralById(1)?.gold).toBe(1000);
|
||||
expect(mismatchWorld.getGeneralById(1)?.role.items.weapon).toBeNull();
|
||||
expect(mismatchWorld.consumeDirtyState().logs).toEqual([]);
|
||||
|
||||
const legacyLateWorld = new InMemoryTurnWorld(
|
||||
{ ...state, meta: { ...state.meta } },
|
||||
{ ...snapshot, generals: [buildGeneral(1)] as any },
|
||||
{ schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] } }
|
||||
);
|
||||
const legacyLateHandler = createTurnDaemonCommandHandler({ world: legacyLateWorld });
|
||||
const { acceptedGameTick: _acceptedGameTick, ...legacyLateCommand } = command;
|
||||
const legacyLateResult = await legacyLateHandler.handle(legacyLateCommand, {
|
||||
db: {
|
||||
$queryRaw: async (query: { strings: readonly string[] }) =>
|
||||
query.strings.join(' ').includes('SELECT options')
|
||||
? [
|
||||
{
|
||||
options: ['찬성'],
|
||||
multipleOptions: 1,
|
||||
endAt: null,
|
||||
endTick: 0n,
|
||||
closedAt: null,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
} as any,
|
||||
});
|
||||
expect(legacyLateResult).toMatchObject({
|
||||
type: 'voteReward',
|
||||
ok: false,
|
||||
reason: '설문조사가 종료되었습니다.',
|
||||
});
|
||||
});
|
||||
|
||||
it('treats an active unique auction as occupied when revalidating the lottery', async () => {
|
||||
@@ -245,6 +412,7 @@ describe('voteReward command', () => {
|
||||
iconPath: '',
|
||||
map: {},
|
||||
const: {
|
||||
develCost: 100,
|
||||
allItems: { weapon: { che_무기_12_칠성검: 1 } },
|
||||
maxUniqueItemLimit: [[-1, 1]],
|
||||
uniqueTrialCoef: 10,
|
||||
@@ -278,6 +446,18 @@ describe('voteReward command', () => {
|
||||
auction: {
|
||||
findMany: async () => [{ targetCode: 'che_무기_12_칠성검' }],
|
||||
},
|
||||
$queryRaw: async (query: { strings: readonly string[] }) =>
|
||||
query.strings.join(' ').includes('SELECT options')
|
||||
? [
|
||||
{
|
||||
options: ['찬성'],
|
||||
multipleOptions: 1,
|
||||
endAt: null,
|
||||
endTick: 0n,
|
||||
closedAt: null,
|
||||
},
|
||||
]
|
||||
: [{ id: 12 }],
|
||||
};
|
||||
|
||||
const result = await handler.handle(
|
||||
@@ -285,8 +465,7 @@ describe('voteReward command', () => {
|
||||
type: 'voteReward',
|
||||
voteId: 1,
|
||||
generalId: 1,
|
||||
goldReward: 500,
|
||||
unique: { expected: false, itemKey: null },
|
||||
selection: [0],
|
||||
},
|
||||
{ db: commandDb as any }
|
||||
);
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { City, Nation } from '@sammo-ts/logic';
|
||||
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import { createDynastyStatisticsHandler, queueYearbookSnapshot } from '../src/turn/yearbookHandler.js';
|
||||
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
|
||||
const turnTime = new Date('0200-01-01T00:00:00.000Z');
|
||||
|
||||
const buildGeneral = (id: number, nationId: number): TurnGeneral => ({
|
||||
id,
|
||||
name: `장수${id}`,
|
||||
nationId,
|
||||
cityId: nationId,
|
||||
troopId: 0,
|
||||
stats: { leadership: 80, strength: 70, intelligence: 60 },
|
||||
experience: 1_000,
|
||||
dedication: 900,
|
||||
officerLevel: 1,
|
||||
role: {
|
||||
personality: null,
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
items: { horse: null, weapon: null, book: null, item: null },
|
||||
},
|
||||
injury: 0,
|
||||
gold: 2_000,
|
||||
rice: 2_000,
|
||||
crew: 0,
|
||||
crewTypeId: 0,
|
||||
train: 0,
|
||||
atmos: 0,
|
||||
age: 30,
|
||||
npcState: nationId === 0 ? 2 : 0,
|
||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
meta: { killturn: 24 },
|
||||
turnTime,
|
||||
});
|
||||
|
||||
const buildCity = (id: number, nationId: number): City => ({
|
||||
id,
|
||||
name: `도시${id}`,
|
||||
nationId,
|
||||
level: 1,
|
||||
state: 0,
|
||||
population: 10_000,
|
||||
populationMax: 20_000,
|
||||
agriculture: 1_000,
|
||||
agricultureMax: 2_000,
|
||||
commerce: 1_000,
|
||||
commerceMax: 2_000,
|
||||
security: 1_000,
|
||||
securityMax: 2_000,
|
||||
supplyState: 1,
|
||||
frontState: 0,
|
||||
defence: 1_000,
|
||||
defenceMax: 2_000,
|
||||
wall: 1_000,
|
||||
wallMax: 2_000,
|
||||
meta: {},
|
||||
});
|
||||
|
||||
const buildNation = (id: number, power: number, meta: Nation['meta']): Nation => ({
|
||||
id,
|
||||
name: id === 0 ? '재야' : `국가${id}`,
|
||||
color: '#777777',
|
||||
capitalCityId: id === 0 ? null : id,
|
||||
chiefGeneralId: null,
|
||||
gold: 10_000,
|
||||
rice: 20_000,
|
||||
power,
|
||||
level: id === 0 ? 0 : 1,
|
||||
typeCode: 'che_중립',
|
||||
meta,
|
||||
});
|
||||
|
||||
type YearbookNationProjection = {
|
||||
id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
level: number;
|
||||
power: number;
|
||||
generalCount: number;
|
||||
};
|
||||
|
||||
describe('yearbook nation projection', () => {
|
||||
it('archives stored nation power/count, preserves zero, and fixes the synthetic neutral values', async () => {
|
||||
const state: TurnWorldState = {
|
||||
id: 1,
|
||||
currentYear: 200,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
lastTurnTime: turnTime,
|
||||
meta: { serverId: 'yearbook-projection-test' },
|
||||
};
|
||||
const snapshot: TurnWorldSnapshot = {
|
||||
scenarioConfig: {
|
||||
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
|
||||
iconPath: '',
|
||||
map: {},
|
||||
const: {},
|
||||
environment: { mapName: 'test', unitSet: 'test' },
|
||||
},
|
||||
scenarioMeta: {
|
||||
title: '연감 테스트',
|
||||
startYear: 200,
|
||||
life: null,
|
||||
fiction: 0,
|
||||
history: [],
|
||||
ignoreDefaultEvents: false,
|
||||
},
|
||||
map: { id: 'test', name: 'test', cities: [] },
|
||||
nations: [
|
||||
{
|
||||
...buildNation(0, 90, { gennum: 90, tech: 90 }),
|
||||
name: '오염된 재야',
|
||||
color: '#ffffff',
|
||||
level: 9,
|
||||
},
|
||||
buildNation(1, 777, { gennum: 9, tech: 100 }),
|
||||
buildNation(2, 0, { tech: 100 }),
|
||||
],
|
||||
cities: [buildCity(0, 0), buildCity(1, 1), buildCity(2, 2)],
|
||||
generals: [
|
||||
buildGeneral(1, 0),
|
||||
buildGeneral(2, 0),
|
||||
buildGeneral(3, 1),
|
||||
buildGeneral(4, 2),
|
||||
buildGeneral(5, 2),
|
||||
],
|
||||
troops: [],
|
||||
diplomacy: [],
|
||||
events: [],
|
||||
initialEvents: [],
|
||||
};
|
||||
const world = new InMemoryTurnWorld(state, snapshot, {
|
||||
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
|
||||
});
|
||||
|
||||
queueYearbookSnapshot(world, 'che', 200, 1);
|
||||
|
||||
const pending = world.peekDirtyState().pendingYearbookSnapshots[0];
|
||||
if (!pending) {
|
||||
throw new Error('expected a queued yearbook snapshot');
|
||||
}
|
||||
const nations = pending.nations as YearbookNationProjection[];
|
||||
expect(nations.map((nation) => nation.id)).toEqual([1, 0, 2]);
|
||||
expect(nations).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 0,
|
||||
name: '재야',
|
||||
color: '#000000',
|
||||
level: 0,
|
||||
power: 1,
|
||||
generalCount: 1,
|
||||
}),
|
||||
expect.objectContaining({ id: 1, power: 777, generalCount: 9 }),
|
||||
expect.objectContaining({ id: 2, power: 0, generalCount: 2 }),
|
||||
])
|
||||
);
|
||||
|
||||
// The contamination above exists only to exercise the archive
|
||||
// projection. Runtime nation zero is normally level zero.
|
||||
world.updateNation(0, { level: 0 });
|
||||
|
||||
const dynastyHandler = createDynastyStatisticsHandler({ getWorld: () => world }).handler;
|
||||
await dynastyHandler.onMonthChanged?.({
|
||||
previousYear: 200,
|
||||
previousMonth: 1,
|
||||
currentYear: 200,
|
||||
currentMonth: 2,
|
||||
turnTime,
|
||||
});
|
||||
expect(world.getState().meta.dynastyStatistics).toBeUndefined();
|
||||
|
||||
await dynastyHandler.onMonthChanged?.({
|
||||
previousYear: 200,
|
||||
previousMonth: 12,
|
||||
currentYear: 201,
|
||||
currentMonth: 1,
|
||||
turnTime,
|
||||
});
|
||||
expect(world.getState().meta.dynastyStatistics).toMatchObject({
|
||||
maxNationCount: 2,
|
||||
maxGeneralCount: 5,
|
||||
currentGeneralCount: 5,
|
||||
userGeneralCount: 3,
|
||||
npcGeneralCount: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user