장수 55종과 수뇌 35종의 상태, 로그, 메시지, 예약 턴 비교를 닫습니다. 실제 시나리오 프로필과 대표 PostgreSQL 수명주기, 즉시 외교와 출병 회귀를 추가하고 발견된 Ref 로그 및 생성 장수 저장 차이를 교정합니다.
447 lines
18 KiB
TypeScript
447 lines
18 KiB
TypeScript
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 in the original flush order 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.HISTORY,
|
|
format: LogFormat.YEAR_MONTH,
|
|
generalId: 7,
|
|
text: '<C>칠성검(+12)</>을 습득',
|
|
}),
|
|
expect.objectContaining({
|
|
scope: LogScope.GENERAL,
|
|
category: LogCategory.ACTION,
|
|
format: LogFormat.MONTH,
|
|
generalId: 7,
|
|
text: '<C>칠성검(+12)</>을 습득했습니다!',
|
|
}),
|
|
expect.objectContaining({
|
|
scope: LogScope.SYSTEM,
|
|
category: LogCategory.HISTORY,
|
|
format: LogFormat.YEAR_MONTH,
|
|
text: '<C><b>【보물수배】</b></><D><b>촉</b></>의 <Y>관우</>가 <C>칠성검(+12)</>을 습득했습니다!',
|
|
}),
|
|
expect.objectContaining({
|
|
scope: LogScope.SYSTEM,
|
|
category: LogCategory.SUMMARY,
|
|
format: LogFormat.MONTH,
|
|
text: '<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,
|
|
});
|
|
});
|
|
});
|