fix(game): 설문 기본 유니크 풀을 복원한다

시나리오가 allItems를 생략해도 Ref 기본 유니크 풀로 추첨한다. 계수 1에서 가오픈과 정식 오픈 tick의 아이템 지급, 장착, 보상 마커와 로그를 고정 seed로 검증한다.
This commit is contained in:
2026-08-27 11:02:14 +00:00
parent a35dcc78b3
commit e30709a382
2 changed files with 114 additions and 2 deletions
@@ -30,7 +30,6 @@ import {
resolveTroopSecretPermission,
resolveMessageTargetIcon,
type GeneralActionModule,
resolveUniqueConfig,
rollUniqueLottery,
type ItemModule,
type LogEntryDraft,
@@ -39,6 +38,7 @@ import {
type TriggerValue,
type TurnCommandProfile,
} from '@sammo-ts/logic';
import { resolveLegacyCompatibleUniqueConfig } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
import { round as roundLegacyInteger, simpleSerialize } from '@sammo-ts/logic/war/utils.js';
import {
cloneItemInventory,
@@ -2909,7 +2909,11 @@ async function handleVoteReward(
) * 5;
const itemRegistry = await getItemRegistry();
const uniqueConfig = resolveUniqueConfig(configConst);
// Ref always has GameConst::$allItems even when a scenario omits an
// allItems override. Keep survey rewards on the same compatibility path
// as rankings, inheritance, and auctions instead of treating that omission
// as an empty lottery pool.
const uniqueConfig = await resolveLegacyCompatibleUniqueConfig(configConst);
const generals = world.listGenerals();
const occupiedUniqueCounts = countOccupiedUniqueItems(
generals.map((entry) => entry.role.items),
+108
View File
@@ -53,6 +53,33 @@ const actorBindingDb = (userId = 'user-1') => ({
},
});
const buildDefaultUniquePoolSnapshot = (general: TurnGeneral): TurnWorldSnapshot => ({
generals: [general] as any,
cities: [] as any,
nations: [] as any,
troops: [],
diplomacy: [],
events: [],
initialEvents: [],
map: {
id: 'test_map',
name: 'TestMap',
cities: [],
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
} as any,
scenarioConfig: {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
iconPath: '',
map: {},
// Ordinary Ref scenarios inherit GameConst::$allItems without writing
// an allItems override into the scenario JSON.
const: { develCost: 100, uniqueTrialCoef: 1 },
environment: { mapName: 'test_map', unitSet: 'default' },
},
scenarioMeta: { startYear: 180 } as any,
unitSet: {} as any,
});
describe('voteReward command', () => {
it('keeps the wall-time fallback open at exact deadline equality', () => {
const deadline = new Date('0180-01-01T00:00:00.000Z');
@@ -416,6 +443,87 @@ describe('voteReward command', () => {
});
});
it.each([
['PREOPEN', new Date('2026-08-27T10:30:00.000Z'), 1, 'preopen-seed'],
['OPEN', new Date('2026-08-27T11:00:05.000Z'), 2, 'open-seed'],
] as const)(
'awards a survey unique from the Ref default pool at coefficient 1 during %s',
async (_phase, wallNow, voteId, hiddenSeed) => {
const general = buildGeneral(1);
const state: TurnWorldState = {
id: 1,
currentYear: 180,
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: new Date('0180-01-01T10:00:00.000Z'),
clockBaseTime: new Date('0180-01-01T10:00:00.000Z'),
clockTick: 36_000_000,
clockMode: 'realtime',
clockWallAnchor: new Date('2026-08-27T11:00:00.000Z'),
meta: {
hiddenSeed,
scenarioId: 0,
initYear: 180,
initMonth: 1,
scenarioMeta: { startYear: 180 },
preopenAt: '2026-08-27 19:30:00',
opentime: '2026-08-27 20:00:00',
},
};
const world = new InMemoryTurnWorld(state, buildDefaultUniquePoolSnapshot(general), {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
});
const acceptedGameTick = world.dateToGameTick(world.getGameNow(wallNow));
expect(acceptedGameTick).toBe(_phase === 'PREOPEN' ? 36_000_000 : 36_300_000);
const commandDb = {
...actorBindingDb(),
auction: { findMany: async () => [] },
$queryRaw: async (query: { strings: readonly string[] }) =>
query.strings.join(' ').includes('SELECT options')
? [
{
options: ['찬성'],
multipleOptions: 1,
endAt: null,
endTick: null,
closedAt: null,
},
]
: [{ id: voteId }],
};
const handler = createTurnDaemonCommandHandler({ world });
const result = await handler.handle(
{
type: 'voteReward',
requestId: `vote-reward-${_phase.toLowerCase()}`,
userId: 'user-1',
voteId,
generalId: 1,
selection: [0],
acceptedGameTick,
},
{ db: commandDb as any }
);
expect(result).toMatchObject({
type: 'voteReward',
ok: true,
awardedUnique: true,
});
if (!result || result.type !== 'voteReward' || !result.ok || !result.itemKey) {
throw new Error(`${_phase} survey unique reward missing`);
}
const updated = world.getGeneralById(1);
expect(updated?.gold).toBe(1500);
expect(Object.values(updated?.role.items ?? {})).toContain(result.itemKey);
expect(updated?.meta.voteRewards).toMatchObject({
[voteId]: { awarded: true, itemKey: result.itemKey },
});
expect(world.consumeDirtyState().logs.some((entry) => entry.text.includes('【설문조사】'))).toBe(true);
}
);
it('treats an active unique auction as occupied when revalidating the lottery', async () => {
const general = buildGeneral(1);
const snapshot: TurnWorldSnapshot = {