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.
This commit is contained in:
2026-02-03 18:25:26 +00:00
parent d3277f204a
commit b0a8f768e9
15 changed files with 2531 additions and 22 deletions
@@ -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,
@@ -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<Map<string, ItemModule>> | null = null;
const getItemRegistry = async (): Promise<Map<string, ItemModule>> => {
if (!itemRegistryPromise) {
itemRegistryPromise = loadItemModules([...ITEM_KEYS]).then((modules) => createItemModuleRegistry(modules));
}
return itemRegistryPromise;
};
const readMetaNumber = (meta: Record<string, unknown>, 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<TurnDaemonCommand, { type: 'voteReward' }>
): Promise<TurnDaemonCommandResult> {
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<string, TriggerValue> = {
awarded: Boolean(itemKey),
at: new Date().toISOString(),
};
if (itemKey) {
rewardEntry.itemKey = itemKey;
}
const nextVoteRewards: Record<string, TriggerValue> = {
...(existingRewards as Record<string, TriggerValue>),
[rewardKey]: rewardEntry,
};
const nextMeta: TurnGeneral['meta'] = {
...baseMeta,
voteRewards: nextVoteRewards,
};
const patch: Partial<TurnGeneral> = {
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: `<C>${itemName}</>${josaUl} 습득했습니다!`,
generalId: general.id,
meta: {},
});
world.pushLog({
scope: LogScope.GENERAL,
category: LogCategory.HISTORY,
format: LogFormat.YEAR_MONTH,
text: `<C>${itemName}</>${josaUl} 습득`,
generalId: general.id,
meta: {},
});
world.pushLog({
scope: LogScope.SYSTEM,
category: LogCategory.SUMMARY,
format: LogFormat.MONTH,
text: `<Y>${generalName}</>${josaYi} <C>${itemName}</>${josaUl} 습득했습니다!`,
meta: {},
});
world.pushLog({
scope: LogScope.SYSTEM,
category: LogCategory.HISTORY,
format: LogFormat.YEAR_MONTH,
text: `<C><b>【설문조사】</b></><D><b>${nationName}</b></>의 <Y>${generalName}</>${josaYi} <C>${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<TurnDaemonCommand, { type: 'tournamentRefund' }>),
tournamentBettingPayout: (command) => handleTournamentBettingPayout(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentBettingPayout' }>),
tournamentReward: (command) => handleTournamentReward(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentReward' }>),
voteReward: (command) => handleVoteReward(ctx, command as Extract<TurnDaemonCommand, { type: 'voteReward' }>),
setNationMeta: (command) => handleSetNationMeta(ctx, command as Extract<TurnDaemonCommand, { type: 'setNationMeta' }>),
adjustGeneralResources: (command) => handleAdjustGeneralResources(ctx, command as Extract<TurnDaemonCommand, { type: 'adjustGeneralResources' }>),
adjustGeneralMeta: (command) => handleAdjustGeneralMeta(ctx, command as Extract<TurnDaemonCommand, { type: 'adjustGeneralMeta' }>),
+217
View File
@@ -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<string, unknown>);
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<string, unknown>;
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);
});
});