fix: Ref 게임 로직과 시나리오 풀 호환을 보정
월 경계, 전투 기술 상한, 연감과 베팅·설문·경매 정산 순서를 Ref 계약에 맞춘다.\n\n시나리오 일반 풀을 ENGINE mutation과 logical tick 기반으로 직렬화하고 914·915 catalog 및 조건부 100기 pool 실행 경계를 추가한다.\n\n경매 worker는 세대별 durable event만 만들고 ENGINE이 row lock 후 상태 전이와 정산을 단일 transaction으로 소유한다.
This commit is contained in:
@@ -0,0 +1,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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user