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:
2026-08-23 16:26:14 +00:00
parent bf6b7be7b0
commit 85591c68ad
114 changed files with 13327 additions and 901 deletions
@@ -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',
+45
View File
@@ -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],
},
});
}
});
});
+102 -1
View File
@@ -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 },
+191 -12
View File
@@ -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,
});
});
});