Implement legacy-compatible survey and unique rewards

This commit is contained in:
2026-07-26 04:17:56 +00:00
parent 9b6d5288d3
commit c2a3b5b797
14 changed files with 1408 additions and 792 deletions
+37 -18
View File
@@ -5,6 +5,7 @@ import { asRecord, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import { GamePrisma } from '@sammo-ts/infra'; import { GamePrisma } from '@sammo-ts/infra';
import { import {
ITEM_KEYS, ITEM_KEYS,
addOccupiedUniqueItemKeys,
buildVoteUniqueSeed, buildVoteUniqueSeed,
countOccupiedUniqueItems, countOccupiedUniqueItems,
createItemModuleRegistry, createItemModuleRegistry,
@@ -22,7 +23,12 @@ const hasAdminRole = (roles: string[], profileName: string): boolean => {
if (roles.includes('superuser') || roles.includes('admin') || roles.includes('admin.superuser')) { if (roles.includes('superuser') || roles.includes('admin') || roles.includes('admin.superuser')) {
return true; 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 }) => { const adminProcedure = authedProcedure.use(({ ctx, next }) => {
@@ -66,9 +72,7 @@ const normalizeCode = (value: string | null | undefined): string | null => {
}; };
const normalizeOptions = (options: string[]): string[] => const normalizeOptions = (options: string[]): string[] =>
options options.map((option) => option.trim()).filter((option) => option.length > 0);
.map((option) => option.trim())
.filter((option) => option.length > 0);
const readMetaNumber = (meta: Record<string, unknown>, key: string, fallback: number): number => { const readMetaNumber = (meta: Record<string, unknown>, key: string, fallback: number): number => {
const value = meta[key]; 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 pollEnded = Boolean(row.closed_at) || (row.end_at ? row.end_at <= new Date() : false);
const userId = ctx.auth?.user.id; const userId = ctx.auth?.user.id;
const general = userId const general = userId ? await ctx.db.general.findFirst({ where: { userId }, select: { id: true } }) : null;
? await ctx.db.general.findFirst({ where: { userId }, select: { id: true } })
: null;
const [comments, userCnt, myVoteRow] = await Promise.all([ const [comments, userCnt, myVoteRow] = await Promise.all([
ctx.db.$queryRaw<VoteCommentRow[]>(GamePrisma.sql` ctx.db.$queryRaw<VoteCommentRow[]>(GamePrisma.sql`
@@ -257,9 +259,8 @@ export const voteRouter = router({
const myVote = myVoteRow[0]?.selection ? parseSelection(myVoteRow[0].selection) : null; const myVote = myVoteRow[0]?.selection ? parseSelection(myVoteRow[0].selection) : null;
const canReveal = row.reveal_mode === 'after_vote' // 레거시는 투표 전에도 현재 집계를 보여준다. after_end만 명시적으로 숨긴다.
? Boolean(myVote) || pollEnded const canReveal = row.reveal_mode === 'after_end' ? pollEnded : true;
: pollEnded;
const voteResults = canReveal const voteResults = canReveal
? await ctx.db.$queryRaw<VoteResultRow[]>(GamePrisma.sql` ? await ctx.db.$queryRaw<VoteResultRow[]>(GamePrisma.sql`
@@ -355,6 +356,9 @@ export const voteRouter = router({
} }
const sortedSelection = [...selection].sort((a, b) => a - b); 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 general = await getMyGeneral(ctx);
const rows = await ctx.db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql` const rows = await ctx.db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
@@ -394,14 +398,24 @@ export const voteRouter = router({
const itemRegistry = await getItemRegistry(); const itemRegistry = await getItemRegistry();
const uniqueConfig = resolveUniqueConfig(constValues); const uniqueConfig = resolveUniqueConfig(constValues);
const generalRows = await ctx.db.general.findMany({ const [generalRows, reservedUniqueRows] = await Promise.all([
select: { ctx.db.general.findMany({
horseCode: true, select: {
weaponCode: true, horseCode: true,
bookCode: true, weaponCode: true,
itemCode: 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) => ({ const generalItems: GeneralItemSlots[] = generalRows.map((row) => ({
horse: normalizeCode(row.horseCode), horse: normalizeCode(row.horseCode),
weapon: normalizeCode(row.weaponCode), weapon: normalizeCode(row.weaponCode),
@@ -410,6 +424,11 @@ export const voteRouter = router({
})); }));
const occupiedUniqueCounts = countOccupiedUniqueItems(generalItems, itemRegistry); 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 userCount = await ctx.db.general.count({ where: { npcState: { lt: 2 } } });
const rngSeed = buildVoteUniqueSeed( const rngSeed = buildVoteUniqueSeed(
+292
View File
@@ -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> = {}): 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<string, unknown>;
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' });
}
});
});
@@ -24,6 +24,7 @@ import {
evaluateConstraints, evaluateConstraints,
resolveGeneralAction, resolveGeneralAction,
ITEM_KEYS, ITEM_KEYS,
addOccupiedUniqueItemKeys,
buildGenericUniqueSeed, buildGenericUniqueSeed,
countOccupiedUniqueItems, countOccupiedUniqueItems,
createItemModuleRegistry, createItemModuleRegistry,
@@ -382,6 +383,7 @@ const buildUniqueLotteryRunner = (options: {
seedBase: string; seedBase: string;
itemRegistry: Map<string, ItemModule>; itemRegistry: Map<string, ItemModule>;
uniqueConfig: ReturnType<typeof resolveUniqueConfig>; uniqueConfig: ReturnType<typeof resolveUniqueConfig>;
getAdditionalOccupiedUniqueItemKeys?: () => Iterable<string | null | undefined>;
}): UniqueLotteryRunner => { }): UniqueLotteryRunner => {
if (!options.worldView) { if (!options.worldView) {
return () => null; return () => null;
@@ -408,6 +410,11 @@ const buildUniqueLotteryRunner = (options: {
entry.id === general.id ? general.role.items : entry.role.items entry.id === general.id ? general.role.items : entry.role.items
); );
const occupiedUniqueCounts = countOccupiedUniqueItems(generalItemsList, options.itemRegistry); const occupiedUniqueCounts = countOccupiedUniqueItems(generalItemsList, options.itemRegistry);
addOccupiedUniqueItemKeys(
occupiedUniqueCounts,
options.getAdditionalOccupiedUniqueItemKeys?.() ?? [],
options.itemRegistry
);
const rngSeed = buildGenericUniqueSeed( const rngSeed = buildGenericUniqueSeed(
options.seedBase, options.seedBase,
world.currentYear, world.currentYear,
@@ -723,6 +730,7 @@ export const createReservedTurnHandler = async (options: {
commandProfile?: TurnCommandProfile; commandProfile?: TurnCommandProfile;
commandEnv?: TurnCommandEnv; commandEnv?: TurnCommandEnv;
commandRngFactory?: (input: { kind: 'nation' | 'general'; actionKey: string; seed: string }) => RandUtil; commandRngFactory?: (input: { kind: 'nation' | 'general'; actionKey: string; seed: string }) => RandUtil;
getAdditionalOccupiedUniqueItemKeys?: () => Iterable<string | null | undefined>;
onActionResolved?: (payload: { onActionResolved?: (payload: {
kind: 'nation' | 'general'; kind: 'nation' | 'general';
generalId: number; generalId: number;
@@ -944,6 +952,7 @@ export const createReservedTurnHandler = async (options: {
seedBase, seedBase,
itemRegistry, itemRegistry,
uniqueConfig, uniqueConfig,
getAdditionalOccupiedUniqueItemKeys: options.getAdditionalOccupiedUniqueItemKeys,
}); });
let baseContext: ActionContextBase = { let baseContext: ActionContextBase = {
general: currentGeneral, general: currentGeneral,
+15
View File
@@ -473,6 +473,8 @@ const createTurnDaemonRuntimeWithLease = async (
neutralAuctionRegistrar.handler, neutralAuctionRegistrar.handler,
frontStateHandler frontStateHandler
); );
let occupiedAuctionUniqueItemKeys: string[] = [];
let refreshOccupiedAuctionUniqueItemKeys = async (): Promise<void> => {};
const worldOptions: InMemoryTurnWorldOptions = { const worldOptions: InMemoryTurnWorldOptions = {
schedule, schedule,
generalTurnHandler: generalTurnHandler:
@@ -486,6 +488,7 @@ const createTurnDaemonRuntimeWithLease = async (
getWorld: () => worldRef, getWorld: () => worldRef,
commandProfile, commandProfile,
commandEnv: monthlyCommandEnv, commandEnv: monthlyCommandEnv,
getAdditionalOccupiedUniqueItemKeys: () => occupiedAuctionUniqueItemKeys,
})), })),
calendarHandler: calendarHandler ?? undefined, calendarHandler: calendarHandler ?? undefined,
autoAdvanceDiplomacyMonth: false, autoAdvanceDiplomacyMonth: false,
@@ -501,6 +504,7 @@ const createTurnDaemonRuntimeWithLease = async (
? async (general) => { ? async (general) => {
const promises: Promise<unknown>[] = []; const promises: Promise<unknown>[] = [];
promises.push(reservedTurnStoreHandle.store.refreshGeneralTurns(general.id)); promises.push(reservedTurnStoreHandle.store.refreshGeneralTurns(general.id));
promises.push(refreshOccupiedAuctionUniqueItemKeys());
if (general.nationId > 0 && general.officerLevel >= 5) { if (general.nationId > 0 && general.officerLevel >= 5) {
promises.push( promises.push(
reservedTurnStoreHandle.store.refreshNationTurns(general.nationId, general.officerLevel) reservedTurnStoreHandle.store.refreshNationTurns(general.nationId, general.officerLevel)
@@ -648,6 +652,17 @@ const createTurnDaemonRuntimeWithLease = async (
if (commandConnector && databaseCommandQueue) { if (commandConnector && databaseCommandQueue) {
await commandConnector.connect(); await commandConnector.connect();
await databaseCommandQueue.initialize(); 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; const baseClose = close;
@@ -11,6 +11,7 @@ import {
LogFormat, LogFormat,
LogScope, LogScope,
ITEM_KEYS, ITEM_KEYS,
addOccupiedUniqueItemKeys,
buildVoteUniqueSeed, buildVoteUniqueSeed,
countOccupiedUniqueItems, countOccupiedUniqueItems,
createItemModuleRegistry, createItemModuleRegistry,
@@ -1162,6 +1163,21 @@ async function handleVoteReward(
generals.map((entry) => entry.role.items), generals.map((entry) => entry.role.items),
itemRegistry 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 userCount = generals.filter((entry) => entry.npcState < 2).length;
const rngSeed = buildVoteUniqueSeed( const rngSeed = buildVoteUniqueSeed(
typeof hiddenSeed === 'string' || typeof hiddenSeed === 'number' ? hiddenSeed : String(hiddenSeed), typeof hiddenSeed === 'string' || typeof hiddenSeed === 'number' ? hiddenSeed : String(hiddenSeed),
@@ -173,4 +173,138 @@ describe('unique lottery on general commands', () => {
const logTexts = (result.logs ?? []).map((entry) => entry.text); const logTexts = (result.logs ?? []).map((entry) => entry.text);
expect(logTexts.some((text) => text.includes('【아이템】'))).toBe(true); 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);
});
}); });
+76
View File
@@ -223,4 +223,80 @@ describe('voteReward command', () => {
const afterSecond = world.getGeneralById(1); const afterSecond = world.getGeneralById(1);
expect(afterSecond?.gold).toBe(1500); 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();
});
}); });
+579 -762
View File
@@ -1,865 +1,682 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref } from 'vue';
import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
type VoteListResponse = Awaited<ReturnType<typeof trpc.vote.getVoteList.query>>; type VoteListResponse = Awaited<ReturnType<typeof trpc.vote.getVoteList.query>>;
type VoteDetailResponse = Awaited<ReturnType<typeof trpc.vote.getVoteDetail.query>>; type VoteDetail = Awaited<ReturnType<typeof trpc.vote.getVoteDetail.query>>;
type RevealMode = 'after_vote' | 'after_end';
type PollSummary = VoteListResponse['polls'][number]; type PollSummary = VoteListResponse['polls'][number];
type VoteDetail = VoteDetailResponse;
type VoteResultEntry = VoteDetail['votes'][number];
const loading = ref(false);
const detailLoading = ref(false);
const error = ref<string | null>(null);
const polls = ref<PollSummary[]>([]); const polls = ref<PollSummary[]>([]);
const voteReward = ref(0); const voteReward = ref(0);
const activeVoteId = ref<number | null>(null); const currentVoteId = ref<number | null>(null);
const voteDetail = ref<VoteDetail | null>(null); const currentVote = ref<VoteDetail | null>(null);
const adminEnabled = ref(false); const loading = ref(false);
const detailLoading = ref(false);
const isVoteAdmin = ref(false);
const showNewVote = ref(false);
const message = ref('');
const messageKind = ref<'success' | 'error'>('success');
const mySinglePick = ref(0);
const myMultiPick = ref<number[]>([]);
const myComment = ref('');
const newVoteTitle = ref('');
const newVoteOptionsText = ref('');
const newVoteMultipleOptions = ref(1);
const selectionSingle = ref<number | null>(null); const getErrorMessage = (error: unknown): string => {
const selectionMulti = ref<number[]>([]); if (error instanceof Error) {
const commentDraft = ref(''); return error.message;
const actionMessage = ref<string | null>(null);
const newPollTitle = ref('');
const newPollBody = ref('');
const newPollOptionsText = ref('');
const newPollMultipleOptions = ref(1);
const newPollEndAt = ref('');
const newPollRevealMode = ref<RevealMode>('after_vote');
const newPollClosePrevious = ref(true);
const updateTitle = ref('');
const updateBody = ref('');
const updateOptionsText = ref('');
const updateMultipleOptions = ref<number | null>(null);
const updateEndAt = ref('');
const updateRevealMode = ref<RevealMode>('after_vote');
const resolveErrorMessage = (value: unknown): string => {
if (value instanceof Error) {
return value.message;
} }
if (typeof value === 'string') { return typeof error === 'string' ? error : '요청을 처리하지 못했습니다.';
return value;
}
return 'unknown_error';
}; };
const formatDate = (value: string | null): string => { const showMessage = (text: string, kind: 'success' | 'error') => {
if (!value) { message.value = text;
return '-'; messageKind.value = kind;
};
const isEnded = (poll: { endAt: string | null; closedAt: string | null }): boolean => {
if (poll.closedAt) {
return true;
} }
return poll.endAt ? new Date(poll.endAt).getTime() < Date.now() : false;
};
const canVote = computed(
() => Boolean(currentVote.value) && !currentVote.value?.myVote && !isEnded(currentVote.value!.voteInfo)
);
const voteTotal = computed(() => (currentVote.value?.votes ?? []).reduce((total, vote) => total + vote.count, 0));
const voteDistribution = computed(() => {
const result = Array.from({ length: currentVote.value?.voteInfo.options.length ?? 0 }, () => 0);
for (const vote of currentVote.value?.votes ?? []) {
for (const selection of vote.selection) {
if (selection >= 0 && selection < result.length) {
result[selection] += vote.count;
}
}
}
return result;
});
const newVoteOptions = computed(() => newVoteOptionsText.value.split('\n').filter((option) => option.length > 0));
const percentage = (count: number, total: number): string => ((count / Math.max(1, total)) * 100).toFixed(1);
const formatStartDate = (value: string): string => value.slice(0, 10);
const formatCommentDate = (value: string): string => {
const date = new Date(value); const date = new Date(value);
if (Number.isNaN(date.getTime())) { if (Number.isNaN(date.getTime())) {
return value; return value;
} }
return date.toLocaleString('ko-KR'); const pad = (part: number) => String(part).padStart(2, '0');
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
}; };
const parseOptionsText = (text: string): string[] => const voteColor = (index: number): string =>
text ['#ff0000', '#ffa500', '#ffff00', '#008000', '#0000ff', '#000080', '#800080'][index % 7]!;
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0);
const resolveDateInput = (value: string): string | undefined => { const voteColorText = (index: number): string => ([1, 2].includes(index % 7) ? '#000' : '#fff');
if (!value) {
return undefined;
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return undefined;
}
return parsed.toISOString();
};
const isPollEnded = (poll: { endAt: string | null; closedAt: string | null }): boolean => {
if (poll.closedAt) {
return true;
}
if (!poll.endAt) {
return false;
}
const endDate = new Date(poll.endAt);
if (Number.isNaN(endDate.getTime())) {
return false;
}
return endDate <= new Date();
};
const currentPoll = computed(() => {
if (!polls.value.length) {
return null;
}
const selected = polls.value.find((poll) => poll.id === activeVoteId.value);
return selected ?? polls.value[0] ?? null;
});
const revealLabel = computed(() => {
if (!voteDetail.value) {
return '';
}
return voteDetail.value.voteInfo.revealMode === 'after_vote' ? '투표 후 공개' : '종료 후 공개';
});
const pollEnded = computed(() => {
if (!voteDetail.value) {
return false;
}
return isPollEnded({
endAt: voteDetail.value.voteInfo.endAt,
closedAt: voteDetail.value.voteInfo.closedAt,
});
});
const canVote = computed(() => {
if (!voteDetail.value) {
return false;
}
if (voteDetail.value.myVote) {
return false;
}
return !pollEnded.value;
});
const canReveal = computed(() => {
if (!voteDetail.value) {
return false;
}
if (voteDetail.value.voteInfo.revealMode === 'after_vote') {
return Boolean(voteDetail.value.myVote) || pollEnded.value;
}
return pollEnded.value;
});
const isSingleChoice = computed(() => voteDetail.value?.voteInfo.multipleOptions === 1);
const voteDistribution = computed(() => {
if (!voteDetail.value) {
return [] as number[];
}
const optionCount = voteDetail.value.voteInfo.options.length;
const counts = Array.from({ length: optionCount }, () => 0);
for (const entry of voteDetail.value.votes as VoteResultEntry[]) {
for (const index of entry.selection) {
if (index >= 0 && index < counts.length) {
counts[index] += entry.count;
}
}
}
return counts;
});
const voteTotal = computed(() =>
(voteDetail.value?.votes ?? []).reduce((sum, entry) => sum + entry.count, 0)
);
const selectPoll = (pollId: number) => {
if (activeVoteId.value === pollId) {
return;
}
activeVoteId.value = pollId;
};
const loadVoteList = async () => {
if (loading.value) {
return;
}
loading.value = true;
error.value = null;
try {
const result = await trpc.vote.getVoteList.query();
polls.value = result.polls;
voteReward.value = result.voteReward ?? 0;
if (!activeVoteId.value || !result.polls.some((poll) => poll.id === activeVoteId.value)) {
const openPoll = result.polls.find((poll) => !isPollEnded(poll));
activeVoteId.value = openPoll?.id ?? result.polls[0]?.id ?? null;
}
} catch (err) {
error.value = resolveErrorMessage(err);
} finally {
loading.value = false;
}
};
const loadVoteDetail = async (voteId: number) => { const loadVoteDetail = async (voteId: number) => {
if (detailLoading.value) {
return;
}
detailLoading.value = true; detailLoading.value = true;
error.value = null;
actionMessage.value = null;
try { try {
const result = await trpc.vote.getVoteDetail.query({ voteId }); const detail = await trpc.vote.getVoteDetail.query({ voteId });
voteDetail.value = result; currentVote.value = detail;
if (result.myVote && result.myVote.length > 0) { currentVoteId.value = voteId;
if (result.voteInfo.multipleOptions === 1) { mySinglePick.value = detail.myVote?.[0] ?? 0;
selectionSingle.value = result.myVote[0] ?? null; myMultiPick.value = detail.myVote ? [...detail.myVote] : [];
selectionMulti.value = [...result.myVote]; myComment.value = '';
} else { } catch (error) {
selectionSingle.value = null; showMessage(getErrorMessage(error), 'error');
selectionMulti.value = [...result.myVote];
}
} else {
selectionSingle.value = null;
selectionMulti.value = [];
}
commentDraft.value = '';
updateTitle.value = result.voteInfo.title;
updateBody.value = result.voteInfo.body;
updateMultipleOptions.value = result.voteInfo.multipleOptions;
updateRevealMode.value = result.voteInfo.revealMode;
updateEndAt.value = result.voteInfo.endAt ? result.voteInfo.endAt.slice(0, 16) : '';
} catch (err) {
error.value = resolveErrorMessage(err);
} finally { } finally {
detailLoading.value = false; detailLoading.value = false;
} }
}; };
const refreshAll = async () => { const reloadVote = async () => {
await loadVoteList(); if (loading.value) {
if (activeVoteId.value) { return;
await loadVoteDetail(activeVoteId.value); }
loading.value = true;
message.value = '';
try {
const result = await trpc.vote.getVoteList.query();
polls.value = result.polls;
voteReward.value = result.voteReward;
const nextVoteId =
(currentVoteId.value && result.polls.some((poll) => poll.id === currentVoteId.value)
? currentVoteId.value
: result.polls[0]?.id) ?? null;
if (nextVoteId) {
await loadVoteDetail(nextVoteId);
} else {
currentVoteId.value = null;
currentVote.value = null;
}
} catch (error) {
showMessage(getErrorMessage(error), 'error');
} finally {
loading.value = false;
}
};
const selectVote = (voteId: number) => {
if (voteId !== currentVoteId.value) {
void loadVoteDetail(voteId);
}
};
const changeMultiPick = (index: number, checked: boolean) => {
const limit = currentVote.value?.voteInfo.multipleOptions ?? 0;
if (checked && limit > 0 && myMultiPick.value.length > limit) {
myMultiPick.value = myMultiPick.value.filter((value) => value !== index);
showMessage(`${limit}개까지만 선택할 수 있습니다.`, 'error');
} }
}; };
const submitVote = async () => { const submitVote = async () => {
if (!voteDetail.value) { if (!currentVote.value) {
return; return;
} }
const optionLimit = voteDetail.value.voteInfo.multipleOptions; const selection = currentVote.value.voteInfo.multipleOptions === 1 ? [mySinglePick.value] : [...myMultiPick.value];
const selected = isSingleChoice.value if (selection.length === 0) {
? selectionSingle.value !== null showMessage('선택한 항목이 없습니다.', 'error');
? [selectionSingle.value]
: []
: [...selectionMulti.value];
if (selected.length === 0) {
actionMessage.value = '선택한 항목이 없습니다.';
return; return;
} }
if (optionLimit >= 1 && selected.length > optionLimit) {
actionMessage.value = '선택한 항목이 너무 많습니다.';
return;
}
actionMessage.value = null;
try { try {
const result = await trpc.vote.submitVote.mutate({ const result = await trpc.vote.submitVote.mutate({
voteId: voteDetail.value.voteInfo.id, voteId: currentVote.value.voteInfo.id,
selection: selected, selection,
}); });
actionMessage.value = result.wonLottery showMessage(result.wonLottery ? '특별한 설문 보상이 제공되었습니다!' : '설문을 마쳤습니다.', 'success');
? '투표 완료! 유니크 추첨에 당첨되었습니다.' await loadVoteDetail(currentVote.value.voteInfo.id);
: '투표가 완료되었습니다.'; } catch (error) {
await refreshAll(); showMessage(getErrorMessage(error), 'error');
} catch (err) {
actionMessage.value = resolveErrorMessage(err);
} }
}; };
const submitComment = async () => { const submitComment = async () => {
if (!voteDetail.value) { if (!currentVote.value || myComment.value.length === 0) {
return; return;
} }
const text = commentDraft.value.trim();
if (!text) {
return;
}
actionMessage.value = null;
try { try {
await trpc.vote.addComment.mutate({ await trpc.vote.addComment.mutate({
voteId: voteDetail.value.voteInfo.id, voteId: currentVote.value.voteInfo.id,
text, text: myComment.value,
}); });
commentDraft.value = ''; myComment.value = '';
await loadVoteDetail(voteDetail.value.voteInfo.id); showMessage('댓글을 달았습니다.', 'success');
} catch (err) { await loadVoteDetail(currentVote.value.voteInfo.id);
actionMessage.value = resolveErrorMessage(err); } catch (error) {
showMessage(getErrorMessage(error), 'error');
} }
}; };
const createPoll = async () => { const submitNewVote = async () => {
const options = parseOptionsText(newPollOptionsText.value);
const endAt = resolveDateInput(newPollEndAt.value);
actionMessage.value = null;
try { try {
await trpc.vote.createPoll.mutate({ await trpc.vote.createPoll.mutate({
title: newPollTitle.value.trim(), title: newVoteTitle.value,
body: newPollBody.value.trim(), body: '',
options, options: newVoteOptions.value,
multipleOptions: newPollMultipleOptions.value, multipleOptions: newVoteMultipleOptions.value,
endAt, revealMode: 'after_vote',
revealMode: newPollRevealMode.value, closePrevious: true,
closePrevious: newPollClosePrevious.value,
}); });
newPollTitle.value = ''; showMessage('설문 조사가 생성되었습니다.', 'success');
newPollBody.value = ''; newVoteTitle.value = '';
newPollOptionsText.value = ''; newVoteOptionsText.value = '';
newPollMultipleOptions.value = 1; newVoteMultipleOptions.value = 1;
newPollEndAt.value = ''; showNewVote.value = false;
newPollRevealMode.value = 'after_vote'; await reloadVote();
newPollClosePrevious.value = true; } catch (error) {
await refreshAll(); showMessage(getErrorMessage(error), 'error');
} catch (err) {
actionMessage.value = resolveErrorMessage(err);
}
};
const updatePoll = async () => {
if (!voteDetail.value) {
return;
}
const appendOptions = parseOptionsText(updateOptionsText.value);
const endAt = resolveDateInput(updateEndAt.value);
actionMessage.value = null;
try {
await trpc.vote.updatePoll.mutate({
voteId: voteDetail.value.voteInfo.id,
title: updateTitle.value.trim() || undefined,
body: updateBody.value.trim() || undefined,
appendOptions: appendOptions.length > 0 ? appendOptions : undefined,
multipleOptions: updateMultipleOptions.value ?? undefined,
endAt,
revealMode: updateRevealMode.value,
});
updateOptionsText.value = '';
await refreshAll();
} catch (err) {
actionMessage.value = resolveErrorMessage(err);
}
};
const closePoll = async () => {
if (!voteDetail.value) {
return;
}
actionMessage.value = null;
try {
await trpc.vote.closePoll.mutate({ voteId: voteDetail.value.voteInfo.id });
await refreshAll();
} catch (err) {
actionMessage.value = resolveErrorMessage(err);
} }
}; };
onMounted(() => { onMounted(() => {
void refreshAll(); void reloadVote();
void trpc.vote.getAdminStatus.query().then((result) => { void trpc.vote.getAdminStatus
adminEnabled.value = Boolean(result?.ok); .query()
}).catch(() => { .then((result) => {
adminEnabled.value = false; isVoteAdmin.value = result.ok === true;
}); })
}); .catch(() => {
isVoteAdmin.value = false;
watch(activeVoteId, (voteId) => { });
if (voteId) {
void loadVoteDetail(voteId);
}
}); });
</script> </script>
<template> <template>
<main class="survey-view"> <main id="container" class="pageVote bg0">
<header class="page-header"> <header class="back_bar bg0">
<div> <RouterLink class="btn btn-sammo-base2 back_btn" to="/"> 닫기</RouterLink>
<h1 class="page-title">설문조사</h1> <button class="btn btn-sammo-base2 reload_btn" type="button" :disabled="loading" @click="reloadVote">
<p class="page-subtitle">투표 참여 보상과 유니크 추첨이 함께 진행됩니다.</p> 갱신
</div> </button>
<div class="header-actions"> <h2 class="title"></h2>
<RouterLink class="ghost" to="/">메인으로</RouterLink> <div>&nbsp;</div>
<button class="ghost" @click="refreshAll" :disabled="loading">새로고침</button> <div></div>
</div>
</header> </header>
<div v-if="error" class="error">{{ error }}</div> <div
<div v-if="actionMessage" class="notice">{{ actionMessage }}</div> v-if="message"
class="vote-notice"
:class="messageKind"
:role="messageKind === 'error' ? 'alert' : 'status'"
>
{{ message }}
</div>
<div id="vote-title" class="bg2">설문 조사({{ voteReward }}금과 추첨으로 유니크템 증정!)</div>
<section class="survey-grid"> <div v-if="detailLoading && !currentVote" class="loading">불러오는 중...</div>
<div class="survey-main"> <table v-if="currentVote" id="vote-result">
<PanelCard title="보상 안내"> <colgroup>
<div class="reward-block"> <col class="vote-idx" />
<div>투표 참여 보상: <strong>{{ voteReward }}</strong> </div> <col class="vote-count" />
<div>추첨 보상: 유니크 장비 1</div> <col class="vote-percent" />
</div> <col class="vote-option" />
</PanelCard> </colgroup>
<thead>
<tr>
<th colspan="3" class="text-end bg1">설문 제목</th>
<th id="vote-detail-title">
{{ currentVote.voteInfo.title }}
<template v-if="currentVote.voteInfo.multipleOptions !== 1">
({{
currentVote.voteInfo.multipleOptions === 0
? currentVote.voteInfo.options.length
: currentVote.voteInfo.multipleOptions
}} 선택 가능 )
</template>
</th>
</tr>
<tr>
<th colspan="3" class="text-end bg1">게시자</th>
<th id="vote-detail-opener">{{ currentVote.voteInfo.openerName || '[SYSTEM]' }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(option, index) in currentVote.voteInfo.options" :key="index">
<td v-if="canVote" class="text-center">
<input
v-if="currentVote.voteInfo.multipleOptions === 1"
:id="`v-vote-${index}`"
v-model="mySinglePick"
class="form-check-input"
type="radio"
:value="index"
/>
<input
v-else
:id="`v-vote-${index}`"
v-model="myMultiPick"
class="form-check-input"
type="checkbox"
:value="index"
@change="changeMultiPick(index, ($event.target as HTMLInputElement).checked)"
/>
</td>
<td
v-else
class="text-end f_tnum"
:style="{ backgroundColor: voteColor(index), color: voteColorText(index) }"
>
{{ index + 1 }}.
</td>
<td class="text-end f_tnum vote-count">
<label :for="`v-vote-${index}`">{{ voteDistribution[index] }}</label>
</td>
<td class="text-end f_tnum vote-percent">
<label :for="`v-vote-${index}`">
({{ percentage(voteDistribution[index] ?? 0, voteTotal) }}%)
</label>
</td>
<td>
<label :for="`v-vote-${index}`">{{ option }}</label>
</td>
</tr>
</tbody>
<tfoot>
<tr>
<template v-if="canVote">
<td class="text-center">투표</td>
<td colspan="2">
<button class="btn btn-primary vote-submit" @click="submitVote">투표</button>
</td>
</template>
<td v-else colspan="3" class="text-center">결산</td>
<td>
투표율: {{ voteTotal }} / {{ currentVote.userCnt }} ({{
percentage(voteTotal, currentVote.userCnt)
}}%)
</td>
</tr>
</tfoot>
</table>
<PanelCard title="현재 설문" :subtitle="currentPoll?.title ?? '설문 정보 없음'"> <form v-if="currentVote" @submit.prevent="submitComment">
<SkeletonLines v-if="loading || detailLoading" :lines="6" /> <table id="vote-comment">
<div v-else-if="!voteDetail" class="placeholder">설문 정보가 없습니다.</div> <colgroup>
<div v-else class="poll-detail"> <col class="comment-idx" />
<div class="poll-meta"> <col class="comment-name" />
<div><strong>제목</strong> {{ voteDetail.voteInfo.title }}</div> <col class="comment-text" />
<div v-if="voteDetail.voteInfo.body"><strong>본문</strong> {{ voteDetail.voteInfo.body }}</div> <col class="comment-date" />
<div><strong>작성자</strong> {{ voteDetail.voteInfo.openerName }}</div> </colgroup>
<div> <thead>
<strong>선택 제한</strong> <tr class="bg1 text-center">
{{ <th>#</th>
voteDetail.voteInfo.multipleOptions === 0 <th><span>국가명</span><span>장수명</span></th>
? '제한 없음' <th>댓글</th>
: voteDetail.voteInfo.multipleOptions === 1 <th>일시</th>
? '1개 선택' </tr>
: `${voteDetail.voteInfo.multipleOptions}개 선택` </thead>
}} <tbody>
</div> <tr v-for="(comment, index) in currentVote.comments" :key="comment.id">
<div><strong>공개 정책</strong> {{ revealLabel }}</div> <td class="comment-idx f_tnum">{{ index + 1 }}.</td>
<div><strong>시작</strong> {{ formatDate(voteDetail.voteInfo.startAt) }}</div> <td class="comment-name">
<div><strong>종료</strong> {{ formatDate(voteDetail.voteInfo.endAt) }}</div> <span>{{ comment.nationName }}</span
<div><strong>닫힘</strong> {{ formatDate(voteDetail.voteInfo.closedAt) }}</div> ><span>{{ comment.generalName }}</span>
</div> </td>
<td>{{ comment.text }}</td>
<td class="comment-date f_tnum">{{ formatCommentDate(comment.createdAt) }}</td>
</tr>
</tbody>
<tfoot>
<tr>
<td></td>
<td><button class="btn btn-primary comment-submit" type="submit">댓글 달기</button></td>
<td colspan="2">
<input v-model="myComment" class="form-control" maxlength="200" aria-label="댓글" />
</td>
</tr>
</tfoot>
</table>
</form>
<div class="poll-options"> <div id="vote-old-title" class="bg2">이전 설문 조사</div>
<div <div id="vote-old-list">
v-for="(option, idx) in voteDetail.voteInfo.options" <div v-for="poll in polls" :key="poll.id" class="vote-old-item">
:key="`${voteDetail.voteInfo.id}-${idx}`" <a href="#" @click.prevent="selectVote(poll.id)">{{ poll.title }}</a>
class="poll-option" ({{ formatStartDate(poll.startAt) }})
>
<label>
<input
v-if="isSingleChoice"
type="radio"
:value="idx"
v-model="selectionSingle"
:disabled="!canVote"
/>
<input
v-else
type="checkbox"
:value="idx"
v-model="selectionMulti"
:disabled="!canVote"
/>
<span>{{ option }}</span>
</label>
</div>
</div>
<div class="poll-actions">
<button class="ghost" @click="submitVote" :disabled="!canVote">투표하기</button>
<span v-if="voteDetail.myVote" class="muted">이미 투표했습니다.</span>
<span v-else-if="pollEnded" class="muted">설문이 종료되었습니다.</span>
</div>
</div>
</PanelCard>
<PanelCard title="결과">
<SkeletonLines v-if="detailLoading" :lines="4" />
<div v-else-if="!voteDetail" class="placeholder">설문 결과가 없습니다.</div>
<div v-else-if="!canReveal" class="placeholder">
결과는 {{ revealLabel }}됩니다.
</div>
<div v-else class="poll-results">
<div class="result-summary">
참여 인원 {{ voteTotal }} / {{ voteDetail.userCnt }}
</div>
<div v-for="(option, idx) in voteDetail.voteInfo.options" :key="`result-${idx}`" class="result-row">
<div class="result-option">{{ option }}</div>
<div class="result-count">{{ voteDistribution[idx] ?? 0 }}</div>
<div class="result-percent">
{{
voteTotal > 0
? ((voteDistribution[idx] ?? 0) / voteTotal * 100).toFixed(1)
: '0.0'
}}%
</div>
</div>
</div>
</PanelCard>
<PanelCard title="댓글">
<SkeletonLines v-if="detailLoading" :lines="4" />
<div v-else-if="!voteDetail" class="placeholder">댓글을 불러오는 중입니다.</div>
<div v-else>
<div v-if="voteDetail.comments.length === 0" class="placeholder">아직 댓글이 없습니다.</div>
<div v-else class="comment-list">
<div v-for="comment in voteDetail.comments" :key="comment.id" class="comment-item">
<div class="comment-header">
<span>{{ comment.nationName }}</span>
<span>{{ comment.generalName }}</span>
<span class="comment-date">{{ formatDate(comment.createdAt) }}</span>
</div>
<div class="comment-text">{{ comment.text }}</div>
</div>
</div>
<div class="comment-form">
<input
v-model="commentDraft"
type="text"
maxlength="200"
placeholder="댓글을 입력하세요"
/>
<button class="ghost" @click="submitComment">댓글 등록</button>
</div>
</div>
</PanelCard>
</div> </div>
</div>
<div class="survey-side"> <div v-if="isVoteAdmin" id="vote-new-panel">
<PanelCard title="설문 목록"> <div><a href="#" @click.prevent="showNewVote = !showNewVote"> 설문 조사 열기</a></div>
<SkeletonLines v-if="loading" :lines="4" /> <template v-if="showNewVote">
<div v-else class="poll-list"> <div class="admin-row">
<div v-if="polls.length === 0" class="placeholder">등록된 설문이 없습니다.</div> <div>설문 제목</div>
<button <div><input v-model="newVoteTitle" class="form-control" type="text" /></div>
v-for="poll in polls" </div>
:key="poll.id" <div class="admin-row">
class="poll-list-item" <div>설문 대상(엔터로 구분) ({{ newVoteOptions.length }})</div>
:class="{ active: poll.id === currentPoll?.id }" <div>
@click="selectPoll(poll.id)" <textarea
> v-model="newVoteOptionsText"
<div class="poll-title">{{ poll.title }}</div> class="form-control"
<div class="poll-meta-row"> :rows="newVoteOptions.length + 1"
<span>{{ formatDate(poll.startAt) }}</span> ></textarea>
<span>{{ isPollEnded(poll) ? '종료됨' : '진행중' }}</span>
</div>
</button>
</div> </div>
</PanelCard> </div>
<div class="admin-row">
<div>동시 응답 (0=모두)</div>
<div>
<input
v-model.number="newVoteMultipleOptions"
class="form-control"
type="number"
min="0"
:max="newVoteOptions.length"
/>
</div>
</div>
<div class="admin-submit">
<button class="btn btn-primary" type="button" @click="submitNewVote">제출</button>
</div>
</template>
</div>
<PanelCard v-if="adminEnabled" title="관리자 패널"> <footer class="bottom_bar bg0">
<div class="admin-section"> <RouterLink class="btn btn-sammo-base2 back_btn" to="/"> 닫기</RouterLink>
<h3> 설문 생성</h3> </footer>
<label>
제목
<input v-model="newPollTitle" type="text" />
</label>
<label>
본문
<textarea v-model="newPollBody" rows="3" />
</label>
<label>
항목 (줄바꿈 구분)
<textarea v-model="newPollOptionsText" rows="4" />
</label>
<label>
동시 응답 (0=제한 없음)
<input v-model.number="newPollMultipleOptions" type="number" min="0" />
</label>
<label>
종료 시각
<input v-model="newPollEndAt" type="datetime-local" />
</label>
<label>
공개 정책
<select v-model="newPollRevealMode">
<option value="after_vote">투표 공개</option>
<option value="after_end">종료 공개</option>
</select>
</label>
<label class="checkbox">
<input v-model="newPollClosePrevious" type="checkbox" />
기존 설문 종료
</label>
<button class="ghost" @click="createPoll">설문 생성</button>
</div>
<div class="admin-section" v-if="voteDetail">
<h3>설문 수정</h3>
<p class="muted">응답 0건일 때만 수정 가능합니다.</p>
<label>
제목
<input v-model="updateTitle" type="text" />
</label>
<label>
본문
<textarea v-model="updateBody" rows="3" />
</label>
<label>
항목 추가 (줄바꿈 구분)
<textarea v-model="updateOptionsText" rows="3" />
</label>
<label>
동시 응답
<input v-model.number="updateMultipleOptions" type="number" min="0" />
</label>
<label>
종료 시각
<input v-model="updateEndAt" type="datetime-local" />
</label>
<label>
공개 정책
<select v-model="updateRevealMode">
<option value="after_vote">투표 공개</option>
<option value="after_end">종료 공개</option>
</select>
</label>
<div class="admin-actions">
<button class="ghost" @click="updatePoll">수정 적용</button>
<button class="ghost" @click="closePoll">설문 종료</button>
</div>
</div>
</PanelCard>
</div>
</section>
</main> </main>
</template> </template>
<style scoped> <style scoped>
.survey-view { .pageVote {
min-height: 100vh; margin: 0 auto;
padding: 24px; color: #fff;
display: flex; font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic';
flex-direction: column; font-size: 14px;
gap: 16px; line-height: 1.5;
} }
.page-header { .bg0 {
display: flex; background-image: url('/image/game/back_walnut.jpg');
flex-wrap: wrap;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid rgba(201, 164, 90, 0.4);
padding-bottom: 12px;
} }
.page-title { .bg1 {
font-size: 1.6rem; background-image: url('/image/game/back_green.jpg');
font-weight: 600;
} }
.page-subtitle { .bg2 {
font-size: 0.85rem; background-image: url('/image/game/back_blue.jpg');
color: rgba(232, 221, 196, 0.7);
} }
.header-actions { .back_bar {
display: flex; width: 100%;
flex-wrap: wrap; height: 32px;
gap: 8px;
}
.ghost {
border: 1px solid rgba(201, 164, 90, 0.4);
padding: 6px 12px;
font-size: 0.8rem;
cursor: pointer;
background: rgba(16, 16, 16, 0.6);
color: inherit;
}
.ghost:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error {
color: #ff8080;
}
.notice {
color: rgba(232, 221, 196, 0.8);
}
.survey-grid {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) 320px; grid-template-columns: 90px 90px 1fr 90px 90px;
gap: 16px;
} }
.survey-main, .back_bar .title {
.survey-side {
display: flex;
flex-direction: column;
gap: 16px;
}
.reward-block {
display: flex;
flex-direction: column;
gap: 6px;
}
.poll-detail {
display: flex;
flex-direction: column;
gap: 12px;
}
.poll-meta {
display: grid;
gap: 6px;
font-size: 0.9rem;
}
.poll-options {
display: flex;
flex-direction: column;
gap: 8px;
}
.poll-option {
display: flex;
gap: 8px;
align-items: center;
}
.poll-actions {
display: flex;
align-items: center;
gap: 12px;
}
.poll-results {
display: flex;
flex-direction: column;
gap: 8px;
}
.result-summary {
font-size: 0.9rem;
color: rgba(232, 221, 196, 0.8);
}
.result-row {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 12px;
align-items: center;
font-size: 0.9rem;
}
.result-count,
.result-percent {
text-align: right;
white-space: nowrap;
}
.comment-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.comment-item {
padding: 10px 12px;
border: 1px solid rgba(201, 164, 90, 0.3);
border-radius: 8px;
background: rgba(16, 16, 16, 0.5);
}
.comment-header {
display: flex;
justify-content: space-between;
font-size: 0.8rem;
color: rgba(232, 221, 196, 0.8);
}
.comment-date {
opacity: 0.7;
}
.comment-text {
margin-top: 6px;
}
.comment-form {
display: flex;
gap: 8px;
margin-top: 12px;
}
.comment-form input {
flex: 1;
padding: 6px 8px;
background: rgba(16, 16, 16, 0.6);
border: 1px solid rgba(201, 164, 90, 0.4);
color: inherit;
}
.poll-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.poll-list-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border: 1px solid rgba(201, 164, 90, 0.3);
background: rgba(16, 16, 16, 0.5);
text-align: left;
cursor: pointer;
color: inherit;
}
.poll-list-item.active {
border-color: rgba(255, 214, 140, 0.8);
background: rgba(32, 24, 12, 0.8);
}
.poll-meta-row {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: rgba(232, 221, 196, 0.7);
}
.placeholder {
color: rgba(232, 221, 196, 0.7);
}
.muted {
color: rgba(232, 221, 196, 0.6);
}
.admin-section {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
}
.admin-section h3 {
margin: 0; margin: 0;
font-size: 1rem;
} }
.admin-section label { .btn {
display: flex; min-height: 35.5px;
flex-direction: column; padding: 5.25px 10.5px;
gap: 4px; border: 1px solid transparent;
font-size: 0.85rem; border-radius: 5.25px;
color: #fff;
font: inherit;
cursor: pointer;
} }
.admin-section input, .btn:hover {
.admin-section textarea, filter: brightness(1.15);
.admin-section select {
padding: 6px 8px;
background: rgba(16, 16, 16, 0.6);
border: 1px solid rgba(201, 164, 90, 0.4);
color: inherit;
} }
.admin-section .checkbox { .btn:focus-visible {
flex-direction: row; outline: 2px solid #8ab4f8;
align-items: center; outline-offset: -2px;
gap: 8px;
} }
.admin-actions { .btn:active {
display: flex; transform: translateY(1px);
gap: 8px;
} }
@media (max-width: 1024px) { .btn:disabled {
.survey-grid { opacity: 0.65;
grid-template-columns: 1fr; cursor: default;
}
.btn-sammo-base2 {
height: 32px;
min-height: 32px;
margin-right: 2px;
border-color: #004f28;
background: #00582c;
font-weight: 600;
text-align: center;
text-decoration: none;
}
.back_bar .btn-sammo-base2 {
width: 88px;
}
.btn-primary {
border-color: #0d6efd;
background: #0d6efd;
}
#vote-title {
font-size: 1.8em;
line-height: 1.5;
text-align: center;
}
#vote-old-title {
font-size: 1.5em;
line-height: 1.5;
text-align: center;
}
#vote-result,
#vote-comment {
width: 100%;
border-collapse: collapse;
}
#vote-result th,
#vote-result td,
#vote-comment th,
#vote-comment td {
padding-right: 1ch;
padding-left: 1ch;
}
#vote-result label {
display: block;
}
#vote-result .vote-idx {
width: 5ch;
}
#vote-result .vote-count {
width: 55px;
padding-right: 0;
}
#vote-result .vote-percent {
width: 70px;
padding-left: 0;
}
.vote-submit {
width: 100%;
}
#vote-comment .comment-idx {
width: 5ch;
text-align: end;
}
#vote-comment .comment-name {
width: 110px;
text-align: center;
}
#vote-comment .comment-name span,
#vote-comment thead th:nth-child(2) span {
display: inline-block;
width: 50%;
}
#vote-comment tbody tr {
border-top: 1px solid gray;
}
.comment-submit {
width: 50%;
margin-left: 50%;
}
.form-control {
width: 100%;
min-height: 35.5px;
box-sizing: border-box;
padding: 5.25px 10.5px;
border: 1px solid #6c757d;
border-radius: 5.25px;
color: #fff;
background: #212529;
font: inherit;
}
.form-check-input {
width: 1em;
height: 1em;
margin: 0;
accent-color: #0d6efd;
}
.text-end {
text-align: end;
}
.text-center {
text-align: center;
}
.f_tnum {
font-variant-numeric: tabular-nums;
}
#vote-old-list,
#vote-new-panel {
padding: 0 7px;
}
.vote-old-item a,
#vote-new-panel a {
color: #6ea8fe;
}
.admin-row {
display: grid;
grid-template-columns: 25% 75%;
}
.admin-row > div {
padding: 2px 0;
}
.admin-submit {
width: 20%;
margin-left: 80%;
display: grid;
}
.bottom_bar {
height: 55.5px;
padding-top: 20px;
box-sizing: border-box;
}
.bottom_bar .back_btn {
display: inline-block;
width: auto;
}
.vote-notice {
padding: 5px 10px;
border: 1px solid #477a47;
color: #d8f5d8;
}
.vote-notice.error {
border-color: #9b4848;
color: #ffd0d0;
}
.loading {
padding: 12px;
text-align: center;
}
@media (min-width: 501px) {
.pageVote {
width: 1000px;
} }
.comment-form { #vote-comment .comment-name {
flex-direction: column; width: 260px;
}
#vote-comment .comment-date {
width: 98px;
padding-right: 0.5ch;
padding-left: 0.5ch;
text-align: center;
}
}
@media (max-width: 500px) {
.pageVote {
width: 500px;
}
#vote-comment .comment-name {
width: 130px;
}
#vote-comment .comment-date {
width: 50px;
padding-right: 0.5ch;
padding-left: 0.5ch;
text-align: center;
}
.admin-row {
grid-template-columns: 100%;
} }
} }
</style> </style>
+4 -1
View File
@@ -106,7 +106,10 @@ Move items into the main docs once they are finalized.
- [AI suggestion] Replace smoke/placeholder tests with concrete assertions for success/failure outcomes (e.g., 등용, NPC능동 invalid args, 출병 troop creation). - [AI suggestion] Replace smoke/placeholder tests with concrete assertions for success/failure outcomes (e.g., 등용, NPC능동 invalid args, 출병 troop creation).
- [AI suggestion] Document che*출병 parity gaps vs legacy (city state/term=43, fallback to che*이동 when friendly, nation.war/AllowWar check, post-war static events/unique-item lottery, missing route data when map/diplomacy not provided). - [AI suggestion] Document che*출병 parity gaps vs legacy (city state/term=43, fallback to che*이동 when friendly, nation.war/AllowWar check, post-war static events/unique-item lottery, missing route data when map/diplomacy not provided).
- [AI suggestion] Verify that "이호경식" followed by "출병" is correctly blocked with the new reserved-turn overlay (diplomacy state sync). - [AI suggestion] Verify that "이호경식" followed by "출병" is correctly blocked with the new reserved-turn overlay (diplomacy state sync).
- [AI suggestion] Survey/unique lottery should account for auction/storage-held unique items once the inventory and auction models are finalized. - [AI suggestion] Migrate the legacy `storage` namespaces used to reserve
unequipped unique items. Survey, monthly nation-level, and general-command
lotteries now count equipped items plus active/finalizing unique auctions,
but core2026 has no equivalent persistent storage namespace to include yet.
## Trigger System ## Trigger System
+18 -11
View File
@@ -36,17 +36,18 @@ storage, route guards, and image loading.
## Enforced contracts ## Enforced contracts
| Screen | Ref entry point | Current automated contract | | Screen | Ref entry point | Current automated contract |
| ------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | | -------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| gateway login/status | `index.php` | 450/700px desktop widths, mobile collapse, Pretendard title, real login mutation/session storage, actual seasonal map asset | | gateway login/status | `index.php` | 450/700px desktop widths, mobile collapse, Pretendard title, real login mutation/session storage, actual seasonal map asset |
| gateway account | `i_entrance/user_info.php` | 550px × minimum 575px panel, 14px Pretendard, three legacy textures, success and API-error password flows | | gateway account | `i_entrance/user_info.php` | 550px × minimum 575px panel, 14px Pretendard, three legacy textures, success and API-error password flows |
| gateway OAuth join | `oauth_kakao/join.php` | 700px centered registration card, Kakao exchange/register success, retained-input API error, hover/focus | | gateway OAuth join | `oauth_kakao/join.php` | 700px centered registration card, Kakao exchange/register success, retained-input API error, hover/focus |
| game login hand-off | unauthenticated `hwe/index.php` redirect | `/che/login` delegates to `/gateway/` | | game login hand-off | unauthenticated `hwe/index.php` redirect | `/che/login` delegates to `/gateway/` |
| troop | `hwe/v_troop.php` | existing `app/game-frontend/e2e/troop.spec.ts` desktop/mobile geometry and interaction suite | | troop | `hwe/v_troop.php` | existing `app/game-frontend/e2e/troop.spec.ts` desktop/mobile geometry and interaction suite |
| hall of fame | `hwe/a_hallOfFame.php` | 500/1000px container, 100px ranking cells, 64px natural image, walnut/green textures, Pretendard, close-button focus | | hall of fame | `hwe/a_hallOfFame.php` | 500/1000px container, 100px ranking cells, 64px natural image, walnut/green textures, Pretendard, close-button focus |
| yearbook | `hwe/v_history.php` | 1000px 700+300 desktop grid, 500px stacked grid, month navigation, legacy textures, success and API-error flows | | yearbook | `hwe/v_history.php` | 1000px 700+300 desktop grid, 500px stacked grid, month navigation, legacy textures, success and API-error flows |
| nation betting | `hwe/v_nationBetting.php` | 1000px/6-column desktop and 500px/3-column mobile grids, picked card style, payout table, success and retained-form error | | nation betting | `hwe/v_nationBetting.php` | 1000px/6-column desktop and 500px/3-column mobile grids, picked card style, payout table, success and retained-form error |
| public NPC list | `hwe/a_npcList.php` | 1000px 12-column table with Chromium-expanded legacy widths, NPC color, eight sorts, retained table/sort after API error | | public NPC list | `hwe/a_npcList.php` | 1000px 12-column table with Chromium-expanded legacy widths, NPC color, eight sorts, retained table/sort after API error |
| survey | `hwe/v_vote.php`, `hwe/ts/PageVote.vue` | 1000/500px fixed container, blue title/green table textures, list/detail/results/comments, selection/focus/hover, submit and retained-selection API error |
The global game baseline is black, white, Pretendard 14px. Legacy texture The global game baseline is black, white, Pretendard 14px. Legacy texture
helpers intentionally follow `common.orig.css`: `bg0` is walnut, `bg1` is helpers intentionally follow `common.orig.css`: `bg0` is walnut, `bg1` is
@@ -54,6 +55,12 @@ green, and `bg2` is blue. Shared `PanelCard` uses the same walnut body and green
header so screens that still use the common component no longer inherit the header so screens that still use the common component no longer inherit the
discarded Galmuri/parchment visual system. discarded Galmuri/parchment visual system.
The survey fixture covers both the poll list and an open detail. Its result
rows are visible before the current general votes when the poll uses the
legacy-compatible `after_vote` mode. The mutation fixture covers voting and
comment submission, while the error fixture confirms that a failed vote keeps
the selected radio option so the user can retry.
## Route coverage rule ## Route coverage rule
Adding or changing a frontend route requires: Adding or changing a frontend route requires:
@@ -144,6 +144,24 @@ export const countOccupiedUniqueItems = (
return counts; return counts;
}; };
export const addOccupiedUniqueItemKeys = (
counts: Map<string, number>,
itemKeys: Iterable<string | null | undefined>,
itemRegistry: Map<string, ItemModule>
): Map<string, number> => {
for (const itemKey of itemKeys) {
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 joinYearMonth = (year: number, month: number): number => year * 12 + month - 1;
const serializeSeed = (...values: Array<string | number>): string => const serializeSeed = (...values: Array<string | number>): string =>
+17
View File
@@ -5,6 +5,7 @@ import type { GeneralItemSlots } from '../src/domain/entities.js';
import type { ItemModule } from '../src/items/types.js'; import type { ItemModule } from '../src/items/types.js';
import { import {
type UniqueAcquireType, type UniqueAcquireType,
addOccupiedUniqueItemKeys,
buildVoteUniqueSeed, buildVoteUniqueSeed,
countOccupiedUniqueItems, countOccupiedUniqueItems,
resolveUniqueConfig, resolveUniqueConfig,
@@ -131,4 +132,20 @@ describe('unique lottery', () => {
expect(counts.get('uniqueItem')).toBe(1); expect(counts.get('uniqueItem')).toBe(1);
expect(counts.get('buyableItem')).toBeUndefined(); expect(counts.get('buyableItem')).toBeUndefined();
}); });
it('adds active auction and storage reservations without counting buyable items', () => {
const itemRegistry = new Map<string, ItemModule>([
['uniqueItem', buildItem('uniqueItem', 'weapon', false)],
['buyableItem', buildItem('buyableItem', 'book', true)],
]);
const counts = addOccupiedUniqueItemKeys(
new Map([['uniqueItem', 1]]),
['uniqueItem', 'uniqueItem', 'buyableItem', null],
itemRegistry
);
expect(counts.get('uniqueItem')).toBe(3);
expect(counts.get('buyableItem')).toBeUndefined();
});
}); });
@@ -163,5 +163,63 @@ export const canonicalFrontendFixture = {
globalAction: ['<L>●</> 유비가 내정을 수행했습니다.'], globalAction: ['<L>●</> 유비가 내정을 수행했습니다.'],
}, },
}, },
surveyList: {
polls: [
{
id: 2,
title: '선호하는 병종',
startAt: '2026-07-26T00:00:00.000Z',
endAt: null,
closedAt: null,
revealMode: 'after_vote',
optionsCount: 3,
multipleOptions: 1,
},
{
id: 1,
title: '지난 설문',
startAt: '2026-07-20T00:00:00.000Z',
endAt: '2026-07-21T00:00:00.000Z',
closedAt: '2026-07-21T00:00:00.000Z',
revealMode: 'after_vote',
optionsCount: 2,
multipleOptions: 1,
},
],
voteReward: 90,
},
surveyDetail: {
voteInfo: {
id: 2,
title: '선호하는 병종',
body: '',
options: ['보병', '기병', '궁병'],
multipleOptions: 1,
revealMode: 'after_vote',
openerGeneralId: 9,
openerName: '설문관리자',
startAt: '2026-07-26T00:00:00.000Z',
endAt: null,
closedAt: null,
},
votes: [
{ selection: [0], count: 2 },
{ selection: [1], count: 1 },
],
comments: [
{
id: 1,
voteId: 2,
generalId: 3,
nationId: 1,
generalName: '관우',
nationName: '촉',
text: '기병이 좋습니다.',
createdAt: '2026-07-26T01:23:00.000Z',
},
],
myVote: null,
userCnt: 17,
},
}, },
} as const; } as const;
@@ -111,6 +111,8 @@ const installHallFixture = async (page: Page): Promise<void> => {
}; };
const installAuthenticatedGameFixture = async (page: Page): Promise<void> => { const installAuthenticatedGameFixture = async (page: Page): Promise<void> => {
let surveyVoted = false;
const surveyComments = fixture.game.surveyDetail.comments.map((comment) => ({ ...comment }));
await installImages(page); await installImages(page);
await page.addInitScript( await page.addInitScript(
({ gameToken, profile }) => { ({ gameToken, profile }) => {
@@ -139,6 +141,38 @@ const installAuthenticatedGameFixture = async (page: Page): Promise<void> => {
if (operation === 'public.getMapLayout') return fixture.game.mapLayout; if (operation === 'public.getMapLayout') return fixture.game.mapLayout;
if (operation === 'yearbook.getRange') return fixture.game.yearbookRange; if (operation === 'yearbook.getRange') return fixture.game.yearbookRange;
if (operation === 'yearbook.getHistory') return fixture.game.yearbook; if (operation === 'yearbook.getHistory') return fixture.game.yearbook;
if (operation === 'vote.getVoteList') return fixture.game.surveyList;
if (operation === 'vote.getVoteDetail') {
return {
...fixture.game.surveyDetail,
votes: surveyVoted
? [
{ selection: [0], count: 3 },
{ selection: [1], count: 1 },
]
: fixture.game.surveyDetail.votes,
comments: surveyComments,
myVote: surveyVoted ? [0] : null,
};
}
if (operation === 'vote.submitVote') {
surveyVoted = true;
return { ok: true, wonLottery: false };
}
if (operation === 'vote.addComment') {
surveyComments.push({
id: surveyComments.length + 1,
voteId: 2,
generalId: 1,
nationId: 1,
generalName: '유비',
nationName: '촉',
text: '새 댓글',
createdAt: '2026-07-26T02:34:00.000Z',
});
return { ok: true };
}
if (operation === 'vote.getAdminStatus') return { ok: false };
throw new Error(`Unhandled authenticated game fixture operation: ${operation}`); throw new Error(`Unhandled authenticated game fixture operation: ${operation}`);
}); });
}); });
@@ -494,3 +528,104 @@ test.describe('yearbook legacy parity', () => {
await expect(page.getByLabel('연월 선택')).toBeVisible(); await expect(page.getByLabel('연월 선택')).toBeVisible();
}); });
}); });
test.describe('survey legacy parity', () => {
test.beforeEach(async ({ page }) => {
await installAuthenticatedGameFixture(page);
});
for (const viewport of [
{ name: 'desktop', width: 1365, height: 768, containerWidth: 1000, commentNameWidth: 260 },
{ name: 'mobile', width: 390, height: 844, containerWidth: 500, commentNameWidth: 130 },
]) {
test(`renders the ref vote tables on ${viewport.name}`, async ({ page }) => {
await page.setViewportSize(viewport);
await page.goto('http://127.0.0.1:15102/che/survey');
await expect(page.getByText('설문 조사(90금과 추첨으로 유니크템 증정!)')).toBeVisible();
await expect(page.getByText('기병이 좋습니다.')).toBeVisible();
await expect(page.locator('#vote-new-panel')).toHaveCount(0);
if (artifactRoot) {
await page.screenshot({
path: resolve(artifactRoot, `survey-core-${viewport.name}.png`),
fullPage: true,
animations: 'disabled',
});
}
const geometry = await page.evaluate(() => {
const rect = (selector: string) =>
document.querySelector<HTMLElement>(selector)!.getBoundingClientRect();
const container = document.querySelector<HTMLElement>('#container')!;
const title = document.querySelector<HTMLElement>('#vote-title')!;
return {
containerWidth: rect('#container').width,
containerHeight: rect('#container').height,
resultWidth: rect('#vote-result').width,
commentNameWidth: rect('#vote-comment .comment-name').width,
fontFamily: getComputedStyle(container).fontFamily,
fontSize: getComputedStyle(container).fontSize,
backgroundImage: getComputedStyle(container).backgroundImage,
title: {
height: rect('#vote-title').height,
fontSize: getComputedStyle(title).fontSize,
backgroundImage: getComputedStyle(title).backgroundImage,
},
};
});
expect(geometry.containerWidth).toBe(viewport.containerWidth);
expect(geometry.containerHeight).toBeLessThan(viewport.height);
expect(geometry.resultWidth).toBe(viewport.containerWidth);
expect(geometry.commentNameWidth).toBe(viewport.commentNameWidth);
expect(geometry.fontFamily).toContain('Pretendard');
expect(geometry.fontSize).toBe('14px');
expect(geometry.backgroundImage).toContain('back_walnut.jpg');
expect(geometry.title.height).toBeCloseTo(37.8, 0);
expect(geometry.title.fontSize).toBe('25.2px');
expect(geometry.title.backgroundImage).toContain('back_blue.jpg');
const secondOption = page.locator('#v-vote-1');
await secondOption.check();
await expect(secondOption).toBeChecked();
await secondOption.focus();
await expect(secondOption).toBeFocused();
const voteButton = page.getByRole('button', { name: '투표', exact: true });
const beforeHover = await voteButton.evaluate((element) => getComputedStyle(element).filter);
await voteButton.hover();
const afterHover = await voteButton.evaluate((element) => getComputedStyle(element).filter);
expect(afterHover).not.toBe(beforeHover);
});
}
test('submits a vote and comment through the real screen controls', async ({ page }) => {
await page.goto('http://127.0.0.1:15102/che/survey');
await page.getByRole('button', { name: '투표', exact: true }).click();
await expect(page.getByRole('status')).toHaveText('설문을 마쳤습니다.');
await expect(page.getByText('결산', { exact: true })).toBeVisible();
await page.getByLabel('댓글').fill('새 댓글');
await page.getByRole('button', { name: '댓글 달기' }).click();
await expect(page.getByText('새 댓글')).toBeVisible();
});
test('keeps the selected option after a vote API error', async ({ page }) => {
await page.route('**/che/api/trpc/**', async (route) => {
if (operationNames(route).includes('vote.submitVote')) {
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: { message: '이미 설문조사를 완료하였습니다.' } }),
});
return;
}
await route.fallback();
});
await page.goto('http://127.0.0.1:15102/che/survey');
const secondOption = page.locator('#v-vote-1');
await secondOption.check();
await page.getByRole('button', { name: '투표', exact: true }).click();
await expect(page.getByRole('alert')).toBeVisible();
await expect(secondOption).toBeChecked();
});
});