fix: 유산 유니크 경매 기본 풀과 제한을 복구

Ref 기본 유니크 풀을 상태 조회·개설·마감에서 공유하고 API와 데몬 양쪽에서 입찰 부위 제한을 재검증한다. 실제 Chromium 및 개설·경쟁 입찰·마감 통합 회귀를 추가한다.
This commit is contained in:
2026-08-20 15:54:20 +00:00
parent 1431995a41
commit db3b8e8f08
10 changed files with 456 additions and 38 deletions
@@ -87,6 +87,12 @@ const statusFixture = {
rawName: '칠성검',
info: '무력을 올려주는 유니크 무기입니다.',
},
{
key: 'che_서적_07_논어',
name: '논어(+7)',
rawName: '논어',
info: '지력을 올려주는 유니크 서적입니다.',
},
],
availableTargetGenerals: [{ id: 8, name: '조조' }],
turnTimeZones: ['00:00'],
@@ -98,6 +104,7 @@ const statusFixture = {
const installFixture = async (page: Page, options: { failBuff?: boolean } = {}) => {
let buffMutationCount = 0;
let resetTurnMutationCount = 0;
const uniqueAuctionRequests: unknown[] = [];
await installImages(page);
await page.addInitScript(() => {
window.localStorage.setItem('sammo-game-token', 'ga_inherit-visual-token');
@@ -105,6 +112,7 @@ const installFixture = async (page: Page, options: { failBuff?: boolean } = {})
});
await page.route('**/che/api/trpc/**', async (route) => {
const names = operations(route);
const requestBody: unknown = route.request().postData() ? route.request().postDataJSON() : null;
if (options.failBuff && names.includes('inherit.buyHiddenBuff')) {
await route.fulfill({
status: 500,
@@ -145,6 +153,10 @@ const installFixture = async (page: Page, options: { failBuff?: boolean } = {})
resetTurnMutationCount += 1;
return response({ ok: true, nextTurnTimeBase: 302.5143852464758, nextTurnTimeLabel: '00:05' });
}
if (name === 'inherit.openUniqueAuction') {
uniqueAuctionRequests.push(requestBody);
return response({ ok: true, auctionId: 31, closeAt: '2026-07-27T00:00:00.000Z' });
}
throw new Error(`Unhandled inheritance fixture operation: ${name}`);
});
await route.fulfill({
@@ -156,6 +168,7 @@ const installFixture = async (page: Page, options: { failBuff?: boolean } = {})
return {
buffMutationCount: () => buffMutationCount,
resetTurnMutationCount: () => resetTurnMutationCount,
uniqueAuctionRequests,
};
};
@@ -290,6 +303,28 @@ test.describe('inheritance management legacy parity', () => {
await expect(page.locator('#inherit_previous_value')).toHaveValue('12,000');
});
test('selects a Ref default unique and starts its auction from the inheritance page', async ({ page }) => {
const fixture = await installFixture(page);
await page.goto(gameUrl);
await page.locator('#specific-unique').selectOption('che_서적_07_논어');
await page.locator('#specific-unique-amount').fill('6000');
page.once('dialog', async (dialog) => {
expect(dialog.message()).toBe('6000 포인트로 논어(+7)를 입찰하겠습니까?');
await dialog.accept();
});
await page
.locator('.shop-item')
.filter({ has: page.locator('#specific-unique') })
.getByRole('button', { name: '경매 시작' })
.click();
await expect.poll(() => fixture.uniqueAuctionRequests.length).toBe(1);
expect(JSON.stringify(fixture.uniqueAuctionRequests[0])).toContain('che_서적_07_논어');
expect(JSON.stringify(fixture.uniqueAuctionRequests[0])).toContain('6000');
await expect(page.locator('.notice.success')).toHaveText('성공했습니다. 경매장을 확인해주세요.');
});
test('keeps controls usable and renders an API mutation error', async ({ page }) => {
await installFixture(page, { failBuff: true });
page.on('dialog', (dialog) => dialog.accept());
+220 -13
View File
@@ -26,15 +26,8 @@ import {
resolveRedisConfigFromEnv,
GamePrisma,
} from '@sammo-ts/infra';
import {
buildNeutralResourceAuctionPlan,
ItemLoader,
ITEM_KEYS,
} from '@sammo-ts/logic';
import {
createItemInventoryFromSlots,
serializeItemInventory,
} from '@sammo-ts/logic/items/inventory.js';
import { buildNeutralResourceAuctionPlan, ItemLoader, ITEM_KEYS } from '@sammo-ts/logic';
import { createItemInventoryFromSlots, serializeItemInventory } from '@sammo-ts/logic/items/inventory.js';
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const workspaceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
@@ -301,6 +294,7 @@ describe('auction integration flow', () => {
databaseUrl: resolvePostgresConfigFromEnv({ schema: 'che' }).url,
adminUser: bootstrap.user,
installOptions: {
serverId: 'auction-integration-908',
turnTermMinutes: 1,
sync: false,
fiction: 0,
@@ -676,6 +670,69 @@ describe('auction integration flow', () => {
turnDaemonLoop = turnDaemon.lifecycle.start();
await sleep(500);
const directTransport = new DatabaseTurnDaemonTransport(prisma, 30_000);
await expect(
directTransport.requestCommand(
{
type: 'auctionBid',
auctionId: auction.id,
generalId: validBidder.generalId,
amount: 400,
},
30_000
)
).resolves.toMatchObject({
type: 'auctionBid',
ok: false,
reason: '이미 다른 유니크를 가지고 있습니다.',
});
const siblingCloseAt = new Date(turnDaemon.world.getGameNow(new Date()).getTime() + 10 * 60_000);
const siblingAuction = await prisma.auction.create({
data: {
type: 'UNIQUE_ITEM',
targetCode: uniquePair.keyB,
hostGeneralId: 0,
hostName: '시스템',
detail: {
startBidAmount: 200,
isReverse: false,
availableLatestBidCloseDate: siblingCloseAt.toISOString(),
},
status: 'OPEN',
closeAt: siblingCloseAt,
closeTick: BigInt(turnDaemon.world.dateToGameTick(siblingCloseAt)),
},
});
await prisma.auctionBid.create({
data: {
auctionId: siblingAuction.id,
generalId: spareBidder.generalId,
amount: 350,
eventId: `same-slot-race-${siblingAuction.id}`,
eventAt: turnDaemon.world.getGameNow(new Date()),
},
});
await expect(
directTransport.requestCommand(
{
type: 'auctionBid',
auctionId: auction.id,
generalId: spareBidder.generalId,
amount: 400,
},
30_000
)
).resolves.toMatchObject({
type: 'auctionBid',
ok: false,
reason: '1순위 입찰자인 경매중에 같은 부위가 있습니다.',
});
await prisma.auction.update({
where: { id: siblingAuction.id },
data: { status: 'CANCELED', finishedAt: new Date() },
});
const finalizeAt = new Date(turnDaemon!.world.getGameNow(new Date()).getTime() - 1000);
const finalizeTick = turnDaemon!.world.dateToGameTick(finalizeAt);
await prisma.auction.update({
@@ -684,7 +741,6 @@ describe('auction integration flow', () => {
});
await redis.zAdd(keys.timerKey, [{ score: finalizeTick, value: String(auction.id) }]);
const transport = new DatabaseTurnDaemonTransport(prisma, 30_000);
await prisma.$executeRaw(
GamePrisma.sql`
UPDATE auction
@@ -694,15 +750,16 @@ describe('auction integration flow', () => {
WHERE id = ${auction.id}
`
);
const result = await transport.requestCommand({ type: 'auctionFinalize', auctionId: auction.id }, 30_000);
const result = await directTransport.requestCommand({ type: 'auctionFinalize', auctionId: auction.id }, 30_000);
expect(result).toMatchObject({ type: 'auctionFinalize', ok: false });
const reopened = await prisma.auction.findUnique({
where: { id: auction.id },
select: { status: true, closeAt: true },
});
expect(reopened?.status).toBe('OPEN');
expect(reopened?.closeAt.getTime()).toBeGreaterThan(finalizeAt.getTime());
expect(reopened).not.toBeNull();
expect(reopened!.status).toBe('OPEN');
expect(reopened!.closeAt.getTime() - finalizeAt.getTime()).toBe(24 * 60_000);
}, 60_000);
it('unique auction: two bidders extend until limit, then winner gets item', async () => {
@@ -1013,4 +1070,154 @@ describe('auction integration flow', () => {
meta: expect.objectContaining({ neutralAuctionRegistrationKey: '180-02' }),
});
}, 60_000);
it('starts from inheritance, accepts another user bid, and awards a default-pool unique', async () => {
if (!gameConnector || !redisConnector || !gameServer) {
throw new Error('runtime not ready');
}
const prisma = gameConnector.prisma;
const redis = redisConnector.client;
const [host, bidder] = userSessions;
if (!host || !bidder) {
throw new Error('not enough bidders');
}
if (turnDaemon) {
await turnDaemon.lifecycle.stop('integration-test');
await turnDaemon.close();
await turnDaemonLoop;
}
const uniquePair = await findUniqueItemPair();
const slotField = resolveSlotField(uniquePair.slot);
if (!slotField) {
throw new Error('unsupported item slot');
}
const state = await prisma.worldState.findFirstOrThrow();
const config =
state.config && typeof state.config === 'object' && !Array.isArray(state.config) ? state.config : {};
const configConst =
config.const && typeof config.const === 'object' && !Array.isArray(config.const) ? config.const : {};
await prisma.worldState.update({
where: { id: state.id },
data: {
currentYear: 180,
currentMonth: 4,
config: {
...config,
const: {
...configConst,
// 표준 Ref 시나리오는 GameConst 기본값을 상속한다. 오래된
// Core snapshot의 문자열 빈 객체도 같은 입력으로 검증한다.
allItems: '{}',
},
},
},
});
await prisma.auction.updateMany({
where: { type: 'UNIQUE_ITEM', status: { in: ['OPEN', 'FINALIZING'] } },
data: { status: 'CANCELED', finishedAt: new Date() },
});
for (const session of [host, bidder]) {
const row = await prisma.general.findUniqueOrThrow({
where: { id: session.generalId },
select: { meta: true },
});
const meta = row.meta && typeof row.meta === 'object' && !Array.isArray(row.meta) ? row.meta : {};
await prisma.general.update({
where: { id: session.generalId },
data: {
weaponCode: 'None',
bookCode: 'None',
horseCode: 'None',
itemCode: 'None',
meta: {
...meta,
itemInventory: serializeItemInventory(
createItemInventoryFromSlots({ horse: null, weapon: null, book: null, item: null })
),
},
},
});
await prisma.inheritancePoint.upsert({
where: { userId_key: { userId: session.userId, key: 'previous' } },
update: { value: 100_000 },
create: { userId: session.userId, key: 'previous', value: 100_000 },
});
}
await prisma.general.updateMany({
where: { [slotField]: uniquePair.keyA } as GamePrisma.GeneralWhereInput,
data: { [slotField]: 'None' } as GamePrisma.GeneralUpdateManyMutationInput,
});
const gameDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'che' }).url;
const gatewayDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url;
turnDaemon = await createTurnDaemonRuntime({
profile: 'che',
profileName: 'che:908',
databaseUrl: gameDatabaseUrl,
gatewayDatabaseUrl,
});
turnDaemonLoop = turnDaemon.lifecycle.start();
await sleep(500);
const hostClient = createGameClient(gameUrl, gameServer.config.trpcPath, { value: host.accessToken });
const bidderClient = createGameClient(gameUrl, gameServer.config.trpcPath, { value: bidder.accessToken });
const inheritStatus = await hostClient.inherit.getStatus.query();
expect(inheritStatus.availableUnique.length).toBeGreaterThan(80);
expect(inheritStatus.availableUnique).toEqual(
expect.arrayContaining([expect.objectContaining({ key: uniquePair.keyA })])
);
const opened = await hostClient.inherit.openUniqueAuction.mutate({
itemId: uniquePair.keyA,
amount: 5_000,
});
const auction = await prisma.auction.findUniqueOrThrow({ where: { id: opened.auctionId } });
expect(auction).toMatchObject({
type: 'UNIQUE_ITEM',
targetCode: uniquePair.keyA,
hostGeneralId: host.generalId,
status: 'OPEN',
});
const timerKeys = buildAuctionTimerKeys(gameServer.config.profileName);
await expect(redis.zScore(timerKeys.timerKey, String(auction.id))).resolves.not.toBeNull();
await bidderClient.auction.bidUnique.mutate({ auctionId: auction.id, amount: 5_100 });
await expect(
prisma.inheritancePoint.findUniqueOrThrow({
where: { userId_key: { userId: host.userId, key: 'previous' } },
})
).resolves.toMatchObject({ value: 100_000 });
const finalizeAt = new Date(turnDaemon.world.getGameNow(new Date()).getTime() - 1_000);
const finalizeTick = turnDaemon.world.dateToGameTick(finalizeAt);
await prisma.auction.update({
where: { id: auction.id },
data: {
closeAt: finalizeAt,
closeTick: BigInt(finalizeTick),
status: 'FINALIZING',
finalizingAt: new Date(),
},
});
const transport = new DatabaseTurnDaemonTransport(prisma, 30_000);
const result = await transport.requestCommand({ type: 'auctionFinalize', auctionId: auction.id }, 30_000);
expect(result).toMatchObject({ type: 'auctionFinalize', ok: true });
await expect(prisma.auction.findUniqueOrThrow({ where: { id: auction.id } })).resolves.toMatchObject({
status: 'FINISHED',
});
const winner = await prisma.general.findUniqueOrThrow({
where: { id: bidder.generalId },
select: { weaponCode: true, bookCode: true, horseCode: true, itemCode: true },
});
expect(Object.values(winner)).toContain(uniquePair.keyA);
await expect(
prisma.inheritancePoint.findUniqueOrThrow({
where: { userId_key: { userId: bidder.userId, key: 'previous' } },
})
).resolves.toMatchObject({ value: 94_900 });
}, 60_000);
});