fix: 유산 유니크 경매 기본 풀과 제한을 복구
Ref 기본 유니크 풀을 상태 조회·개설·마감에서 공유하고 API와 데몬 양쪽에서 입찰 부위 제한을 재검증한다. 실제 Chromium 및 개설·경쟁 입찰·마감 통합 회귀를 추가한다.
This commit is contained in:
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user