diff --git a/app/game-api/src/router/vote/index.ts b/app/game-api/src/router/vote/index.ts index 15d59dc..249b9f5 100644 --- a/app/game-api/src/router/vote/index.ts +++ b/app/game-api/src/router/vote/index.ts @@ -5,6 +5,7 @@ import { asRecord, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; import { GamePrisma } from '@sammo-ts/infra'; import { ITEM_KEYS, + addOccupiedUniqueItemKeys, buildVoteUniqueSeed, countOccupiedUniqueItems, createItemModuleRegistry, @@ -22,7 +23,12 @@ const hasAdminRole = (roles: string[], profileName: string): boolean => { if (roles.includes('superuser') || roles.includes('admin') || roles.includes('admin.superuser')) { return true; } - return roles.some((role) => role === 'admin.survey' || role === `admin.survey:${profileName}`); + return roles.some( + (role) => + role === 'admin.survey.open' || + role === 'admin.survey.open:*' || + role === `admin.survey.open:${profileName}` + ); }; const adminProcedure = authedProcedure.use(({ ctx, next }) => { @@ -66,9 +72,7 @@ const normalizeCode = (value: string | null | undefined): string | null => { }; const normalizeOptions = (options: string[]): string[] => - options - .map((option) => option.trim()) - .filter((option) => option.length > 0); + options.map((option) => option.trim()).filter((option) => option.length > 0); const readMetaNumber = (meta: Record, key: string, fallback: number): number => { const value = meta[key]; @@ -225,9 +229,7 @@ export const voteRouter = router({ const pollEnded = Boolean(row.closed_at) || (row.end_at ? row.end_at <= new Date() : false); const userId = ctx.auth?.user.id; - const general = userId - ? await ctx.db.general.findFirst({ where: { userId }, select: { id: true } }) - : null; + const general = userId ? await ctx.db.general.findFirst({ where: { userId }, select: { id: true } }) : null; const [comments, userCnt, myVoteRow] = await Promise.all([ ctx.db.$queryRaw(GamePrisma.sql` @@ -257,9 +259,8 @@ export const voteRouter = router({ const myVote = myVoteRow[0]?.selection ? parseSelection(myVoteRow[0].selection) : null; - const canReveal = row.reveal_mode === 'after_vote' - ? Boolean(myVote) || pollEnded - : pollEnded; + // 레거시는 투표 전에도 현재 집계를 보여준다. after_end만 명시적으로 숨긴다. + const canReveal = row.reveal_mode === 'after_end' ? pollEnded : true; const voteResults = canReveal ? await ctx.db.$queryRaw(GamePrisma.sql` @@ -355,6 +356,9 @@ export const voteRouter = router({ } const sortedSelection = [...selection].sort((a, b) => a - b); + if (new Set(sortedSelection).size !== sortedSelection.length) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '선택한 항목이 올바르지 않습니다.' }); + } const general = await getMyGeneral(ctx); const rows = await ctx.db.$queryRaw>(GamePrisma.sql` @@ -394,14 +398,24 @@ export const voteRouter = router({ const itemRegistry = await getItemRegistry(); const uniqueConfig = resolveUniqueConfig(constValues); - const generalRows = await ctx.db.general.findMany({ - select: { - horseCode: true, - weaponCode: true, - bookCode: true, - itemCode: true, - }, - }); + const [generalRows, reservedUniqueRows] = await Promise.all([ + ctx.db.general.findMany({ + select: { + horseCode: true, + weaponCode: true, + bookCode: true, + itemCode: true, + }, + }), + ctx.db.auction.findMany({ + where: { + type: 'UNIQUE_ITEM', + status: { in: ['OPEN', 'FINALIZING'] }, + targetCode: { not: null }, + }, + select: { targetCode: true }, + }), + ]); const generalItems: GeneralItemSlots[] = generalRows.map((row) => ({ horse: normalizeCode(row.horseCode), weapon: normalizeCode(row.weaponCode), @@ -410,6 +424,11 @@ export const voteRouter = router({ })); const occupiedUniqueCounts = countOccupiedUniqueItems(generalItems, itemRegistry); + addOccupiedUniqueItemKeys( + occupiedUniqueCounts, + reservedUniqueRows.map((row) => row.targetCode), + itemRegistry + ); const userCount = await ctx.db.general.count({ where: { npcState: { lt: 2 } } }); const rngSeed = buildVoteUniqueSeed( diff --git a/app/game-api/test/voteRouter.test.ts b/app/game-api/test/voteRouter.test.ts new file mode 100644 index 0000000..69ba616 --- /dev/null +++ b/app/game-api/test/voteRouter.test.ts @@ -0,0 +1,292 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; +import type { GamePrisma, RedisConnector } from '@sammo-ts/infra'; + +import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; +import { InMemoryFlushStore } from '../src/auth/flushStore.js'; +import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js'; +import type { TurnDaemonTransport } from '../src/daemon/transport.js'; +import { appRouter } from '../src/router.js'; + +const poll = { + id: 1, + title: '선호하는 병종', + body: '', + options: ['보병', '기병'], + multiple_options: 1, + reveal_mode: 'after_vote', + opener_general_id: 1, + opener_name: '관리자', + start_at: new Date('2026-07-26T00:00:00Z'), + end_at: null, + closed_at: null, +}; + +const buildGeneral = (overrides: Partial = {}): GeneralRow => ({ + id: 7, + userId: 'user-1', + name: '유비', + nationId: 1, + cityId: 1, + troopId: 0, + npcState: 0, + affinity: null, + bornYear: 180, + deadYear: 300, + picture: null, + imageServer: 0, + leadership: 50, + strength: 50, + intel: 50, + injury: 0, + experience: 0, + dedication: 0, + officerLevel: 1, + gold: 1000, + rice: 1000, + crew: 0, + crewTypeId: 0, + train: 0, + atmos: 0, + weaponCode: 'None', + bookCode: 'None', + horseCode: 'None', + itemCode: 'None', + turnTime: new Date('2026-07-26T00:00:00Z'), + recentWarTime: null, + age: 20, + startAge: 20, + personalCode: 'None', + specialCode: 'None', + special2Code: 'None', + lastTurn: {}, + meta: {}, + penalty: {}, + createdAt: new Date('2026-07-26T00:00:00Z'), + updatedAt: new Date('2026-07-26T00:00:00Z'), + ...overrides, +}); + +const buildAuth = (roles: string[] = [], userId = 'user-1'): GameSessionTokenPayload => ({ + version: 1, + profile: 'che:default', + issuedAt: '2026-07-26T00:00:00.000Z', + expiresAt: '2026-07-27T00:00:00.000Z', + sessionId: `session-${userId}`, + user: { + id: userId, + username: userId, + displayName: userId, + roles, + }, + sanctions: {}, +}); + +const sqlText = (query: GamePrisma.Sql): string => query.strings.join(' '); + +const buildContext = (options: { + auth?: GameSessionTokenPayload | null; + general?: GeneralRow | null; + myVote?: number[] | null; + voteRows?: Array<{ selection: number[]; cnt: number }>; + pollRow?: typeof poll; + configConst?: Record; + auctionTargets?: string[]; +}) => { + const auth = options.auth === undefined ? buildAuth() : options.auth; + const general = options.general === undefined ? buildGeneral() : options.general; + const requestCommand = vi.fn(async () => ({ + type: 'voteReward' as const, + ok: true as const, + voteId: 1, + generalId: general?.id ?? 0, + awardedUnique: false, + })); + const queryRaw = vi.fn(async (query: GamePrisma.Sql) => { + const text = sqlText(query); + if (text.includes('FROM vote_poll') && text.includes('LIMIT 1')) { + return [options.pollRow ?? poll]; + } + if (text.includes('INSERT INTO vote (')) { + return [{ id: 11 }]; + } + if (text.includes('FROM vote_comment')) { + return []; + } + if (text.includes('SELECT selection') && text.includes('general_id')) { + return options.myVote ? [{ selection: options.myVote }] : []; + } + if (text.includes('GROUP BY selection')) { + return options.voteRows ?? [{ selection: [0], cnt: 2 }]; + } + if (text.includes('INSERT INTO vote_comment')) { + return []; + } + return []; + }); + const db = { + $queryRaw: queryRaw, + worldState: { + findFirst: vi.fn(async () => ({ + id: 1, + scenarioCode: 'default', + currentYear: 200, + currentMonth: 1, + tickSeconds: 3600, + config: { const: { develCost: 18, allItems: {}, ...(options.configConst ?? {}) } }, + meta: { + hiddenSeed: 'seed', + scenarioId: 200, + initYear: 180, + initMonth: 1, + scenarioMeta: { startYear: 180 }, + }, + updatedAt: new Date(), + })), + }, + general: { + findFirst: vi.fn(async ({ where }: { where: { userId: string } }) => + general?.userId === where.userId ? general : null + ), + findMany: vi.fn(async () => [ + { + horseCode: general?.horseCode ?? 'None', + weaponCode: general?.weaponCode ?? 'None', + bookCode: general?.bookCode ?? 'None', + itemCode: general?.itemCode ?? 'None', + }, + ]), + count: vi.fn(async () => 2), + }, + nation: { + findFirst: vi.fn(async () => ({ name: '촉' })), + }, + auction: { + findMany: vi.fn(async () => (options.auctionTargets ?? []).map((targetCode) => ({ targetCode }))), + }, + }; + const accessTokenStore = new RedisAccessTokenStore( + { + get: async () => null, + set: async () => null, + }, + 'che:default' + ); + const context: GameApiContext = { + db: db as unknown as DatabaseClient, + redis: {} as RedisConnector['client'], + turnDaemon: { requestCommand } as unknown as TurnDaemonTransport, + battleSim: {} as GameApiContext['battleSim'], + profile: { id: 'che', scenario: 'default', name: 'che:default' }, + auth, + uploadDir: 'uploads', + uploadPath: '/uploads', + uploadPublicUrl: null, + accessTokenStore, + flushStore: new InMemoryFlushStore(), + gameTokenSecret: 'test-secret', + }; + return { context, requestCommand, queryRaw, db }; +}; + +describe('vote router actor and permission boundaries', () => { + it('rejects unauthenticated survey access', async () => { + const fixture = buildContext({ auth: null }); + + await expect(appRouter.createCaller(fixture.context).vote.getVoteList()).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + }); + + it('uses only the general owned by the authenticated user for voting and reward dispatch', async () => { + const owned = buildGeneral({ id: 7, userId: 'user-1', name: '유비' }); + const fixture = buildContext({ general: owned }); + + await expect( + appRouter.createCaller(fixture.context).vote.submitVote({ voteId: 1, selection: [0] }) + ).resolves.toEqual({ ok: true, wonLottery: false }); + expect(fixture.requestCommand).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'voteReward', + voteId: 1, + generalId: 7, + goldReward: 90, + }) + ); + }); + + it('includes active unique auctions in the API-side reward expectation', async () => { + const fixture = buildContext({ + configConst: { + allItems: { weapon: { che_무기_12_칠성검: 1 } }, + maxUniqueItemLimit: [[-1, 1]], + uniqueTrialCoef: 10, + maxUniqueTrialProb: 10, + minMonthToAllowInheritItem: 0, + }, + auctionTargets: ['che_무기_12_칠성검'], + }); + + await appRouter.createCaller(fixture.context).vote.submitVote({ voteId: 1, selection: [0] }); + + expect(fixture.requestCommand).toHaveBeenCalledWith( + expect.objectContaining({ + unique: { expected: false, itemKey: null }, + }) + ); + }); + + it('rejects voting and comments when the authenticated user owns no general', async () => { + const fixture = buildContext({ auth: buildAuth([], 'user-2'), general: buildGeneral({ userId: 'user-1' }) }); + const caller = appRouter.createCaller(fixture.context); + + await expect(caller.vote.submitVote({ voteId: 1, selection: [0] })).rejects.toMatchObject({ + code: 'NOT_FOUND', + message: 'General not found', + }); + await expect(caller.vote.addComment({ voteId: 1, text: '댓글' })).rejects.toMatchObject({ + code: 'NOT_FOUND', + message: 'General not found', + }); + expect(fixture.requestCommand).not.toHaveBeenCalled(); + }); + + it('rejects duplicate selections before persisting a vote', async () => { + const fixture = buildContext({ pollRow: { ...poll, multiple_options: 2 } }); + + await expect( + appRouter.createCaller(fixture.context).vote.submitVote({ voteId: 1, selection: [0, 0] }) + ).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: '선택한 항목이 올바르지 않습니다.', + }); + expect(fixture.requestCommand).not.toHaveBeenCalled(); + }); + + it('shows legacy-compatible aggregate results before the current general votes', async () => { + const fixture = buildContext({ myVote: null, voteRows: [{ selection: [0], cnt: 2 }] }); + + const result = await appRouter.createCaller(fixture.context).vote.getVoteDetail({ voteId: 1 }); + + expect(result.myVote).toBeNull(); + expect(result.votes).toEqual([{ selection: [0], count: 2 }]); + }); + + it.each([ + ['global survey permission', ['admin.survey.open'], true], + ['wildcard survey permission', ['admin.survey.open:*'], true], + ['matching profile permission', ['admin.survey.open:che:default'], true], + ['different profile permission', ['admin.survey.open:hwe:default'], false], + ['ordinary user', ['user'], false], + ])('%s controls the administrator panel', async (_label, roles, allowed) => { + const fixture = buildContext({ auth: buildAuth(roles) }); + const request = appRouter.createCaller(fixture.context).vote.getAdminStatus(); + + if (allowed) { + await expect(request).resolves.toEqual({ ok: true }); + } else { + await expect(request).rejects.toMatchObject({ code: 'FORBIDDEN' }); + } + }); +}); diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index 5fe0a1b..5a0a976 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -24,6 +24,7 @@ import { evaluateConstraints, resolveGeneralAction, ITEM_KEYS, + addOccupiedUniqueItemKeys, buildGenericUniqueSeed, countOccupiedUniqueItems, createItemModuleRegistry, @@ -382,6 +383,7 @@ const buildUniqueLotteryRunner = (options: { seedBase: string; itemRegistry: Map; uniqueConfig: ReturnType; + getAdditionalOccupiedUniqueItemKeys?: () => Iterable; }): UniqueLotteryRunner => { if (!options.worldView) { return () => null; @@ -408,6 +410,11 @@ const buildUniqueLotteryRunner = (options: { entry.id === general.id ? general.role.items : entry.role.items ); const occupiedUniqueCounts = countOccupiedUniqueItems(generalItemsList, options.itemRegistry); + addOccupiedUniqueItemKeys( + occupiedUniqueCounts, + options.getAdditionalOccupiedUniqueItemKeys?.() ?? [], + options.itemRegistry + ); const rngSeed = buildGenericUniqueSeed( options.seedBase, world.currentYear, @@ -723,6 +730,7 @@ export const createReservedTurnHandler = async (options: { commandProfile?: TurnCommandProfile; commandEnv?: TurnCommandEnv; commandRngFactory?: (input: { kind: 'nation' | 'general'; actionKey: string; seed: string }) => RandUtil; + getAdditionalOccupiedUniqueItemKeys?: () => Iterable; onActionResolved?: (payload: { kind: 'nation' | 'general'; generalId: number; @@ -944,6 +952,7 @@ export const createReservedTurnHandler = async (options: { seedBase, itemRegistry, uniqueConfig, + getAdditionalOccupiedUniqueItemKeys: options.getAdditionalOccupiedUniqueItemKeys, }); let baseContext: ActionContextBase = { general: currentGeneral, diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index 6517527..2362c52 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -473,6 +473,8 @@ const createTurnDaemonRuntimeWithLease = async ( neutralAuctionRegistrar.handler, frontStateHandler ); + let occupiedAuctionUniqueItemKeys: string[] = []; + let refreshOccupiedAuctionUniqueItemKeys = async (): Promise => {}; const worldOptions: InMemoryTurnWorldOptions = { schedule, generalTurnHandler: @@ -486,6 +488,7 @@ const createTurnDaemonRuntimeWithLease = async ( getWorld: () => worldRef, commandProfile, commandEnv: monthlyCommandEnv, + getAdditionalOccupiedUniqueItemKeys: () => occupiedAuctionUniqueItemKeys, })), calendarHandler: calendarHandler ?? undefined, autoAdvanceDiplomacyMonth: false, @@ -501,6 +504,7 @@ const createTurnDaemonRuntimeWithLease = async ( ? async (general) => { const promises: Promise[] = []; promises.push(reservedTurnStoreHandle.store.refreshGeneralTurns(general.id)); + promises.push(refreshOccupiedAuctionUniqueItemKeys()); if (general.nationId > 0 && general.officerLevel >= 5) { promises.push( reservedTurnStoreHandle.store.refreshNationTurns(general.nationId, general.officerLevel) @@ -648,6 +652,17 @@ const createTurnDaemonRuntimeWithLease = async ( if (commandConnector && databaseCommandQueue) { await commandConnector.connect(); await databaseCommandQueue.initialize(); + refreshOccupiedAuctionUniqueItemKeys = async () => { + const rows = await commandConnector.prisma.auction.findMany({ + where: { + type: 'UNIQUE_ITEM', + status: { in: ['OPEN', 'FINALIZING'] }, + targetCode: { not: null }, + }, + select: { targetCode: true }, + }); + occupiedAuctionUniqueItemKeys = rows.flatMap((row) => (row.targetCode ? [row.targetCode] : [])); + }; } const baseClose = close; diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index 3be3c7c..b4c9109 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -11,6 +11,7 @@ import { LogFormat, LogScope, ITEM_KEYS, + addOccupiedUniqueItemKeys, buildVoteUniqueSeed, countOccupiedUniqueItems, createItemModuleRegistry, @@ -1162,6 +1163,21 @@ async function handleVoteReward( generals.map((entry) => entry.role.items), itemRegistry ); + if (ctx.commandDb) { + const reservedUniqueRows = await ctx.commandDb.auction.findMany({ + where: { + type: 'UNIQUE_ITEM', + status: { in: ['OPEN', 'FINALIZING'] }, + targetCode: { not: null }, + }, + select: { targetCode: true }, + }); + addOccupiedUniqueItemKeys( + occupiedUniqueCounts, + reservedUniqueRows.map((row) => row.targetCode), + itemRegistry + ); + } const userCount = generals.filter((entry) => entry.npcState < 2).length; const rngSeed = buildVoteUniqueSeed( typeof hiddenSeed === 'string' || typeof hiddenSeed === 'number' ? hiddenSeed : String(hiddenSeed), diff --git a/app/game-engine/test/uniqueLotteryCommand.test.ts b/app/game-engine/test/uniqueLotteryCommand.test.ts index c09664e..e293455 100644 --- a/app/game-engine/test/uniqueLotteryCommand.test.ts +++ b/app/game-engine/test/uniqueLotteryCommand.test.ts @@ -173,4 +173,138 @@ describe('unique lottery on general commands', () => { const logTexts = (result.logs ?? []).map((entry) => entry.text); expect(logTexts.some((text) => text.includes('【아이템】'))).toBe(true); }); + + it('does not award a unique item reserved by an active auction', async () => { + const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] }; + const generals = [buildGeneral(1)]; + const snapshot: TurnWorldSnapshot = { + generals: generals as any, + cities: [ + { + id: 1, + name: 'City_1', + nationId: 1, + viewName: 'City_1', + agriculture: 100, + agricultureMax: 2000, + commerce: 100, + commerceMax: 2000, + security: 100, + securityMax: 100, + def: 100, + defMax: 100, + wall: 100, + wallMax: 100, + pop: 10000, + popMax: 50000, + trust: 50, + supplyState: 1, + frontState: 0, + tradepoint: 0, + level: 1, + meta: {}, + }, + ] as any, + nations: [ + { + id: 1, + name: 'TestNation', + color: '#FF0000', + capitalCityId: 1, + chiefGeneralId: 1, + gold: 10000, + rice: 10000, + power: 0, + level: 1, + typeCode: 'che_def', + meta: {}, + }, + ] as any, + troops: [], + diplomacy: [], + events: [], + initialEvents: [], + map: { + id: 'test_map', + name: 'TestMap', + cities: [ + { + id: 1, + name: 'City_1', + level: 1, + region: 1, + position: { x: 0, y: 0 }, + connections: [], + max: {} as any, + initial: {} as any, + }, + ], + 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: {}, + const: { + allItems: { + weapon: { + che_무기_12_칠성검: 1, + }, + }, + maxUniqueItemLimit: [[-1, 1]], + uniqueTrialCoef: 10, + maxUniqueTrialProb: 10, + minMonthToAllowInheritItem: 0, + }, + environment: { mapName: 'test_map', unitSet: 'default' }, + }, + scenarioMeta: { + startYear: 180, + } as any, + unitSet: {} as any, + }; + const state: TurnWorldState = { + id: 1, + currentYear: 180, + currentMonth: 1, + tickSeconds: 3600, + lastTurnTime: new Date('0180-01-01T00:00:00Z'), + meta: { + hiddenSeed: 'seed', + scenarioId: 200, + initYear: 180, + initMonth: 1, + scenarioMeta: { startYear: 180 }, + }, + }; + const world = new InMemoryTurnWorld(state, snapshot, { schedule }); + const reservedTurns = new InMemoryReservedTurnStore( + { + generalTurn: { findMany: async () => [] }, + nationTurn: { findMany: async () => [] }, + } as any, + { maxGeneralTurns: 30, maxNationTurns: 12 } + ); + reservedTurns.getGeneralTurns(1)[0] = { action: 'che_훈련', args: {} }; + const handler = await createReservedTurnHandler({ + reservedTurns, + scenarioConfig: snapshot.scenarioConfig, + scenarioMeta: snapshot.scenarioMeta, + map: snapshot.map, + unitSet: snapshot.unitSet, + getWorld: () => world, + getAdditionalOccupiedUniqueItemKeys: () => ['che_무기_12_칠성검'], + }); + + const result = handler.execute({ + general: world.getGeneralById(1)!, + city: world.getCityById(1)!, + nation: world.getNationById(1)!, + world: world.getState(), + schedule, + }); + + expect(result.general?.role.items.weapon).toBeNull(); + expect((result.logs ?? []).some((entry) => entry.text.includes('【아이템】'))).toBe(false); + }); }); diff --git a/app/game-engine/test/voteReward.test.ts b/app/game-engine/test/voteReward.test.ts index e713b6f..402f9d0 100644 --- a/app/game-engine/test/voteReward.test.ts +++ b/app/game-engine/test/voteReward.test.ts @@ -223,4 +223,80 @@ describe('voteReward command', () => { const afterSecond = world.getGeneralById(1); expect(afterSecond?.gold).toBe(1500); }); + + it('treats an active unique auction as occupied when revalidating the lottery', async () => { + const general = buildGeneral(1); + const snapshot: 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: {}, + const: { + allItems: { weapon: { che_무기_12_칠성검: 1 } }, + maxUniqueItemLimit: [[-1, 1]], + uniqueTrialCoef: 10, + maxUniqueTrialProb: 10, + minMonthToAllowInheritItem: 0, + }, + environment: { mapName: 'test_map', unitSet: 'default' }, + }, + scenarioMeta: { startYear: 180 } as any, + unitSet: {} as any, + }; + const state: TurnWorldState = { + id: 1, + currentYear: 180, + currentMonth: 1, + tickSeconds: 3600, + lastTurnTime: new Date('0180-01-01T00:00:00Z'), + meta: { + hiddenSeed: 'seed', + scenarioId: 200, + initYear: 180, + initMonth: 1, + scenarioMeta: { startYear: 180 }, + }, + }; + const world = new InMemoryTurnWorld(state, snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + }); + const handler = createTurnDaemonCommandHandler({ world }); + const commandDb = { + auction: { + findMany: async () => [{ targetCode: 'che_무기_12_칠성검' }], + }, + }; + + const result = await handler.handle( + { + type: 'voteReward', + voteId: 1, + generalId: 1, + goldReward: 500, + unique: { expected: false, itemKey: null }, + }, + { db: commandDb as any } + ); + + expect(result).toMatchObject({ + type: 'voteReward', + ok: true, + awardedUnique: false, + }); + expect(world.getGeneralById(1)?.gold).toBe(1500); + expect(world.getGeneralById(1)?.role.items.weapon).toBeNull(); + }); }); diff --git a/app/game-frontend/src/views/SurveyView.vue b/app/game-frontend/src/views/SurveyView.vue index ec59caa..22e4e71 100644 --- a/app/game-frontend/src/views/SurveyView.vue +++ b/app/game-frontend/src/views/SurveyView.vue @@ -1,865 +1,682 @@