From b0a8f768e97c33e7bb838090c8c984082013cd1c Mon Sep 17 00:00:00 2001 From: Hide_D Date: Tue, 3 Feb 2026 18:25:26 +0000 Subject: [PATCH] feat: add survey view with voting functionality and admin panel - Implemented SurveyView.vue for displaying and managing polls, including voting, comments, and results. - Added new database tables for vote polls, votes, and comments in migration script. - Created unique lottery logic for handling unique item rewards based on voting. - Developed unit tests for unique lottery functionality to ensure deterministic behavior and correct counting of occupied unique items. --- app/game-api/src/router.ts | 2 + app/game-api/src/router/vote/index.ts | 675 ++++++++++++++ app/game-engine/src/turn/commandRegistry.ts | 22 + .../src/turn/worldCommandHandler.ts | 240 +++++ app/game-engine/test/voteReward.test.ts | 217 +++++ app/game-frontend/src/router/index.ts | 10 + app/game-frontend/src/views/MainView.vue | 1 + app/game-frontend/src/views/SurveyView.vue | 865 ++++++++++++++++++ docs/architecture/todo.md | 1 + packages/common/src/turnDaemon/types.ts | 71 +- packages/infra/prisma/game.prisma | 52 ++ .../migration.sql | 40 + packages/logic/src/index.ts | 1 + packages/logic/src/rewards/uniqueLottery.ts | 278 ++++++ packages/logic/test/uniqueLottery.test.ts | 78 ++ 15 files changed, 2531 insertions(+), 22 deletions(-) create mode 100644 app/game-api/src/router/vote/index.ts create mode 100644 app/game-engine/test/voteReward.test.ts create mode 100644 app/game-frontend/src/views/SurveyView.vue create mode 100644 packages/infra/prisma/migrations/20260203000000_add_vote_tables/migration.sql create mode 100644 packages/logic/src/rewards/uniqueLottery.ts create mode 100644 packages/logic/test/uniqueLottery.test.ts diff --git a/app/game-api/src/router.ts b/app/game-api/src/router.ts index c0b8956..24ba073 100644 --- a/app/game-api/src/router.ts +++ b/app/game-api/src/router.ts @@ -22,6 +22,7 @@ import { diplomacyRouter } from './router/diplomacy/index.js'; import { yearbookRouter } from './router/yearbook/index.js'; import { rankingRouter } from './router/ranking/index.js'; import { dynastyRouter } from './router/dynasty/index.js'; +import { voteRouter } from './router/vote/index.js'; export const appRouter = router({ health: healthRouter, @@ -46,6 +47,7 @@ export const appRouter = router({ yearbook: yearbookRouter, ranking: rankingRouter, dynasty: dynastyRouter, + vote: voteRouter, }); export type AppRouter = typeof appRouter; diff --git a/app/game-api/src/router/vote/index.ts b/app/game-api/src/router/vote/index.ts new file mode 100644 index 0000000..15d59dc --- /dev/null +++ b/app/game-api/src/router/vote/index.ts @@ -0,0 +1,675 @@ +import { TRPCError } from '@trpc/server'; +import { z } from 'zod'; + +import { asRecord, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; +import { GamePrisma } from '@sammo-ts/infra'; +import { + ITEM_KEYS, + buildVoteUniqueSeed, + countOccupiedUniqueItems, + createItemModuleRegistry, + loadItemModules, + resolveUniqueConfig, + rollUniqueLottery, + type GeneralItemSlots, + type ItemModule, +} from '@sammo-ts/logic'; + +import { authedProcedure, router } from '../../trpc.js'; +import { getMyGeneral } from '../shared/general.js'; + +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}`); +}; + +const adminProcedure = authedProcedure.use(({ ctx, next }) => { + const roles = ctx.auth?.user.roles ?? []; + if (!hasAdminRole(roles, ctx.profile.name)) { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Admin permission is required.' }); + } + return next(); +}); + +let itemRegistryPromise: Promise> | null = null; + +const getItemRegistry = async (): Promise> => { + if (!itemRegistryPromise) { + itemRegistryPromise = loadItemModules([...ITEM_KEYS]).then((modules) => createItemModuleRegistry(modules)); + } + return itemRegistryPromise; +}; + +const resolveNumber = (source: Record, keys: string[], fallback: number): number => { + for (const key of keys) { + const value = source[key]; + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string') { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + } + return fallback; +}; + +const normalizeCode = (value: string | null | undefined): string | null => { + if (!value || value === 'None') { + return null; + } + return value; +}; + +const normalizeOptions = (options: string[]): string[] => + options + .map((option) => option.trim()) + .filter((option) => option.length > 0); + +const readMetaNumber = (meta: Record, key: string, fallback: number): number => { + const value = meta[key]; + if (typeof value === 'number' && Number.isFinite(value)) { + return Math.floor(value); + } + if (typeof value === 'string') { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return Math.floor(parsed); + } + } + return fallback; +}; + +const parseOptions = (value: unknown): string[] => { + if (Array.isArray(value)) { + return value.filter((entry): entry is string => typeof entry === 'string'); + } + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value); + if (Array.isArray(parsed)) { + return parsed.filter((entry): entry is string => typeof entry === 'string'); + } + } catch { + return []; + } + } + return []; +}; + +const parseSelection = (value: unknown): number[] => { + if (Array.isArray(value)) { + return value.map((entry) => Number(entry)).filter((entry) => Number.isFinite(entry)); + } + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value); + if (Array.isArray(parsed)) { + return parsed.map((entry) => Number(entry)).filter((entry) => Number.isFinite(entry)); + } + } catch { + return []; + } + } + return []; +}; + +type VotePollRow = { + id: number; + title: string; + body: string; + options: unknown; + multiple_options: number; + reveal_mode: string; + opener_general_id: number; + opener_name: string; + start_at: Date; + end_at: Date | null; + closed_at: Date | null; +}; + +type VoteListRow = { + id: number; + title: string; + start_at: Date; + end_at: Date | null; + closed_at: Date | null; + reveal_mode: string; + multiple_options: number; + options_count: number; +}; + +type VoteCommentRow = { + id: number; + vote_id: number; + general_id: number; + nation_id: number; + general_name: string; + nation_name: string; + text: string; + created_at: Date; +}; + +type VoteResultRow = { + selection: unknown; + cnt: number | bigint; +}; + +const zRevealMode = z.enum(['after_vote', 'after_end']); + +export const voteRouter = router({ + getVoteList: authedProcedure.query(async ({ ctx }) => { + const worldState = await ctx.db.worldState.findFirst(); + const config = asRecord(worldState?.config ?? {}); + const constValues = asRecord(config.const); + const develCost = resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0); + const voteReward = develCost * 5; + + const rows = await ctx.db.$queryRaw(GamePrisma.sql` + SELECT + id, + title, + start_at, + end_at, + closed_at, + reveal_mode, + multiple_options, + jsonb_array_length(options) AS options_count + FROM vote_poll + ORDER BY id DESC + `); + + const polls = rows.map((row) => ({ + id: row.id, + title: row.title, + startAt: row.start_at.toISOString(), + endAt: row.end_at ? row.end_at.toISOString() : null, + closedAt: row.closed_at ? row.closed_at.toISOString() : null, + revealMode: row.reveal_mode as 'after_vote' | 'after_end', + optionsCount: Number(row.options_count ?? 0), + multipleOptions: row.multiple_options, + })); + + return { polls, voteReward }; + }), + getVoteDetail: authedProcedure + .input(z.object({ voteId: z.number().int().positive() })) + .query(async ({ ctx, input }) => { + const rows = await ctx.db.$queryRaw(GamePrisma.sql` + SELECT + id, + title, + body, + options, + multiple_options, + reveal_mode, + opener_general_id, + opener_name, + start_at, + end_at, + closed_at + FROM vote_poll + WHERE id = ${input.voteId} + LIMIT 1 + `); + const row = rows[0]; + if (!row) { + throw new TRPCError({ code: 'NOT_FOUND', message: '설문조사가 없습니다.' }); + } + + const options = parseOptions(row.options); + 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 [comments, userCnt, myVoteRow] = await Promise.all([ + ctx.db.$queryRaw(GamePrisma.sql` + SELECT + id, + vote_id, + general_id, + nation_id, + general_name, + nation_name, + text, + created_at + FROM vote_comment + WHERE vote_id = ${input.voteId} + ORDER BY created_at ASC + `), + ctx.db.general.count({ where: { npcState: { lt: 2 } } }), + general + ? ctx.db.$queryRaw>(GamePrisma.sql` + SELECT selection + FROM vote + WHERE vote_id = ${input.voteId} AND general_id = ${general.id} + LIMIT 1 + `) + : Promise.resolve([]), + ]); + + const myVote = myVoteRow[0]?.selection ? parseSelection(myVoteRow[0].selection) : null; + + const canReveal = row.reveal_mode === 'after_vote' + ? Boolean(myVote) || pollEnded + : pollEnded; + + const voteResults = canReveal + ? await ctx.db.$queryRaw(GamePrisma.sql` + SELECT selection, count(*) as cnt + FROM vote + WHERE vote_id = ${input.voteId} + GROUP BY selection + `) + : []; + + const votes = voteResults.map((entry) => ({ + selection: parseSelection(entry.selection), + count: Number(entry.cnt), + })); + + return { + voteInfo: { + id: row.id, + title: row.title, + body: row.body, + options, + multipleOptions: row.multiple_options, + revealMode: row.reveal_mode as 'after_vote' | 'after_end', + openerGeneralId: row.opener_general_id, + openerName: row.opener_name, + startAt: row.start_at.toISOString(), + endAt: row.end_at ? row.end_at.toISOString() : null, + closedAt: row.closed_at ? row.closed_at.toISOString() : null, + }, + votes, + comments: comments.map((comment) => ({ + id: comment.id, + voteId: comment.vote_id, + generalId: comment.general_id, + nationId: comment.nation_id, + generalName: comment.general_name, + nationName: comment.nation_name, + text: comment.text, + createdAt: comment.created_at.toISOString(), + })), + myVote, + userCnt, + }; + }), + submitVote: authedProcedure + .input( + z.object({ + voteId: z.number().int().positive(), + selection: z.array(z.number().int()), + }) + ) + .mutation(async ({ ctx, input }) => { + const selection = input.selection; + if (!selection || selection.length === 0) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '선택한 항목이 없습니다.' }); + } + + const pollRows = await ctx.db.$queryRaw(GamePrisma.sql` + SELECT + id, + title, + body, + options, + multiple_options, + reveal_mode, + opener_general_id, + opener_name, + start_at, + end_at, + closed_at + FROM vote_poll + WHERE id = ${input.voteId} + LIMIT 1 + `); + const poll = pollRows[0]; + if (!poll) { + throw new TRPCError({ code: 'NOT_FOUND', message: '설문조사가 없습니다.' }); + } + if (poll.closed_at || (poll.end_at && poll.end_at < new Date())) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '설문조사가 종료되었습니다.' }); + } + + const options = parseOptions(poll.options); + if (poll.multiple_options >= 1 && selection.length > poll.multiple_options) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '선택한 항목이 너무 많습니다.' }); + } + + const optionCount = options.length; + for (const value of selection) { + if (!Number.isFinite(value) || value < 0 || value >= optionCount) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '선택한 항목이 없습니다.' }); + } + } + + const sortedSelection = [...selection].sort((a, b) => a - b); + const general = await getMyGeneral(ctx); + + const rows = await ctx.db.$queryRaw>(GamePrisma.sql` + INSERT INTO vote (vote_id, general_id, nation_id, selection) + VALUES ( + ${input.voteId}, + ${general.id}, + ${general.nationId}, + CAST(${JSON.stringify(sortedSelection)} AS jsonb) + ) + ON CONFLICT (vote_id, general_id) DO NOTHING + RETURNING id + `); + + if (!rows[0]?.id) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 설문조사를 완료하였습니다.' }); + } + + const worldState = await ctx.db.worldState.findFirst(); + if (!worldState) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state is not initialized.' }); + } + + const config = asRecord(worldState.config); + const constValues = asRecord(config.const); + const develCost = resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0); + const voteReward = develCost * 5; + + const worldMeta = asRecord(worldState.meta); + const scenarioMeta = asRecord(worldMeta.scenarioMeta); + const startYear = readMetaNumber(scenarioMeta, 'startYear', worldState.currentYear); + const initYear = readMetaNumber(worldMeta, 'initYear', startYear); + const initMonth = readMetaNumber(worldMeta, 'initMonth', 1); + const scenarioId = readMetaNumber(worldMeta, 'scenarioId', 0); + const hiddenSeed = worldMeta.hiddenSeed ?? worldMeta.seed ?? worldState.id; + + 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 generalItems: GeneralItemSlots[] = generalRows.map((row) => ({ + horse: normalizeCode(row.horseCode), + weapon: normalizeCode(row.weaponCode), + book: normalizeCode(row.bookCode), + item: normalizeCode(row.itemCode), + })); + + const occupiedUniqueCounts = countOccupiedUniqueItems(generalItems, itemRegistry); + const userCount = await ctx.db.general.count({ where: { npcState: { lt: 2 } } }); + + const rngSeed = buildVoteUniqueSeed( + typeof hiddenSeed === 'string' || typeof hiddenSeed === 'number' ? hiddenSeed : String(hiddenSeed), + input.voteId, + general.id + ); + const rng = new RandUtil(LiteHashDRBG.build(rngSeed)); + const itemKey = rollUniqueLottery({ + rng, + config: uniqueConfig, + itemRegistry, + generalItems: { + horse: normalizeCode(general.horseCode), + weapon: normalizeCode(general.weaponCode), + book: normalizeCode(general.bookCode), + item: normalizeCode(general.itemCode), + }, + occupiedUniqueCounts, + scenarioId, + userCount, + currentYear: worldState.currentYear, + currentMonth: worldState.currentMonth, + startYear, + initYear, + initMonth, + acquireType: '설문조사', + }); + + const rewardResult = await ctx.turnDaemon.requestCommand({ + type: 'voteReward', + voteId: input.voteId, + generalId: general.id, + goldReward: voteReward, + unique: { + expected: Boolean(itemKey), + itemKey: itemKey ?? null, + }, + }); + + if (!rewardResult || rewardResult.type !== 'voteReward') { + throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' }); + } + if (!rewardResult.ok) { + throw new TRPCError({ code: 'BAD_REQUEST', message: rewardResult.reason }); + } + + return { ok: true, wonLottery: rewardResult.awardedUnique }; + }), + addComment: authedProcedure + .input( + z.object({ + voteId: z.number().int().positive(), + text: z.string().trim().max(200), + }) + ) + .mutation(async ({ ctx, input }) => { + if (!input.text) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '내용이 필요합니다.' }); + } + + const pollRows = await ctx.db.$queryRaw>(GamePrisma.sql` + SELECT id + FROM vote_poll + WHERE id = ${input.voteId} + LIMIT 1 + `); + if (!pollRows[0]?.id) { + throw new TRPCError({ code: 'NOT_FOUND', message: '설문조사가 없습니다.' }); + } + + const general = await getMyGeneral(ctx); + const nation = general.nationId + ? await ctx.db.nation.findFirst({ where: { id: general.nationId }, select: { name: true } }) + : null; + const nationName = nation?.name ?? '재야'; + + await ctx.db.$queryRaw(GamePrisma.sql` + INSERT INTO vote_comment (vote_id, general_id, nation_id, general_name, nation_name, text) + VALUES (${input.voteId}, ${general.id}, ${general.nationId}, ${general.name}, ${nationName}, ${input.text}) + `); + + return { ok: true }; + }), + createPoll: adminProcedure + .input( + z.object({ + title: z.string().trim().min(1).max(200), + body: z.string().trim().max(5000).optional().default(''), + options: z.array(z.string()).default([]), + multipleOptions: z.number().int().optional().default(1), + endAt: z.string().optional(), + revealMode: zRevealMode, + closePrevious: z.boolean().optional().default(true), + }) + ) + .mutation(async ({ ctx, input }) => { + const general = await getMyGeneral(ctx); + const options = normalizeOptions(input.options); + if (options.length === 0) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '항목이 없습니다.' }); + } + + const endAt = input.endAt ? new Date(input.endAt) : null; + if (endAt && Number.isNaN(endAt.getTime())) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 잘못되었습니다.' }); + } + if (endAt && endAt < new Date()) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' }); + } + + let multipleOptions = input.multipleOptions; + if (multipleOptions < 0) { + multipleOptions = 0; + } + if (multipleOptions > options.length) { + multipleOptions = options.length; + } + + if (input.closePrevious) { + await ctx.db.$queryRaw(GamePrisma.sql` + UPDATE vote_poll + SET closed_at = NOW(), updated_at = NOW() + WHERE closed_at IS NULL + `); + } + + await ctx.db.$queryRaw(GamePrisma.sql` + INSERT INTO vote_poll ( + title, + body, + options, + multiple_options, + reveal_mode, + opener_general_id, + opener_name, + start_at, + end_at + ) + VALUES ( + ${input.title}, + ${input.body ?? ''}, + CAST(${JSON.stringify(options)} AS jsonb), + ${multipleOptions}, + ${input.revealMode}, + ${general.id}, + ${general.name}, + NOW(), + ${endAt} + ) + `); + + return { ok: true }; + }), + updatePoll: adminProcedure + .input( + z.object({ + voteId: z.number().int().positive(), + title: z.string().trim().min(1).max(200).optional(), + body: z.string().trim().max(5000).optional(), + appendOptions: z.array(z.string()).optional(), + multipleOptions: z.number().int().optional(), + endAt: z.string().optional(), + revealMode: zRevealMode.optional(), + }) + ) + .mutation(async ({ ctx, input }) => { + const pollRows = await ctx.db.$queryRaw(GamePrisma.sql` + SELECT + id, + title, + body, + options, + multiple_options, + reveal_mode, + opener_general_id, + opener_name, + start_at, + end_at, + closed_at + FROM vote_poll + WHERE id = ${input.voteId} + LIMIT 1 + `); + const poll = pollRows[0]; + if (!poll) { + throw new TRPCError({ code: 'NOT_FOUND', message: '설문조사가 없습니다.' }); + } + + const voteCountRow = await ctx.db.$queryRaw>(GamePrisma.sql` + SELECT count(*) as cnt + FROM vote + WHERE vote_id = ${input.voteId} + `); + const voteCount = Number(voteCountRow[0]?.cnt ?? 0); + if (voteCount > 0) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 설문조사가 진행중입니다.' }); + } + + const existingOptions = parseOptions(poll.options); + const appendedOptions = normalizeOptions(input.appendOptions ?? []); + const nextOptions = appendedOptions.length > 0 ? [...existingOptions, ...appendedOptions] : existingOptions; + + let nextMultipleOptions = input.multipleOptions; + if (nextMultipleOptions !== undefined) { + if (nextMultipleOptions < 0) { + nextMultipleOptions = 0; + } + if (nextMultipleOptions > nextOptions.length) { + nextMultipleOptions = nextOptions.length; + } + } + + const endAt = input.endAt ? new Date(input.endAt) : undefined; + if (endAt && Number.isNaN(endAt.getTime())) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 잘못되었습니다.' }); + } + if (endAt && endAt < new Date()) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' }); + } + + if ( + input.title === undefined && + input.body === undefined && + appendedOptions.length === 0 && + nextMultipleOptions === undefined && + endAt === undefined && + input.revealMode === undefined + ) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '변경할 항목이 없습니다.' }); + } + + await ctx.db.$queryRaw(GamePrisma.sql` + UPDATE vote_poll + SET + title = COALESCE(${input.title}, title), + body = COALESCE(${input.body}, body), + options = ${appendedOptions.length > 0 ? GamePrisma.sql`CAST(${JSON.stringify(nextOptions)} AS jsonb)` : GamePrisma.sql`options`}, + multiple_options = COALESCE(${nextMultipleOptions}, multiple_options), + reveal_mode = COALESCE(${input.revealMode}, reveal_mode), + end_at = ${endAt ?? poll.end_at}, + updated_at = NOW() + WHERE id = ${input.voteId} + `); + + return { ok: true }; + }), + closePoll: adminProcedure + .input(z.object({ voteId: z.number().int().positive() })) + .mutation(async ({ ctx, input }) => { + const rows = await ctx.db.$queryRaw>(GamePrisma.sql` + UPDATE vote_poll + SET closed_at = NOW(), updated_at = NOW() + WHERE id = ${input.voteId} + RETURNING id + `); + if (!rows[0]?.id) { + throw new TRPCError({ code: 'NOT_FOUND', message: '설문조사가 없습니다.' }); + } + return { ok: true }; + }), + getAdminStatus: adminProcedure.query(async () => ({ ok: true })), +}); diff --git a/app/game-engine/src/turn/commandRegistry.ts b/app/game-engine/src/turn/commandRegistry.ts index 8440b2d..1f535ef 100644 --- a/app/game-engine/src/turn/commandRegistry.ts +++ b/app/game-engine/src/turn/commandRegistry.ts @@ -141,6 +141,19 @@ const zTournamentReward = z.object({ top4: z.array(zFiniteNumber), }); +const zVoteReward = z.object({ + type: z.literal('voteReward'), + voteId: zFiniteNumber, + generalId: zFiniteNumber, + goldReward: zFiniteNumber, + unique: z + .object({ + expected: z.boolean(), + itemKey: z.string().nullable().optional(), + }) + .optional(), +}); + const zSetNationMeta = z.object({ type: z.literal('setNationMeta'), nationId: zFiniteNumber, @@ -358,6 +371,14 @@ const normalizeTournamentReward: CommandNormalizer<'tournamentReward'> = (envelo return { ...command, requestId: envelope.requestId }; }; +const normalizeVoteReward: CommandNormalizer<'voteReward'> = (envelope) => { + const command = parseWith(zVoteReward, envelope.command); + if (!command) { + return null; + } + return { ...command, requestId: envelope.requestId }; +}; + const normalizeSetNationMeta: CommandNormalizer<'setNationMeta'> = (envelope) => { const command = parseWith(zSetNationMeta, envelope.command); if (!command) { @@ -431,6 +452,7 @@ const normalizers: CommandNormalizerMap = { tournamentRefund: normalizeTournamentRefund, tournamentBettingPayout: normalizeTournamentBettingPayout, tournamentReward: normalizeTournamentReward, + voteReward: normalizeVoteReward, setNationMeta: normalizeSetNationMeta, adjustGeneralResources: normalizeAdjustGeneralResources, adjustGeneralMeta: normalizeAdjustGeneralMeta, diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index f7de0cb..900fb4b 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -5,6 +5,21 @@ import type { TurnDaemonCommandResult, TurnRunResult, } from '../lifecycle/types.js'; +import { asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; +import { + LogCategory, + LogFormat, + LogScope, + ITEM_KEYS, + buildVoteUniqueSeed, + countOccupiedUniqueItems, + createItemModuleRegistry, + loadItemModules, + resolveUniqueConfig, + rollUniqueLottery, + type ItemModule, + type TriggerValue, +} from '@sammo-ts/logic'; import type { InMemoryTurnWorld } from './inMemoryWorld.js'; import type { TurnGeneral } from './types.js'; @@ -27,6 +42,29 @@ const flushWorld = async (world: InMemoryTurnWorld, hooks?: TurnDaemonHooks): Pr await hooks.flushChanges(buildFlushResult(world)); }; +let itemRegistryPromise: Promise> | null = null; + +const getItemRegistry = async (): Promise> => { + if (!itemRegistryPromise) { + itemRegistryPromise = loadItemModules([...ITEM_KEYS]).then((modules) => createItemModuleRegistry(modules)); + } + return itemRegistryPromise; +}; + +const readMetaNumber = (meta: Record, key: string, fallback: number): number => { + const value = meta[key]; + if (typeof value === 'number' && Number.isFinite(value)) { + return Math.floor(value); + } + if (typeof value === 'string') { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return Math.floor(parsed); + } + } + return fallback; +}; + interface CommandHandlerContext { world: InMemoryTurnWorld; hooks?: TurnDaemonHooks; @@ -925,6 +963,207 @@ async function handleTournamentReward( return ctx.tournamentRewardFinalizer.finalize(command); } +// 설문 보상은 API에서 전달된 RNG 결과를 재검증한 뒤 월드에 반영한다. +async function handleVoteReward( + ctx: CommandHandlerContext, + command: Extract +): Promise { + const { world, hooks } = ctx; + const general = world.getGeneralById(command.generalId); + if (!general) { + return { + type: 'voteReward', + ok: false, + voteId: command.voteId, + generalId: command.generalId, + reason: '장수 정보를 찾을 수 없습니다.', + }; + } + + const baseMeta = general.meta; + const metaRecord = asRecord(baseMeta); + const existingRewards = asRecord(metaRecord.voteRewards); + const rewardKey = String(command.voteId); + if (Object.prototype.hasOwnProperty.call(existingRewards, rewardKey)) { + const existingValue = existingRewards[rewardKey]; + const existingEntry = asRecord(existingValue); + const existingItemKey = typeof existingEntry.itemKey === 'string' ? existingEntry.itemKey : null; + const awarded = existingEntry.awarded === true || Boolean(existingItemKey); + return { + type: 'voteReward', + ok: true, + voteId: command.voteId, + generalId: command.generalId, + awardedUnique: awarded, + itemKey: existingItemKey ?? null, + alreadyApplied: true, + }; + } + + const worldState = world.getState(); + const worldMeta = asRecord(worldState.meta); + const scenarioMeta = asRecord(worldMeta.scenarioMeta); + const startYear = readMetaNumber(scenarioMeta, 'startYear', worldState.currentYear); + const initYear = readMetaNumber(worldMeta, 'initYear', startYear); + const initMonth = readMetaNumber(worldMeta, 'initMonth', 1); + const scenarioId = readMetaNumber(worldMeta, 'scenarioId', 0); + const hiddenSeed = worldMeta.hiddenSeed ?? worldMeta.seed ?? worldState.id; + + const itemRegistry = await getItemRegistry(); + const configConst = asRecord(world.getScenarioConfig().const); + const uniqueConfig = resolveUniqueConfig(configConst); + const generals = world.listGenerals(); + const occupiedUniqueCounts = countOccupiedUniqueItems( + generals.map((entry) => entry.role.items), + itemRegistry + ); + const userCount = generals.filter((entry) => entry.npcState < 2).length; + const rngSeed = buildVoteUniqueSeed( + typeof hiddenSeed === 'string' || typeof hiddenSeed === 'number' ? hiddenSeed : String(hiddenSeed), + command.voteId, + command.generalId + ); + const rng = new RandUtil(LiteHashDRBG.build(rngSeed)); + const itemKey = rollUniqueLottery({ + rng, + config: uniqueConfig, + itemRegistry, + generalItems: general.role.items, + occupiedUniqueCounts, + scenarioId, + userCount, + currentYear: worldState.currentYear, + currentMonth: worldState.currentMonth, + startYear, + initYear, + initMonth, + acquireType: '설문조사', + }); + + const expectedUnique = command.unique?.expected ?? false; + const expectedItemKey = command.unique?.itemKey ?? null; + if (expectedUnique !== Boolean(itemKey)) { + return { + type: 'voteReward', + ok: false, + voteId: command.voteId, + generalId: command.generalId, + reason: '유니크 판정이 일치하지 않습니다.', + }; + } + if (expectedUnique) { + if (!expectedItemKey || !itemKey || expectedItemKey !== itemKey) { + return { + type: 'voteReward', + ok: false, + voteId: command.voteId, + generalId: command.generalId, + reason: '유니크 판정이 일치하지 않습니다.', + }; + } + } else if (itemKey) { + return { + type: 'voteReward', + ok: false, + voteId: command.voteId, + generalId: command.generalId, + reason: '유니크 판정이 일치하지 않습니다.', + }; + } + + const rewardEntry: Record = { + awarded: Boolean(itemKey), + at: new Date().toISOString(), + }; + if (itemKey) { + rewardEntry.itemKey = itemKey; + } + + const nextVoteRewards: Record = { + ...(existingRewards as Record), + [rewardKey]: rewardEntry, + }; + + const nextMeta: TurnGeneral['meta'] = { + ...baseMeta, + voteRewards: nextVoteRewards, + }; + + const patch: Partial = { + gold: general.gold + command.goldReward, + meta: nextMeta, + }; + + if (itemKey) { + const itemModule = itemRegistry.get(itemKey); + if (!itemModule) { + return { + type: 'voteReward', + ok: false, + voteId: command.voteId, + generalId: command.generalId, + reason: '유니크 아이템을 찾을 수 없습니다.', + }; + } + patch.role = { + ...general.role, + items: { + ...general.role.items, + [itemModule.slot]: itemKey, + }, + }; + + const nationName = world.getNationById(general.nationId)?.name ?? '재야'; + const generalName = general.name; + const itemName = itemModule.name; + const itemRawName = itemModule.rawName; + const josaYi = JosaUtil.pick(generalName, '이'); + const josaUl = JosaUtil.pick(itemRawName, '을'); + + world.pushLog({ + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + text: `${itemName}${josaUl} 습득했습니다!`, + generalId: general.id, + meta: {}, + }); + world.pushLog({ + scope: LogScope.GENERAL, + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + text: `${itemName}${josaUl} 습득`, + generalId: general.id, + meta: {}, + }); + world.pushLog({ + scope: LogScope.SYSTEM, + category: LogCategory.SUMMARY, + format: LogFormat.MONTH, + text: `${generalName}${josaYi} ${itemName}${josaUl} 습득했습니다!`, + meta: {}, + }); + world.pushLog({ + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + text: `【설문조사】${nationName}${generalName}${josaYi} ${itemName}${josaUl} 습득했습니다!`, + meta: {}, + }); + } + + world.updateGeneral(command.generalId, patch); + await flushWorld(world, hooks); + return { + type: 'voteReward', + ok: true, + voteId: command.voteId, + generalId: command.generalId, + awardedUnique: Boolean(itemKey), + ...(itemKey ? { itemKey } : {}), + }; +} + export const createTurnDaemonCommandHandler = (options: { world: InMemoryTurnWorld; hooks?: TurnDaemonHooks; @@ -959,6 +1198,7 @@ export const createTurnDaemonCommandHandler = (options: { tournamentRefund: (command) => handleTournamentRefund(ctx, command as Extract), tournamentBettingPayout: (command) => handleTournamentBettingPayout(ctx, command as Extract), tournamentReward: (command) => handleTournamentReward(ctx, command as Extract), + voteReward: (command) => handleVoteReward(ctx, command as Extract), setNationMeta: (command) => handleSetNationMeta(ctx, command as Extract), adjustGeneralResources: (command) => handleAdjustGeneralResources(ctx, command as Extract), adjustGeneralMeta: (command) => handleAdjustGeneralMeta(ctx, command as Extract), diff --git a/app/game-engine/test/voteReward.test.ts b/app/game-engine/test/voteReward.test.ts new file mode 100644 index 0000000..dfa1a6a --- /dev/null +++ b/app/game-engine/test/voteReward.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it } from 'vitest'; + +import { LiteHashDRBG, RandUtil } from '@sammo-ts/common'; +import { + ITEM_KEYS, + buildVoteUniqueSeed, + countOccupiedUniqueItems, + createItemModuleRegistry, + loadItemModules, + resolveUniqueConfig, + rollUniqueLottery, +} from '@sammo-ts/logic'; + +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; +import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js'; + +const buildGeneral = (id: number): TurnGeneral => ({ + id, + name: `General_${id}`, + nationId: 1, + cityId: 1, + troopId: 0, + stats: { leadership: 50, strength: 50, intelligence: 50 }, + turnTime: new Date('0180-01-01T00:00:00Z'), + role: { + items: { horse: null, weapon: null, book: null, item: null }, + personality: null, + specialDomestic: null, + specialWar: null, + }, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { killturn: 24 }, + officerLevel: 1, + experience: 0, + dedication: 0, + injury: 0, + gold: 1000, + rice: 1000, + crew: 0, + crewTypeId: 0, + train: 0, + atmos: 0, + age: 30, + npcState: 0, +}); + +describe('voteReward command', () => { + it('applies gold, unique item, logs, and idempotency', async () => { + 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: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + }); + + const itemRegistry = createItemModuleRegistry(await loadItemModules([...ITEM_KEYS])); + const config = resolveUniqueConfig(snapshot.scenarioConfig.const as Record); + const occupied = countOccupiedUniqueItems(generals.map((general) => general.role.items), itemRegistry); + const rng = new RandUtil(LiteHashDRBG.build(buildVoteUniqueSeed('seed', 1, 1))); + const itemKey = rollUniqueLottery({ + rng, + config, + itemRegistry, + generalItems: generals[0]!.role.items, + occupiedUniqueCounts: occupied, + scenarioId: 200, + userCount: 1, + currentYear: 180, + currentMonth: 1, + startYear: 180, + initYear: 180, + initMonth: 1, + acquireType: '설문조사', + }); + + expect(itemKey).toBe('che_무기_12_칠성검'); + + const handler = createTurnDaemonCommandHandler({ world }); + const command = { + type: 'voteReward' as const, + voteId: 1, + generalId: 1, + goldReward: 500, + unique: { + expected: true, + itemKey, + }, + }; + + const result = await handler.handle(command); + expect(result && result.type).toBe('voteReward'); + if (!result || result.type !== 'voteReward' || !result.ok) { + throw new Error('voteReward result missing'); + } + expect(result.awardedUnique).toBe(true); + + const updated = world.getGeneralById(1); + expect(updated?.gold).toBe(1500); + expect(updated?.role.items.weapon).toBe('che_무기_12_칠성검'); + const meta = updated?.meta as Record; + expect(meta?.voteRewards).toBeTruthy(); + + const diff = world.consumeDirtyState(); + const logTexts = diff.logs.map((entry) => entry.text); + expect(logTexts.some((text) => text.includes('【설문조사】'))).toBe(true); + + const second = await handler.handle(command); + expect(second && second.type).toBe('voteReward'); + if (!second || second.type !== 'voteReward' || !second.ok) { + throw new Error('voteReward second result missing'); + } + expect(second.alreadyApplied).toBe(true); + const afterSecond = world.getGeneralById(1); + expect(afterSecond?.gold).toBe(1500); + }); +}); diff --git a/app/game-frontend/src/router/index.ts b/app/game-frontend/src/router/index.ts index cce04df..a81233d 100644 --- a/app/game-frontend/src/router/index.ts +++ b/app/game-frontend/src/router/index.ts @@ -24,6 +24,7 @@ import BestGeneralView from '../views/BestGeneralView.vue'; import HallOfFameView from '../views/HallOfFameView.vue'; import DynastyListView from '../views/DynastyListView.vue'; import DynastyDetailView from '../views/DynastyDetailView.vue'; +import SurveyView from '../views/SurveyView.vue'; import { useSessionStore } from '../stores/session'; const routes = [ @@ -199,6 +200,15 @@ const routes = [ requiresGeneral: true, }, }, + { + path: '/survey', + name: 'survey', + component: SurveyView, + meta: { + requiresAuth: true, + requiresGeneral: true, + }, + }, { path: '/my-settings', name: 'my-settings', diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index e1eea92..d2e5f27 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -105,6 +105,7 @@ watch( 전투 시뮬레이터 내 정보 토너먼트 + 설문조사 NPC 정책 유산 강화