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:
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user