Merge origin/main into frontend parity worktree
This commit is contained in:
@@ -80,6 +80,17 @@ export type TurnDaemonCommand =
|
||||
}
|
||||
| { type: 'dropItem'; requestId?: string; generalId: number; itemType: string }
|
||||
| { type: 'auctionFinalize'; requestId?: string; auctionId: number }
|
||||
| {
|
||||
type: 'auctionOpen';
|
||||
requestId?: string;
|
||||
generalId: number;
|
||||
auctionType: 'BUY_RICE' | 'SELL_RICE' | 'UNIQUE_ITEM';
|
||||
amount: number;
|
||||
closeTurnCnt?: number;
|
||||
startBidAmount?: number;
|
||||
finishBidAmount?: number;
|
||||
itemKey?: string;
|
||||
}
|
||||
| {
|
||||
type: 'changePermission';
|
||||
requestId?: string;
|
||||
@@ -226,6 +237,17 @@ export type TurnDaemonCommandResult =
|
||||
generalId: number;
|
||||
reason: string;
|
||||
}
|
||||
| {
|
||||
type: 'auctionOpen';
|
||||
ok: true;
|
||||
auctionId: number;
|
||||
closeAt: string;
|
||||
}
|
||||
| {
|
||||
type: 'auctionOpen';
|
||||
ok: false;
|
||||
reason: string;
|
||||
}
|
||||
| {
|
||||
type: 'troopJoin';
|
||||
ok: true;
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface DatabaseClient {
|
||||
nationTurn: GamePrisma.NationTurnDelegate;
|
||||
troop: GamePrisma.TroopDelegate;
|
||||
logEntry: GamePrisma.LogEntryDelegate;
|
||||
auction: GamePrisma.AuctionDelegate;
|
||||
auctionBid: GamePrisma.AuctionBidDelegate;
|
||||
inheritancePoint: GamePrisma.InheritancePointDelegate;
|
||||
inheritanceLog: GamePrisma.InheritanceLogDelegate;
|
||||
inheritanceResult: GamePrisma.InheritanceResultDelegate;
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||
|
||||
import { simpleSerialize } from '../war/utils.js';
|
||||
|
||||
const DEFAULT_FIRST_NAMES = [
|
||||
'가', '간', '감', '강', '고', '공', '공손', '곽', '관', '괴', '교', '금', '노', '뇌', '능', '도', '동',
|
||||
'두', '등', '마', '맹', '문', '미', '반', '방', '부', '비', '사', '사마', '서', '설', '성', '소', '손',
|
||||
'송', '순', '신', '심', '악', '안', '양', '엄', '여', '염', '오', '왕', '요', '우', '원', '위', '유',
|
||||
'육', '윤', '이', '장', '저', '전', '정', '제갈', '조', '종', '주', '진', '채', '태사', '하', '하후',
|
||||
'학', '한', '향', '허', '호', '화', '황', '공손', '손', '왕', '유', '장', '조',
|
||||
] as const;
|
||||
|
||||
const DEFAULT_LAST_NAMES = [
|
||||
'가', '간', '강', '거', '건', '검', '견', '경', '공', '광', '권', '규', '녕', '단', '대', '도', '등',
|
||||
'람', '량', '례', '로', '료', '모', '민', '박', '범', '보', '비', '사', '상', '색', '서', '소', '속',
|
||||
'송', '수', '순', '습', '승', '양', '연', '영', '온', '옹', '완', '우', '웅', '월', '위', '유', '윤',
|
||||
'융', '이', '익', '임', '정', '제', '조', '주', '준', '지', '찬', '책', '충', '탁', '택', '통', '패',
|
||||
'평', '포', '합', '해', '혁', '현', '화', '환', '회', '횡', '후', '훈', '휴', '흠', '흥',
|
||||
] as const;
|
||||
|
||||
const readNameParts = (value: unknown, fallback: readonly string[]): string[] => {
|
||||
if (!Array.isArray(value)) {
|
||||
return [...fallback];
|
||||
}
|
||||
const result = value.filter((entry): entry is string => typeof entry === 'string');
|
||||
return result.length > 0 ? result : [...fallback];
|
||||
};
|
||||
|
||||
export const buildAuctionAlias = (
|
||||
generalId: number,
|
||||
hiddenSeed: string | number,
|
||||
configConst: Record<string, unknown> = {}
|
||||
): string => {
|
||||
const firstNames = readNameParts(configConst.randGenFirstName, DEFAULT_FIRST_NAMES);
|
||||
const middleNames = readNameParts(configConst.randGenMiddleName, ['']);
|
||||
const lastNames = readNameParts(configConst.randGenLastName, DEFAULT_LAST_NAMES);
|
||||
const pool: string[] = [];
|
||||
for (const first of firstNames) {
|
||||
for (const middle of middleNames) {
|
||||
for (const last of lastNames) {
|
||||
pool.push(`${first}${middle}${last}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const shuffled = new RandUtil(
|
||||
new LiteHashDRBG(simpleSerialize(hiddenSeed, 'obfuscatedNamePool'))
|
||||
).shuffle(pool);
|
||||
const normalizedId = Math.max(0, Math.floor(generalId));
|
||||
const duplicateIndex = Math.floor(normalizedId / shuffled.length);
|
||||
const name = shuffled[normalizedId % shuffled.length] ?? `익명${normalizedId}`;
|
||||
return duplicateIndex === 0 ? name : `${name}${duplicateIndex}`;
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
import { LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||
|
||||
import { simpleSerialize } from '../war/utils.js';
|
||||
|
||||
export type NeutralResourceAuctionType = 'BUY_RICE' | 'SELL_RICE';
|
||||
|
||||
export interface NeutralAuctionPlanInput {
|
||||
hiddenSeed: string | number;
|
||||
seedYear: number;
|
||||
seedMonth: number;
|
||||
nationCount: number;
|
||||
consumeTournamentRoll: boolean;
|
||||
averageGold: number;
|
||||
averageRice: number;
|
||||
buyRiceAuctionCount: number;
|
||||
sellRiceAuctionCount: number;
|
||||
}
|
||||
|
||||
export interface NeutralResourceAuctionPlan {
|
||||
auctionType: NeutralResourceAuctionType;
|
||||
amount: number;
|
||||
startBidAmount: number;
|
||||
finishBidAmount: number;
|
||||
closeTurnCnt: number;
|
||||
}
|
||||
|
||||
const clamp = (value: number, min: number, max: number): number => {
|
||||
if (max < min) {
|
||||
return min;
|
||||
}
|
||||
return Math.min(max, Math.max(min, value));
|
||||
};
|
||||
|
||||
const roundToTens = (value: number): number => Math.round(value / 10) * 10;
|
||||
|
||||
const normalizeCount = (value: number): number => (Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0);
|
||||
|
||||
const normalizeAverage = (value: number): number => clamp(Number.isFinite(value) ? value : 0, 1_000, 20_000);
|
||||
|
||||
const canOpenResourceAuction = (plan: NeutralResourceAuctionPlan): boolean =>
|
||||
plan.closeTurnCnt >= 1 &&
|
||||
plan.closeTurnCnt <= 24 &&
|
||||
plan.amount >= 100 &&
|
||||
plan.amount <= 10_000 &&
|
||||
plan.startBidAmount >= plan.amount * 0.5 &&
|
||||
plan.startBidAmount <= plan.amount * 2 &&
|
||||
plan.finishBidAmount >= plan.amount * 1.1 &&
|
||||
plan.finishBidAmount <= plan.amount * 2 &&
|
||||
plan.finishBidAmount >= plan.startBidAmount * 1.1;
|
||||
|
||||
export const buildNeutralResourceAuctionPlan = (input: NeutralAuctionPlanInput): NeutralResourceAuctionPlan[] => {
|
||||
// ref TurnExecutionHelper는 날짜를 넘기기 전에 이전 연월로 monthly RNG를 만든다.
|
||||
const rng = new RandUtil(
|
||||
new LiteHashDRBG(simpleSerialize(input.hiddenSeed, 'monthly', input.seedYear, input.seedMonth))
|
||||
);
|
||||
|
||||
// ref postUpdateMonthly()의 국가 국력 보정이 registerAuction()보다 먼저 RNG를 소비한다.
|
||||
for (let nationIdx = 0; nationIdx < normalizeCount(input.nationCount); nationIdx += 1) {
|
||||
rng.nextRange(0.95, 1.05);
|
||||
}
|
||||
// 토너먼트가 없고 자동 개시가 켜진 경우 성공 여부와 무관하게 한 번 소비한다.
|
||||
if (input.consumeTournamentRoll) {
|
||||
rng.nextBool(0.4);
|
||||
}
|
||||
|
||||
const averageGold = normalizeAverage(input.averageGold);
|
||||
const averageRice = normalizeAverage(input.averageRice);
|
||||
const result: NeutralResourceAuctionPlan[] = [];
|
||||
|
||||
const buyRiceAuctionCount = normalizeCount(input.buyRiceAuctionCount);
|
||||
if (rng.nextBool(1 / (buyRiceAuctionCount + 5))) {
|
||||
const multiplier = rng.nextRangeInt(1, 5);
|
||||
const rawAmount = (averageRice / 20) * multiplier;
|
||||
const rawStartBid = clamp((averageGold / 20) * 0.9 * multiplier, rawAmount * 0.8, rawAmount * 1.2);
|
||||
const plan: NeutralResourceAuctionPlan = {
|
||||
auctionType: 'BUY_RICE',
|
||||
amount: roundToTens(rawAmount),
|
||||
startBidAmount: roundToTens(rawStartBid),
|
||||
finishBidAmount: roundToTens(rawAmount * 2),
|
||||
closeTurnCnt: rng.nextRangeInt(3, 12),
|
||||
};
|
||||
if (canOpenResourceAuction(plan)) {
|
||||
result.push(plan);
|
||||
}
|
||||
}
|
||||
|
||||
const sellRiceAuctionCount = normalizeCount(input.sellRiceAuctionCount);
|
||||
if (rng.nextBool(1 / (sellRiceAuctionCount + 5))) {
|
||||
const multiplier = rng.nextRangeInt(1, 5);
|
||||
const rawAmount = (averageGold / 20) * multiplier;
|
||||
const rawStartBid = clamp((averageRice / 20) * 1.1 * multiplier, rawAmount * 0.8, rawAmount * 1.2);
|
||||
const plan: NeutralResourceAuctionPlan = {
|
||||
auctionType: 'SELL_RICE',
|
||||
amount: roundToTens(rawAmount),
|
||||
startBidAmount: roundToTens(rawStartBid),
|
||||
finishBidAmount: roundToTens(rawAmount * 2),
|
||||
closeTurnCnt: rng.nextRangeInt(3, 12),
|
||||
};
|
||||
if (canOpenResourceAuction(plan)) {
|
||||
result.push(plan);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
@@ -1,6 +1,8 @@
|
||||
export * from './domain/entities.js';
|
||||
export type { RandomGenerator } from '@sammo-ts/common';
|
||||
export * from './actions/index.js';
|
||||
export * from './auction/alias.js';
|
||||
export * from './auction/neutral.js';
|
||||
export * from './constraints/index.js';
|
||||
export * from './crewType/index.js';
|
||||
export * from './diplomacy/index.js';
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildAuctionAlias } from '../src/auction/alias.js';
|
||||
|
||||
describe('buildAuctionAlias', () => {
|
||||
it('returns a stable alias for the same world seed and general id', () => {
|
||||
const first = buildAuctionAlias(17, 'legacy-compatible-seed');
|
||||
const second = buildAuctionAlias(17, 'legacy-compatible-seed');
|
||||
|
||||
expect(second).toBe(first);
|
||||
expect(first.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('uses scenario-specific name pools without exposing a general name', () => {
|
||||
const config = {
|
||||
randGenFirstName: ['청'],
|
||||
randGenMiddleName: ['운'],
|
||||
randGenLastName: ['객', '상'],
|
||||
};
|
||||
|
||||
expect(buildAuctionAlias(0, 'seed', config)).toMatch(/^청운(객|상)$/);
|
||||
expect(buildAuctionAlias(1, 'seed', config)).toMatch(/^청운(객|상)$/);
|
||||
expect(buildAuctionAlias(2, 'seed', config)).toMatch(/^청운(객|상)1$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildNeutralResourceAuctionPlan,
|
||||
type NeutralAuctionPlanInput,
|
||||
type NeutralResourceAuctionPlan,
|
||||
} from '../src/auction/neutral.js';
|
||||
|
||||
const refRoot = process.env.SAMMO_REF_ROOT;
|
||||
const oraclePath = fileURLToPath(new URL('../../../tools/legacy-oracles/neutral-auction.php', import.meta.url));
|
||||
|
||||
const runLegacyOracle = (input: NeutralAuctionPlanInput): NeutralResourceAuctionPlan[] => {
|
||||
if (!refRoot) {
|
||||
throw new Error('SAMMO_REF_ROOT is required');
|
||||
}
|
||||
const result = spawnSync('php', [oraclePath, refRoot, JSON.stringify(input)], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(result.stderr || `legacy oracle exited with ${result.status}`);
|
||||
}
|
||||
return JSON.parse(result.stdout) as NeutralResourceAuctionPlan[];
|
||||
};
|
||||
|
||||
const fixtures: Array<{ input: NeutralAuctionPlanInput; expectedTypes: string[] }> = [
|
||||
{
|
||||
expectedTypes: ['BUY_RICE'],
|
||||
input: {
|
||||
hiddenSeed: 'merchant-11',
|
||||
seedYear: 180,
|
||||
seedMonth: 1,
|
||||
nationCount: 3,
|
||||
consumeTournamentRoll: false,
|
||||
averageGold: 5_432,
|
||||
averageRice: 7_654,
|
||||
buyRiceAuctionCount: 0,
|
||||
sellRiceAuctionCount: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
expectedTypes: ['BUY_RICE', 'SELL_RICE'],
|
||||
input: {
|
||||
hiddenSeed: 'tournament-35',
|
||||
seedYear: 191,
|
||||
seedMonth: 12,
|
||||
nationCount: 8,
|
||||
consumeTournamentRoll: true,
|
||||
averageGold: 25_000,
|
||||
averageRice: 500,
|
||||
buyRiceAuctionCount: 2,
|
||||
sellRiceAuctionCount: 4,
|
||||
},
|
||||
},
|
||||
{
|
||||
expectedTypes: [],
|
||||
input: {
|
||||
hiddenSeed: 'merchant-32',
|
||||
seedYear: 203,
|
||||
seedMonth: 7,
|
||||
nationCount: 3,
|
||||
consumeTournamentRoll: false,
|
||||
averageGold: 5_432,
|
||||
averageRice: 7_654,
|
||||
buyRiceAuctionCount: 0,
|
||||
sellRiceAuctionCount: 0,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe.skipIf(!refRoot)('neutral auction legacy PHP differential', () => {
|
||||
for (const [index, fixture] of fixtures.entries()) {
|
||||
it(`matches legacy RNG timing and amounts for fixture ${index + 1}`, () => {
|
||||
const actual = buildNeutralResourceAuctionPlan(fixture.input);
|
||||
expect(actual).toEqual(runLegacyOracle(fixture.input));
|
||||
expect(actual.map((plan) => plan.auctionType)).toEqual(fixture.expectedTypes);
|
||||
});
|
||||
}
|
||||
|
||||
it('matches a seed, month, count, tournament, and average-resource matrix', () => {
|
||||
let generatedAuctions = 0;
|
||||
const generatedTypes = new Set<string>();
|
||||
for (let index = 0; index < 96; index += 1) {
|
||||
const input: NeutralAuctionPlanInput = {
|
||||
hiddenSeed: `neutral-matrix-${index}`,
|
||||
seedYear: 180 + (index % 17),
|
||||
seedMonth: (index % 12) + 1,
|
||||
nationCount: index % 11,
|
||||
consumeTournamentRoll: index % 2 === 0,
|
||||
averageGold: 500 + ((index * 1_337) % 25_000),
|
||||
averageRice: 500 + ((index * 2_111) % 25_000),
|
||||
buyRiceAuctionCount: index % 9,
|
||||
sellRiceAuctionCount: index % 13,
|
||||
};
|
||||
const actual = buildNeutralResourceAuctionPlan(input);
|
||||
expect(actual, `matrix fixture ${index}`).toEqual(runLegacyOracle(input));
|
||||
generatedAuctions += actual.length;
|
||||
for (const plan of actual) {
|
||||
generatedTypes.add(plan.auctionType);
|
||||
}
|
||||
}
|
||||
expect(generatedAuctions).toBeGreaterThan(0);
|
||||
expect(generatedTypes).toEqual(new Set(['BUY_RICE', 'SELL_RICE']));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user