merge: 설문 기본 유니크 풀을 복원한다

This commit is contained in:
2026-08-27 11:03:11 +00:00
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 = {