diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index 5daa61eb..6112f4f5 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -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), diff --git a/app/game-engine/test/voteReward.test.ts b/app/game-engine/test/voteReward.test.ts index a869b067..f1a30043 100644 --- a/app/game-engine/test/voteReward.test.ts +++ b/app/game-engine/test/voteReward.test.ts @@ -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 = {