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
+49 -22
View File
@@ -115,14 +115,25 @@ export type TurnDaemonCommand =
top16: number[];
top8: number[];
top4: number[];
}
| {
type: 'setNationMeta';
requestId?: string;
nationId: number;
updates: Record<string, unknown>;
expectedUpdatedAt?: string;
}
}
| {
type: 'voteReward';
requestId?: string;
voteId: number;
generalId: number;
goldReward: number;
unique?: {
expected: boolean;
itemKey?: string | null;
};
}
| {
type: 'setNationMeta';
requestId?: string;
nationId: number;
updates: Record<string, unknown>;
expectedUpdatedAt?: string;
}
| {
type: 'adjustGeneralResources';
requestId?: string;
@@ -264,20 +275,36 @@ export type TurnDaemonCommandResult =
winnerId: number;
runnerUpId: number;
reason: string;
}
| {
type: 'setNationMeta';
ok: true;
nationId: number;
updatedAt: string;
}
| {
type: 'setNationMeta';
ok: false;
nationId: number;
reason: string;
currentUpdatedAt?: string;
}
}
| {
type: 'voteReward';
ok: true;
voteId: number;
generalId: number;
awardedUnique: boolean;
itemKey?: string | null;
alreadyApplied?: boolean;
}
| {
type: 'voteReward';
ok: false;
voteId: number;
generalId: number;
reason: string;
}
| {
type: 'setNationMeta';
ok: true;
nationId: number;
updatedAt: string;
}
| {
type: 'setNationMeta';
ok: false;
nationId: number;
reason: string;
currentUpdatedAt?: string;
}
| {
type: 'adjustGeneralResources';
ok: true;
+52
View File
@@ -514,3 +514,55 @@ model BoardComment {
@@index([postId, createdAt])
@@map("board_comment")
}
model VotePoll {
id Int @id @default(autoincrement())
title String
body String @default("")
options Json
multipleOptions Int @default(1) @map("multiple_options")
revealMode String @map("reveal_mode")
openerGeneralId Int @map("opener_general_id")
openerName String @map("opener_name")
startAt DateTime @default(now()) @map("start_at")
endAt DateTime? @map("end_at")
closedAt DateTime? @map("closed_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
votes Vote[]
comments VoteComment[]
@@map("vote_poll")
}
model Vote {
id Int @id @default(autoincrement())
voteId Int @map("vote_id")
generalId Int @map("general_id")
nationId Int @map("nation_id")
selection Json
createdAt DateTime @default(now()) @map("created_at")
poll VotePoll @relation(fields: [voteId], references: [id], onDelete: Cascade)
@@unique([voteId, generalId])
@@index([voteId])
@@map("vote")
}
model VoteComment {
id Int @id @default(autoincrement())
voteId Int @map("vote_id")
generalId Int @map("general_id")
nationId Int @map("nation_id")
generalName String @map("general_name")
nationName String @map("nation_name")
text String
createdAt DateTime @default(now()) @map("created_at")
poll VotePoll @relation(fields: [voteId], references: [id], onDelete: Cascade)
@@index([voteId, createdAt])
@@map("vote_comment")
}
@@ -0,0 +1,40 @@
CREATE TABLE "vote_poll" (
"id" SERIAL PRIMARY KEY,
"title" TEXT NOT NULL,
"body" TEXT NOT NULL DEFAULT '',
"options" JSONB NOT NULL,
"multiple_options" INTEGER NOT NULL DEFAULT 1,
"reveal_mode" TEXT NOT NULL,
"opener_general_id" INTEGER NOT NULL,
"opener_name" TEXT NOT NULL,
"start_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"end_at" TIMESTAMP(3) NULL,
"closed_at" TIMESTAMP(3) NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE "vote" (
"id" SERIAL PRIMARY KEY,
"vote_id" INTEGER NOT NULL REFERENCES "vote_poll"("id") ON DELETE CASCADE,
"general_id" INTEGER NOT NULL,
"nation_id" INTEGER NOT NULL,
"selection" JSONB NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX "vote_vote_general_uidx" ON "vote"("vote_id", "general_id");
CREATE INDEX "vote_vote_idx" ON "vote"("vote_id");
CREATE TABLE "vote_comment" (
"id" SERIAL PRIMARY KEY,
"vote_id" INTEGER NOT NULL REFERENCES "vote_poll"("id") ON DELETE CASCADE,
"general_id" INTEGER NOT NULL,
"nation_id" INTEGER NOT NULL,
"general_name" TEXT NOT NULL,
"nation_name" TEXT NOT NULL,
"text" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX "vote_comment_vote_created_idx" ON "vote_comment"("vote_id", "created_at");
+1
View File
@@ -8,6 +8,7 @@ export * from './logging/index.js';
export * from './messages/index.js';
export * from './items/index.js';
export { ITEM_KEYS, createItemActionModules, createItemModuleRegistry, loadItemModules } from './items/index.js';
export * from './rewards/uniqueLottery.js';
export * from './inheritance/inheritBuff.js';
export * from './resources/index.js';
export * from './ports/world.js';
+278
View File
@@ -0,0 +1,278 @@
import { asRecord, parseJson, type RandUtil } from '@sammo-ts/common';
import type { GeneralItemSlots } from '../domain/entities.js';
import type { ItemModule } from '../items/types.js';
export type UniqueItemPool = Record<string, Record<string, number>>;
export type UniqueLotteryConfig = {
allItems: UniqueItemPool;
maxUniqueItemLimit: Array<[number, number]>;
uniqueTrialCoef: number;
maxUniqueTrialProb: number;
minMonthToAllowInheritItem: number;
};
export type UniqueLotteryInput = {
rng: RandUtil;
config: UniqueLotteryConfig;
itemRegistry: Map<string, ItemModule>;
generalItems: GeneralItemSlots;
occupiedUniqueCounts: Map<string, number>;
scenarioId: number;
userCount: number;
currentYear: number;
currentMonth: number;
startYear: number;
initYear: number;
initMonth: number;
acquireType?: string;
inheritRandomUnique?: boolean;
};
const DEFAULT_MAX_UNIQUE_ITEM_LIMIT: Array<[number, number]> = [
[-1, 1],
[3, 2],
[10, 3],
[20, 4],
];
const DEFAULT_UNIQUE_TRIAL_COEF = 1;
const DEFAULT_MAX_UNIQUE_TRIAL_PROB = 0.25;
const DEFAULT_MIN_MONTH_TO_ALLOW_INHERIT_ITEM = 4;
const readNumber = (value: unknown, fallback: number): number => {
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 normalizeItemPool = (value: unknown): UniqueItemPool => {
if (typeof value === 'string') {
const parsed = parseJson<UniqueItemPool>(value);
return normalizeItemPool(parsed);
}
const record = asRecord(value);
const result: UniqueItemPool = {};
for (const [itemType, rawItems] of Object.entries(record)) {
const rawEntries = asRecord(rawItems);
const entries: Record<string, number> = {};
for (const [itemKey, rawCount] of Object.entries(rawEntries)) {
const count = readNumber(rawCount, Number.NaN);
if (!Number.isFinite(count)) {
continue;
}
entries[itemKey] = Math.floor(count);
}
result[itemType] = entries;
}
return result;
};
const normalizeLimitTable = (value: unknown): Array<[number, number]> => {
if (!Array.isArray(value)) {
return [...DEFAULT_MAX_UNIQUE_ITEM_LIMIT];
}
const result: Array<[number, number]> = [];
for (const entry of value) {
if (!Array.isArray(entry) || entry.length < 2) {
continue;
}
const year = readNumber(entry[0], Number.NaN);
const limit = readNumber(entry[1], Number.NaN);
if (!Number.isFinite(year) || !Number.isFinite(limit)) {
continue;
}
result.push([Math.floor(year), Math.floor(limit)]);
}
return result.length > 0 ? result : [...DEFAULT_MAX_UNIQUE_ITEM_LIMIT];
};
export const resolveUniqueConfig = (configConst: Record<string, unknown>): UniqueLotteryConfig => {
const allItems = normalizeItemPool(configConst.allItems);
return {
allItems,
maxUniqueItemLimit: normalizeLimitTable(configConst.maxUniqueItemLimit),
uniqueTrialCoef: readNumber(configConst.uniqueTrialCoef, DEFAULT_UNIQUE_TRIAL_COEF),
maxUniqueTrialProb: readNumber(configConst.maxUniqueTrialProb, DEFAULT_MAX_UNIQUE_TRIAL_PROB),
minMonthToAllowInheritItem: Math.max(
0,
Math.floor(readNumber(configConst.minMonthToAllowInheritItem, DEFAULT_MIN_MONTH_TO_ALLOW_INHERIT_ITEM))
),
};
};
export const countOccupiedUniqueItems = (
generals: GeneralItemSlots[],
itemRegistry: Map<string, ItemModule>
): Map<string, number> => {
const counts = new Map<string, number>();
for (const items of generals) {
const values = [items.horse, items.weapon, items.book, items.item];
for (const itemKey of values) {
if (!itemKey) {
continue;
}
const module = itemRegistry.get(itemKey);
if (!module || module.buyable) {
continue;
}
counts.set(itemKey, (counts.get(itemKey) ?? 0) + 1);
}
}
return counts;
};
const joinYearMonth = (year: number, month: number): number => year * 12 + month - 1;
const serializeSeed = (...values: Array<string | number>): string =>
values
.map((value) => (typeof value === 'string' ? `str(${value.length},${value})` : `int(${Math.floor(value)})`))
.join('|');
export const buildVoteUniqueSeed = (hiddenSeed: string | number, voteId: number, generalId: number): string =>
serializeSeed(hiddenSeed, 'voteUnique', voteId, generalId);
export const rollUniqueLottery = (input: UniqueLotteryInput): string | null => {
const {
rng,
config,
itemRegistry,
generalItems,
occupiedUniqueCounts,
scenarioId,
userCount,
currentYear,
currentMonth,
startYear,
initYear,
initMonth,
acquireType,
inheritRandomUnique,
} = input;
if (userCount <= 0) {
return null;
}
const itemTypes = Object.keys(config.allItems);
const itemTypeCnt = itemTypes.length;
if (itemTypeCnt <= 0) {
return null;
}
const relYear = currentYear - startYear;
let maxTrialCountByYear = 1;
for (const [targetYear, targetTrialCnt] of config.maxUniqueItemLimit) {
if (relYear < targetYear) {
break;
}
maxTrialCountByYear = targetTrialCnt;
}
let trialCnt = Math.min(itemTypeCnt, maxTrialCountByYear);
let maxCnt = itemTypeCnt;
const invalidItemTypes = new Set<string>();
const equippedItems: Array<[string, string | null]> = [
['horse', generalItems.horse],
['weapon', generalItems.weapon],
['book', generalItems.book],
['item', generalItems.item],
];
for (const [slot, itemKey] of equippedItems) {
if (!itemKey) {
continue;
}
const module = itemRegistry.get(itemKey);
if (!module || module.buyable) {
continue;
}
invalidItemTypes.add(slot);
trialCnt -= 1;
maxCnt -= 1;
}
if (trialCnt <= 0 || maxCnt <= 0) {
return null;
}
const relMonthByInit =
joinYearMonth(currentYear, currentMonth) - joinYearMonth(initYear, initMonth);
const availableBuyUnique = relMonthByInit >= config.minMonthToAllowInheritItem;
let prob: number;
if (scenarioId < 100) {
prob = 1 / (userCount * 3 * itemTypeCnt);
} else {
prob = 1 / (userCount * itemTypeCnt);
}
if (acquireType === '설문조사') {
prob = 1 / (userCount * itemTypeCnt * 0.7 / 3);
} else if (acquireType === '랜덤 임관') {
prob = 1 / (userCount * itemTypeCnt / 10 / 2);
}
prob *= config.uniqueTrialCoef;
if (prob > config.maxUniqueTrialProb) {
prob = config.maxUniqueTrialProb;
}
prob /= Math.sqrt(7);
const moreProb = Math.pow(10, 1 / 4);
if (inheritRandomUnique && availableBuyUnique) {
prob = 1;
} else if (acquireType === '건국') {
prob = 1;
}
let success = false;
for (let i = 0; i < maxCnt; i += 1) {
if (rng.nextBool(prob)) {
success = true;
break;
}
prob *= moreProb;
}
if (!success) {
return null;
}
const availableUnique: Array<[string, number]> = [];
for (const itemType of itemTypes) {
if (invalidItemTypes.has(itemType)) {
continue;
}
const itemEntries = config.allItems[itemType] ?? {};
for (const [itemKey, rawCount] of Object.entries(itemEntries)) {
const count = readNumber(rawCount, 0);
if (count <= 0) {
continue;
}
const module = itemRegistry.get(itemKey);
if (!module || module.buyable) {
continue;
}
const remain = count - (occupiedUniqueCounts.get(itemKey) ?? 0);
if (remain > 0) {
availableUnique.push([itemKey, remain]);
}
}
}
if (availableUnique.length === 0) {
return null;
}
return rng.choiceUsingWeightPair(availableUnique);
};
+78
View File
@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest';
import { LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import type { GeneralItemSlots } from '../src/domain/entities.js';
import type { ItemModule } from '../src/items/types.js';
import {
buildVoteUniqueSeed,
countOccupiedUniqueItems,
resolveUniqueConfig,
rollUniqueLottery,
} from '../src/rewards/uniqueLottery.js';
const buildItem = (key: string, slot: ItemModule['slot'], buyable = false): ItemModule => ({
key,
rawName: key,
name: key,
info: key,
slot,
cost: null,
buyable,
consumable: false,
reqSecu: 0,
unique: !buyable,
});
describe('unique lottery', () => {
it('returns deterministic item for fixed seed', () => {
const itemRegistry = new Map<string, ItemModule>([
['itemB', buildItem('itemB', 'weapon', false)],
]);
const config = resolveUniqueConfig({
allItems: {
weapon: {
itemB: 1,
},
},
maxUniqueItemLimit: [[-1, 1]],
uniqueTrialCoef: 10,
maxUniqueTrialProb: 10,
minMonthToAllowInheritItem: 0,
});
const rngSeed = buildVoteUniqueSeed('seed', 1, 1);
const rng = new RandUtil(LiteHashDRBG.build(rngSeed));
const result = rollUniqueLottery({
rng,
config,
itemRegistry,
generalItems: { horse: null, weapon: null, book: null, item: null },
occupiedUniqueCounts: new Map(),
scenarioId: 200,
userCount: 1,
currentYear: 200,
currentMonth: 1,
startYear: 180,
initYear: 180,
initMonth: 1,
acquireType: '설문조사',
});
expect(result).toBe('itemB');
});
it('counts only non-buyable equipped items', () => {
const itemRegistry = new Map<string, ItemModule>([
['uniqueItem', buildItem('uniqueItem', 'weapon', false)],
['buyableItem', buildItem('buyableItem', 'book', true)],
]);
const generals: GeneralItemSlots[] = [
{ horse: null, weapon: 'uniqueItem', book: null, item: null },
{ horse: null, weapon: null, book: 'buyableItem', item: null },
];
const counts = countOccupiedUniqueItems(generals, itemRegistry);
expect(counts.get('uniqueItem')).toBe(1);
expect(counts.get('buyableItem')).toBeUndefined();
});
});