fix: 유산 유니크 경매 기본 풀과 제한을 복구
Ref 기본 유니크 풀을 상태 조회·개설·마감에서 공유하고 API와 데몬 양쪽에서 입찰 부위 제한을 재검증한다. 실제 Chromium 및 개설·경쟁 입찰·마감 통합 회귀를 추가한다.
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
||||
} from '@sammo-ts/logic';
|
||||
import type { InheritBuffType } from '@sammo-ts/logic';
|
||||
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
|
||||
import { resolveLegacyCompatibleUniqueConfig } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
|
||||
import {
|
||||
appendInheritanceLog,
|
||||
buildResetCost,
|
||||
@@ -74,17 +75,17 @@ const readBuffLevel = (buff: Record<string, number>, key: InheritBuffType): numb
|
||||
};
|
||||
|
||||
const loadAvailableUniqueItems = async (worldState: WorldStateRow) => {
|
||||
const configuredItems = asRecord(asRecord(worldState.config).const).allItems;
|
||||
const configConst = asRecord(asRecord(worldState.config).const);
|
||||
const loader = new ItemLoader();
|
||||
const { allItems } = await resolveLegacyCompatibleUniqueConfig(configConst, loader);
|
||||
const enabledKeys: Array<Parameters<ItemLoader['load']>[0]> = [];
|
||||
for (const entries of Object.values(asRecord(configuredItems))) {
|
||||
for (const entries of Object.values(allItems)) {
|
||||
for (const [key, amount] of Object.entries(asRecord(entries))) {
|
||||
if (asNumber(amount, 0) !== 0 && isItemKey(key)) {
|
||||
enabledKeys.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const loader = new ItemLoader();
|
||||
const items = await Promise.all(
|
||||
[...new Set(enabledKeys)].map(async (key) => {
|
||||
const item = await loader.load(key);
|
||||
|
||||
@@ -97,6 +97,7 @@ const buildContext = (options: {
|
||||
target?: GeneralRow | null;
|
||||
inheritancePoint?: number;
|
||||
inheritanceLogs?: Array<{ id: number; year: number; month: number; text: string; createdAt: Date }>;
|
||||
configConst?: Record<string, unknown>;
|
||||
}) => {
|
||||
const auth = options.auth === undefined ? buildAuth() : options.auth;
|
||||
const general = options.general === undefined ? buildGeneral() : options.general;
|
||||
@@ -113,10 +114,19 @@ const buildContext = (options: {
|
||||
const logCreate = vi.fn(async () => ({}));
|
||||
const findMany = vi.fn(async () => (target ? [{ id: target.id, name: target.name }] : []));
|
||||
const inheritanceLogFindMany = vi.fn(async () => options.inheritanceLogs ?? []);
|
||||
const activeWorldState =
|
||||
options.configConst === undefined
|
||||
? worldState
|
||||
: {
|
||||
...worldState,
|
||||
config: {
|
||||
const: options.configConst,
|
||||
},
|
||||
};
|
||||
const db = {
|
||||
$queryRaw: vi.fn(async () => [{ value: options.inheritancePoint ?? 10_000 }]),
|
||||
worldState: {
|
||||
findFirst: vi.fn(async () => worldState),
|
||||
findFirst: vi.fn(async () => activeWorldState),
|
||||
},
|
||||
general: {
|
||||
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) =>
|
||||
@@ -192,6 +202,22 @@ describe('inherit router actor and permission boundaries', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([{}, { allItems: '{}' }])(
|
||||
'restores selectable Ref default uniques for a legacy scenario config: %j',
|
||||
async (configConst) => {
|
||||
const fixture = buildContext({ configConst });
|
||||
const status = await appRouter.createCaller(fixture.context).inherit.getStatus();
|
||||
|
||||
expect(status.availableUnique.length).toBeGreaterThan(80);
|
||||
expect(status.availableUnique).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ key: 'che_무기_12_칠성검', rawName: '칠성검' }),
|
||||
expect.objectContaining({ key: 'che_서적_07_논어', rawName: '논어' }),
|
||||
])
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('loads the first inheritance-log page without an out-of-range integer cursor', async () => {
|
||||
const createdAt = new Date('2026-07-26T00:00:00Z');
|
||||
const fixture = buildContext({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { createGamePostgresConnector, GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
import { isItemKey, ItemLoader } from '@sammo-ts/logic';
|
||||
|
||||
import type { TurnDaemonCommand, TurnDaemonCommandResult } from '../lifecycle/types.js';
|
||||
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js';
|
||||
@@ -23,6 +24,7 @@ const MIN_EXTENSION_MINUTES_PER_BID = 1;
|
||||
interface AuctionRow {
|
||||
id: number;
|
||||
type: AuctionType;
|
||||
targetCode: string | null;
|
||||
hostGeneralId: number;
|
||||
detail: unknown;
|
||||
status: AuctionStatus;
|
||||
@@ -99,6 +101,7 @@ const loadAuction = async (prisma: QueryClient, auctionId: number): Promise<Auct
|
||||
GamePrisma.sql`
|
||||
SELECT id,
|
||||
type,
|
||||
target_code as "targetCode",
|
||||
host_general_id as "hostGeneralId",
|
||||
detail,
|
||||
status,
|
||||
@@ -178,6 +181,7 @@ export const createAuctionBidder = async (options: {
|
||||
await connector.connect();
|
||||
const prisma = connector.prisma;
|
||||
const world = options.world;
|
||||
const itemLoader = new ItemLoader();
|
||||
|
||||
return {
|
||||
bid: async (command, commandDb): Promise<TurnDaemonCommandResult> => {
|
||||
@@ -290,6 +294,74 @@ export const createAuctionBidder = async (options: {
|
||||
reason: '장수 정보를 찾을 수 없습니다.',
|
||||
};
|
||||
}
|
||||
if (auction.type === 'UNIQUE_ITEM') {
|
||||
const itemKey = auction.targetCode;
|
||||
if (!itemKey || !isItemKey(itemKey)) {
|
||||
return {
|
||||
type: 'auctionBid',
|
||||
ok: false,
|
||||
auctionId: command.auctionId,
|
||||
reason: '아이템이 올바르지 않습니다.',
|
||||
};
|
||||
}
|
||||
const item = await itemLoader.load(itemKey).catch(() => null);
|
||||
if (!item || item.buyable) {
|
||||
return {
|
||||
type: 'auctionBid',
|
||||
ok: false,
|
||||
auctionId: command.auctionId,
|
||||
reason: item ? '구매할 수 있는 아이템입니다.' : '아이템 정보를 불러올 수 없습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
const currentSlotItem = general.role.items[item.slot];
|
||||
if (currentSlotItem && currentSlotItem !== 'None' && isItemKey(currentSlotItem)) {
|
||||
const currentItem = await itemLoader.load(currentSlotItem).catch(() => null);
|
||||
if (currentItem && !currentItem.buyable) {
|
||||
return {
|
||||
type: 'auctionBid',
|
||||
ok: false,
|
||||
auctionId: command.auctionId,
|
||||
reason:
|
||||
currentSlotItem === itemKey
|
||||
? '이미 그 유니크를 가지고 있습니다.'
|
||||
: '이미 다른 유니크를 가지고 있습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const otherHighestBids = await db.$queryRaw<Array<{ auctionId: number; targetCode: string | null }>>(
|
||||
GamePrisma.sql`
|
||||
SELECT candidate.id as "auctionId", candidate.target_code as "targetCode"
|
||||
FROM auction candidate
|
||||
INNER JOIN LATERAL (
|
||||
SELECT bid.general_id
|
||||
FROM auction_bid bid
|
||||
WHERE bid.auction_id = candidate.id
|
||||
ORDER BY bid.amount DESC, bid.id ASC
|
||||
LIMIT 1
|
||||
) highest ON true
|
||||
WHERE candidate.type = 'UNIQUE_ITEM'
|
||||
AND candidate.status IN ('OPEN', 'FINALIZING')
|
||||
AND candidate.id <> ${auction.id}
|
||||
AND highest.general_id = ${command.generalId}
|
||||
`
|
||||
);
|
||||
for (const other of otherHighestBids) {
|
||||
if (!other.targetCode || !isItemKey(other.targetCode)) {
|
||||
continue;
|
||||
}
|
||||
const otherItem = await itemLoader.load(other.targetCode).catch(() => null);
|
||||
if (otherItem?.slot === item.slot) {
|
||||
return {
|
||||
type: 'auctionBid',
|
||||
ok: false,
|
||||
auctionId: command.auctionId,
|
||||
reason: '1순위 입찰자인 경매중에 같은 부위가 있습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
if (auction.type !== 'UNIQUE_ITEM' && auction.hostGeneralId === general.id) {
|
||||
return {
|
||||
type: 'auctionBid',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createGamePostgresConnector, GamePrisma } from '@sammo-ts/infra';
|
||||
import { ActionLogger, ItemLoader, LogFormat, UserLogger, isItemKey, resolveUniqueConfig } from '@sammo-ts/logic';
|
||||
import { ActionLogger, ItemLoader, LogFormat, UserLogger, isItemKey } from '@sammo-ts/logic';
|
||||
import { resolveLegacyCompatibleUniqueConfig } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
|
||||
import { cloneItemInventory, ensureItemInventory, equipNewItem } from '@sammo-ts/logic/items/index.js';
|
||||
import { asRecord, JosaUtil } from '@sammo-ts/common';
|
||||
|
||||
@@ -18,6 +19,8 @@ type AuctionStatus = 'OPEN' | 'FINALIZING' | 'FINISHED' | 'CANCELED';
|
||||
const COEFF_EXTENSION_MINUTES_PER_BID = 1 / 6;
|
||||
const MIN_EXTENSION_MINUTES_PER_BID = 1;
|
||||
const MIN_EXTENSION_MINUTES_LIMIT_BY_BID = 5;
|
||||
const COEFF_EXTENSION_MINUTES_LIMIT_UNIQUE_COUNT = 24;
|
||||
const MIN_EXTENSION_MINUTES_BY_EXTENSION_QUERY = 5;
|
||||
|
||||
interface AuctionRow {
|
||||
id: number;
|
||||
@@ -363,7 +366,10 @@ export const createAuctionFinalizer = async (options: {
|
||||
}
|
||||
|
||||
const state = world.getState();
|
||||
const config = resolveUniqueConfig(asRecord(world.getScenarioConfig().const));
|
||||
const config = await resolveLegacyCompatibleUniqueConfig(
|
||||
asRecord(world.getScenarioConfig().const),
|
||||
itemLoader
|
||||
);
|
||||
const scenarioMeta = asRecord(state.meta.scenarioMeta);
|
||||
const startYear =
|
||||
typeof scenarioMeta.startYear === 'number' && Number.isFinite(scenarioMeta.startYear)
|
||||
@@ -392,7 +398,11 @@ export const createAuctionFinalizer = async (options: {
|
||||
const turnMinutes = await resolveTurnMinutes(db);
|
||||
const nextCloseAt = new Date(
|
||||
auction.closeAt.getTime() +
|
||||
Math.max(MIN_EXTENSION_MINUTES_LIMIT_BY_BID, turnMinutes * 0.5) * 60_000
|
||||
Math.max(
|
||||
MIN_EXTENSION_MINUTES_BY_EXTENSION_QUERY,
|
||||
turnMinutes * COEFF_EXTENSION_MINUTES_LIMIT_UNIQUE_COUNT
|
||||
) *
|
||||
60_000
|
||||
);
|
||||
const nextLatestBidCloseAt = new Date(
|
||||
nextCloseAt.getTime() +
|
||||
|
||||
@@ -2,14 +2,8 @@ import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { asRecord, JosaUtil } from '@sammo-ts/common';
|
||||
import { acquireGameSchemaAdvisoryXactLock, GamePrisma } from '@sammo-ts/infra';
|
||||
import {
|
||||
ActionLogger,
|
||||
ItemLoader,
|
||||
LogFormat,
|
||||
buildAuctionAlias,
|
||||
isItemKey,
|
||||
resolveUniqueConfig,
|
||||
} from '@sammo-ts/logic';
|
||||
import { ActionLogger, ItemLoader, LogFormat, buildAuctionAlias, isItemKey } from '@sammo-ts/logic';
|
||||
import { resolveLegacyCompatibleUniqueConfig } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
|
||||
|
||||
import type { TurnDaemonCommand, TurnDaemonCommandResult } from '../lifecycle/types.js';
|
||||
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js';
|
||||
@@ -147,7 +141,8 @@ const openUniqueAuction = async (
|
||||
return fail(`최소 경매 금액은 ${minimumPoint}입니다.`);
|
||||
}
|
||||
|
||||
const item = await new ItemLoader().load(itemKey).catch(() => null);
|
||||
const itemLoader = new ItemLoader();
|
||||
const item = await itemLoader.load(itemKey).catch(() => null);
|
||||
if (!item) {
|
||||
return fail('아이템 정보를 불러올 수 없습니다.');
|
||||
}
|
||||
@@ -156,7 +151,7 @@ const openUniqueAuction = async (
|
||||
}
|
||||
const currentSlotItem = general.role.items[item.slot];
|
||||
if (currentSlotItem && currentSlotItem !== 'None' && isItemKey(currentSlotItem)) {
|
||||
const currentItem = await new ItemLoader().load(currentSlotItem).catch(() => null);
|
||||
const currentItem = await itemLoader.load(currentSlotItem).catch(() => null);
|
||||
if (currentItem && !currentItem.buyable) {
|
||||
return fail('이미 가진 아이템이 있습니다.');
|
||||
}
|
||||
@@ -189,7 +184,7 @@ const openUniqueAuction = async (
|
||||
return fail('아직 경매가 끝나지 않았습니다.');
|
||||
}
|
||||
|
||||
const uniqueConfig = resolveUniqueConfig(configConst);
|
||||
const uniqueConfig = await resolveLegacyCompatibleUniqueConfig(configConst, itemLoader);
|
||||
const configuredAmount = uniqueConfig.allItems[item.slot]?.[itemKey] ?? 0;
|
||||
const occupiedAmount = world
|
||||
.listGenerals()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { formatServerDateTime } from '@sammo-ts/common';
|
||||
import { formatServerDateTime, JosaUtil } from '@sammo-ts/common';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
@@ -379,7 +379,8 @@ const buyRandomUnique = async () => {
|
||||
};
|
||||
|
||||
const openUniqueAuction = async () => {
|
||||
if (!uniqueForm.itemId.trim()) {
|
||||
const selectedItem = status.value?.availableUnique.find((item) => item.key === uniqueForm.itemId.trim());
|
||||
if (!selectedItem) {
|
||||
actionError.value = '유니크를 선택해주세요.';
|
||||
return;
|
||||
}
|
||||
@@ -388,15 +389,20 @@ const openUniqueAuction = async () => {
|
||||
actionError.value = '입찰 포인트를 입력해주세요.';
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(`유니크 경매를 ${amount} 포인트로 신청하시겠습니까?`)) {
|
||||
if (previousPoint.value < amount) {
|
||||
actionError.value = '유산 포인트가 부족합니다.';
|
||||
return;
|
||||
}
|
||||
const itemJosa = JosaUtil.pick(selectedItem.rawName, '을');
|
||||
if (!window.confirm(`${amount} 포인트로 ${selectedItem.name}${itemJosa} 입찰하겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
await runAction(async () => {
|
||||
await trpc.inherit.openUniqueAuction.mutate({
|
||||
itemId: uniqueForm.itemId.trim(),
|
||||
itemId: selectedItem.key,
|
||||
amount,
|
||||
});
|
||||
});
|
||||
}, '성공했습니다. 경매장을 확인해주세요.');
|
||||
};
|
||||
|
||||
const checkOwner = async () => {
|
||||
|
||||
Reference in New Issue
Block a user