feat: add legacy-compatible neutral auctions

This commit is contained in:
2026-07-25 11:07:37 +00:00
parent 93ae4df519
commit 06c7197a1a
15 changed files with 999 additions and 31 deletions
@@ -24,7 +24,7 @@ import {
resolveRedisConfigFromEnv,
GamePrisma,
} from '@sammo-ts/infra';
import { ItemLoader, ITEM_KEYS } from '@sammo-ts/logic';
import { buildNeutralResourceAuctionPlan, ItemLoader, ITEM_KEYS } from '@sammo-ts/logic';
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const workspaceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
@@ -453,9 +453,9 @@ describe('auction integration flow', () => {
const poorClient = createGameClient(gameUrl, gameServer.config.trpcPath, {
value: poorBidder.accessToken,
});
await expect(
poorClient.auction.bidBuyRice.mutate({ auctionId: auction.id, amount: 500 })
).rejects.toThrow('금이 부족합니다.');
await expect(poorClient.auction.bidBuyRice.mutate({ auctionId: auction.id, amount: 500 })).rejects.toThrow(
'금이 부족합니다.'
);
const updatedAuction = await prisma.auction.findUnique({
where: { id: auction.id },
@@ -576,9 +576,9 @@ describe('auction integration flow', () => {
const ownerClient = createGameClient(gameUrl, gameServer.config.trpcPath, {
value: ownerBidder.accessToken,
});
await expect(
ownerClient.auction.bidUnique.mutate({ auctionId: auction.id, amount: 300 })
).rejects.toThrow('이미 다른 유니크를 가지고 있습니다.');
await expect(ownerClient.auction.bidUnique.mutate({ auctionId: auction.id, amount: 300 })).rejects.toThrow(
'이미 다른 유니크를 가지고 있습니다.'
);
const validClient = createGameClient(gameUrl, gameServer.config.trpcPath, {
value: validBidder.accessToken,
@@ -782,4 +782,138 @@ describe('auction integration flow', () => {
expect(winner).not.toBeNull();
expect(Object.values(winner!)).toContain(uniquePair.keyA);
}, 60_000);
it('opens the same neutral merchant auctions on the legacy monthly boundary', async () => {
if (!gameConnector) {
throw new Error('runtime not ready');
}
const prisma = gameConnector.prisma;
if (turnDaemon) {
await turnDaemon.lifecycle.stop('integration-test');
await turnDaemon.close();
await turnDaemonLoop;
}
await prisma.auction.deleteMany({
where: {
hostGeneralId: 0,
type: { in: ['BUY_RICE', 'SELL_RICE'] },
},
});
const futureTurn = new Date(Date.now() + 60 * 60_000);
await prisma.general.updateMany({
where: { npcState: { lt: 2 } },
data: {
gold: 5_432,
rice: 7_654,
turnTime: futureTurn,
},
});
const nationCount = await prisma.nation.count();
let hiddenSeed = '';
let expected = [] as ReturnType<typeof buildNeutralResourceAuctionPlan>;
for (let index = 0; index < 1_000; index += 1) {
hiddenSeed = `integration-neutral-${index}`;
expected = buildNeutralResourceAuctionPlan({
hiddenSeed,
seedYear: 180,
seedMonth: 1,
nationCount,
consumeTournamentRoll: false,
averageGold: 5_432,
averageRice: 7_654,
buyRiceAuctionCount: 0,
sellRiceAuctionCount: 0,
});
if (expected.length > 0) {
break;
}
}
expect(expected.length).toBeGreaterThan(0);
const worldState = await prisma.worldState.findFirstOrThrow();
const worldMeta =
worldState.meta && typeof worldState.meta === 'object' && !Array.isArray(worldState.meta)
? worldState.meta
: {};
await prisma.worldState.update({
where: { id: worldState.id },
data: {
currentYear: 180,
currentMonth: 1,
tickSeconds: 60,
meta: {
...worldMeta,
hiddenSeed,
lastTurnTime: new Date(Date.now() - 61_000).toISOString(),
neutralAuctionRegistrationKey: null,
},
},
});
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();
const deadline = Date.now() + 10_000;
let rows = await prisma.auction.findMany({
where: {
hostGeneralId: 0,
type: { in: ['BUY_RICE', 'SELL_RICE'] },
},
orderBy: { id: 'asc' },
});
while (rows.length < expected.length && Date.now() < deadline) {
await sleep(100);
rows = await prisma.auction.findMany({
where: {
hostGeneralId: 0,
type: { in: ['BUY_RICE', 'SELL_RICE'] },
},
orderBy: { id: 'asc' },
});
}
expect(rows).toHaveLength(expected.length);
for (const [index, plan] of expected.entries()) {
const row = rows[index]!;
const detail = row.detail as Record<string, unknown>;
expect(row).toMatchObject({
type: plan.auctionType,
targetCode: String(plan.amount),
hostGeneralId: 0,
hostName: '상인',
status: 'OPEN',
});
expect(detail).toMatchObject({
amount: plan.amount,
startBidAmount: plan.startBidAmount,
finishBidAmount: plan.finishBidAmount,
closeTurnCnt: plan.closeTurnCnt,
seedYear: 180,
seedMonth: 1,
neutralRegistrationKey: '180-02',
});
expect(row.closeAt.getTime() - row.createdAt.getTime()).toBeGreaterThanOrEqual(
plan.closeTurnCnt * 60_000 - 2_000
);
expect(row.closeAt.getTime() - row.createdAt.getTime()).toBeLessThanOrEqual(
plan.closeTurnCnt * 60_000 + 2_000
);
}
const persistedState = await prisma.worldState.findUniqueOrThrow({
where: { id: worldState.id },
});
expect(persistedState).toMatchObject({
currentYear: 180,
currentMonth: 2,
meta: expect.objectContaining({ neutralAuctionRegistrationKey: '180-02' }),
});
}, 60_000);
});
+86
View File
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
use sammo\LiteHashDRBG;
use sammo\RandUtil;
use sammo\Util;
if ($argc !== 3) {
fwrite(STDERR, "usage: php neutral-auction.php <ref-root> <json-input>\n");
exit(2);
}
$refRoot = rtrim($argv[1], '/');
require $refRoot . '/vendor/autoload.php';
$input = json_decode($argv[2], true, flags: JSON_THROW_ON_ERROR);
$rng = new RandUtil(new LiteHashDRBG(Util::simpleSerialize(
$input['hiddenSeed'],
'monthly',
$input['seedYear'],
$input['seedMonth'],
)));
for ($idx = 0; $idx < max(0, (int)$input['nationCount']); $idx++) {
$rng->nextRange(0.95, 1.05);
}
if ($input['consumeTournamentRoll']) {
$rng->nextBool(0.4);
}
$avgGold = Util::valueFit($input['averageGold'], 1000, 20000);
$avgRice = Util::valueFit($input['averageRice'], 1000, 20000);
$result = [];
$appendIfOpenable = static function (array $plan) use (&$result): void {
if ($plan['closeTurnCnt'] < 1 || $plan['closeTurnCnt'] > 24) {
return;
}
if ($plan['amount'] < 100 || $plan['amount'] > 10000) {
return;
}
if ($plan['startBidAmount'] < $plan['amount'] * 0.5 || $plan['amount'] * 2 < $plan['startBidAmount']) {
return;
}
if ($plan['finishBidAmount'] < $plan['amount'] * 1.1 || $plan['amount'] * 2 < $plan['finishBidAmount']) {
return;
}
if ($plan['finishBidAmount'] < $plan['startBidAmount'] * 1.1) {
return;
}
$result[] = $plan;
};
$buyRiceCount = max(0, (int)$input['buyRiceAuctionCount']);
if ($rng->nextBool(1 / ($buyRiceCount + 5))) {
$mul = $rng->nextRangeInt(1, 5);
$amount = $avgRice / 20 * $mul;
$cost = $avgGold / 20 * 0.9 * $mul;
$topv = $amount * 2;
$cost = Util::valueFit($cost, $amount * 0.8, $amount * 1.2);
$appendIfOpenable([
'auctionType' => 'BUY_RICE',
'amount' => Util::round($amount, -1),
'startBidAmount' => Util::round($cost, -1),
'finishBidAmount' => Util::round($topv, -1),
'closeTurnCnt' => $rng->nextRangeInt(3, 12),
]);
}
$sellRiceCount = max(0, (int)$input['sellRiceAuctionCount']);
if ($rng->nextBool(1 / ($sellRiceCount + 5))) {
$mul = $rng->nextRangeInt(1, 5);
$amount = $avgGold / 20 * $mul;
$cost = $avgRice / 20 * 1.1 * $mul;
$topv = $amount * 2;
$cost = Util::valueFit($cost, $amount * 0.8, $amount * 1.2);
$appendIfOpenable([
'auctionType' => 'SELL_RICE',
'amount' => Util::round($amount, -1),
'startBidAmount' => Util::round($cost, -1),
'finishBidAmount' => Util::round($topv, -1),
'closeTurnCnt' => $rng->nextRangeInt(3, 12),
]);
}
echo json_encode($result, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), PHP_EOL;