feat: port scenario 903 select-pool flow

This commit is contained in:
2026-07-30 23:27:59 +00:00
parent 9bd057456b
commit 115218ded8
80 changed files with 6859 additions and 48 deletions
+1
View File
@@ -34,6 +34,7 @@
"@fastify/cors": "^11.2.0",
"@fastify/static": "^9.0.0",
"@sammo-ts/common": "workspace:*",
"@sammo-ts/game-engine": "workspace:*",
"@sammo-ts/infra": "workspace:*",
"@sammo-ts/logic": "workspace:*",
"@trpc/server": "^11.8.1",
+3 -1
View File
@@ -41,7 +41,9 @@ const parsePayload = (value: unknown): GameSessionTokenPayload | null => {
typeof user.id !== 'string' ||
typeof user.username !== 'string' ||
typeof user.displayName !== 'string' ||
!Array.isArray(user.roles)
!Array.isArray(user.roles) ||
(user.legacyMemberNo !== undefined &&
(!Number.isSafeInteger(user.legacyMemberNo) || user.legacyMemberNo <= 0))
) {
return null;
}
+7 -2
View File
@@ -19,7 +19,9 @@ import {
createTraitCatalog,
createOfficerLevelActionModules,
DOMESTIC_TRAIT_KEYS,
EVENT_DOMESTIC_TRAIT_KEYS,
loadDomesticTraitModules,
loadEventDomesticTraitModules,
loadNationTraitModules,
loadPersonalityTraitModules,
loadWarTraitModules,
@@ -52,7 +54,10 @@ const itemWarModules: WarActionModule[] = createItemActionModules(
).war;
const crewTypeWarTriggerRegistry = createCrewTypeWarTriggerRegistry();
const traitCatalog = createTraitCatalog({
domestic: await loadDomesticTraitModules([...DOMESTIC_TRAIT_KEYS]),
domestic: [
...(await loadDomesticTraitModules([...DOMESTIC_TRAIT_KEYS])),
...(await loadEventDomesticTraitModules([...EVENT_DOMESTIC_TRAIT_KEYS])),
],
war: await loadWarTraitModules([...WAR_TRAIT_KEYS]),
personality: await loadPersonalityTraitModules([...PERSONALITY_TRAIT_KEYS]),
nation: await loadNationTraitModules([...NATION_TRAIT_KEYS]),
@@ -145,7 +150,7 @@ const mapGeneralPayload = (payload: BattleSimJobPayload['attackerGeneral']): Gen
officerLevel: payload.officer_level,
role: {
personality: payload.personal,
specialDomestic: null,
specialDomestic: payload.special ?? null,
specialWar: payload.special2,
items: {
horse: normalizeItemCode(payload.horse),
+1
View File
@@ -8,6 +8,7 @@ export const zBattleSimGeneral = z.object({
nation: z.number().int().positive(),
turntime: z.string().min(1),
personal: z.string().nullable(),
special: z.string().nullable().optional(),
special2: z.string().nullable(),
crew: z.number().int().min(0),
crewtype: z.number().int().positive(),
@@ -1,5 +1,7 @@
import {
ITEM_KEYS,
EVENT_DOMESTIC_TRAIT_KEYS,
loadEventDomesticTraitModules,
loadItemModules,
loadNationTraitModules,
loadPersonalityTraitModules,
@@ -95,22 +97,26 @@ const toTraitOption = (module: TraitModule): BattleSimTraitOption => ({
let cachedTraitOptions: Promise<{
nationTypes: BattleSimTraitOption[];
eventDomesticTraits: BattleSimTraitOption[];
warTraits: BattleSimTraitOption[];
personalities: BattleSimTraitOption[];
}> | null = null;
export const loadBattleSimTraitOptions = async (): Promise<{
nationTypes: BattleSimTraitOption[];
eventDomesticTraits: BattleSimTraitOption[];
warTraits: BattleSimTraitOption[];
personalities: BattleSimTraitOption[];
}> => {
if (!cachedTraitOptions) {
cachedTraitOptions = Promise.all([
loadNationTraitModules([...NATION_TRAIT_KEYS]),
loadEventDomesticTraitModules([...EVENT_DOMESTIC_TRAIT_KEYS]),
loadWarTraitModules([...WAR_TRAIT_KEYS]),
loadPersonalityTraitModules([...PERSONALITY_TRAIT_KEYS]),
]).then(([nationTraits, warTraits, personalities]) => ({
]).then(([nationTraits, eventDomesticTraits, warTraits, personalities]) => ({
nationTypes: nationTraits.map(toTraitOption),
eventDomesticTraits: eventDomesticTraits.map(toTraitOption),
warTraits: warTraits.map(toTraitOption),
personalities: personalities.map(toTraitOption),
}));
+1
View File
@@ -10,6 +10,7 @@ export interface BattleSimGeneralPayload {
nation: number;
turntime: string;
personal: string | null;
special?: string | null;
special2: string | null;
crew: number;
crewtype: number;
+16 -2
View File
@@ -29,6 +29,16 @@ export class ConflictingTurnDaemonCommandError extends Error {
}
}
export class FailedTurnDaemonCommandError extends Error {
constructor(
readonly requestId: string,
readonly storedError: string | null
) {
super(storedError ?? `Engine input event ${requestId} failed.`);
this.name = 'FailedTurnDaemonCommandError';
}
}
export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
constructor(
private readonly db: DatabaseClient,
@@ -45,6 +55,10 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
target: 'ENGINE',
eventType: command.type,
payload: asJson(durableCommand),
actorUserId:
'userId' in command && typeof command.userId === 'string'
? command.userId
: null,
},
});
} catch (error) {
@@ -80,13 +94,13 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
while (Date.now() < deadline) {
const event = await this.db.inputEvent.findUnique({
where: { requestId },
select: { status: true, result: true },
select: { status: true, result: true, error: true },
});
if (event?.status === 'SUCCEEDED') {
return event.result as T;
}
if (event?.status === 'FAILED') {
return null;
throw new FailedTurnDaemonCommandError(requestId, event.error);
}
await delay(Math.min(50, Math.max(1, deadline - Date.now())));
}
+8 -1
View File
@@ -5,7 +5,7 @@ import { isAfter, isValid, parseISO } from 'date-fns';
import { z } from 'zod';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import { procedure, router } from '../../trpc.js';
import { authedProcedure, procedure, router } from '../../trpc.js';
const parseDate = (value: string): Date | null => {
const parsed = parseISO(value);
@@ -45,6 +45,13 @@ const verifyGatewayToken = (
};
export const authRouter = router({
status: authedProcedure.query(({ ctx }) => {
const userId = ctx.auth?.user.id;
if (!userId) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return { userId };
}),
exchangeGatewayToken: procedure
.input(z.object({ gatewayToken: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
+3
View File
@@ -112,6 +112,7 @@ export const battleRouter = router({
crewTypes,
},
nationTypes: traits.nationTypes,
eventDomesticTraits: traits.eventDomesticTraits,
warTraits: traits.warTraits,
personalities: traits.personalities,
items,
@@ -207,6 +208,7 @@ export const battleRouter = router({
bookCode: true,
itemCode: true,
personalCode: true,
specialCode: true,
special2Code: true,
meta: true,
},
@@ -241,6 +243,7 @@ export const battleRouter = router({
injury: general.injury,
rice: general.rice,
personal: normalizeOptionalKey(general.personalCode),
special: normalizeOptionalKey(general.specialCode),
special2: normalizeOptionalKey(general.special2Code),
crew: general.crew,
crewtype: general.crewTypeId,
+165 -3
View File
@@ -2,8 +2,8 @@ import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { randomBytes } from 'node:crypto';
import type { DatabaseClient, WorldStateRow } from '../../context.js';
import { authedProcedure, router } from '../../trpc.js';
import type { DatabaseClient, GameApiContext, WorldStateRow } from '../../context.js';
import { authedProcedure, engineAuthedProcedure, router } from '../../trpc.js';
import { asNumber, asRecord, asStringArray, LiteHashDRBG } from '@sammo-ts/common';
import {
isPersonalityTraitKey,
@@ -21,6 +21,58 @@ import {
resolveInheritConstants,
setInheritancePoint,
} from '../../services/inheritance.js';
import {
getSelectionPoolStatus,
reserveSelectionPool,
resolveSelectionMaxGeneral,
} from '../../services/selectPool.js';
const resolveSelectionCommandResult = (
result:
| Awaited<ReturnType<GameApiContext['turnDaemon']['requestCommand']>>
| null,
expectedType: 'selectPoolCreate' | 'selectPoolReselect'
): { ok: true; generalId: number } => {
if (!result) {
throw new TRPCError({
code: 'TIMEOUT',
message:
'장수 선택 요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다. 같은 요청으로 다시 시도해 주세요.',
});
}
if (result.type !== expectedType) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: '턴 데몬이 올바르지 않은 장수 선택 결과를 반환했습니다.',
});
}
if (!result.ok) {
throw new TRPCError({
code: result.code,
message: result.reason,
});
}
return { ok: true, generalId: result.generalId };
};
const resolveSelectionRequestId = (
contextRequestId: string | undefined,
userId: string,
clientRequestId: string | undefined,
operation: 'create' | 'reselect'
): string | undefined => {
if (clientRequestId) {
return `select-pool:${userId}:${clientRequestId}:${operation}`;
}
if (!contextRequestId) {
return undefined;
}
const path =
operation === 'create'
? 'join.selectPoolGeneral'
: 'join.reselectPoolGeneral';
return `${contextRequestId}:${path}`;
};
const DEFAULT_JOIN_STAT = {
total: 165,
@@ -181,10 +233,12 @@ export const joinRouter = router({
const availableSpecialWar = asStringArray(configConst.availableSpecialWar);
const warKeys = availableSpecialWar.length > 0 ? availableSpecialWar : [...WAR_TRAIT_KEYS];
const [personalities, warSpecials, nationRows] = await Promise.all([
const [personalities, warSpecials, nationRows, userGeneralCount, npcGeneralCount] =
await Promise.all([
loadPersonalityOptions(),
loadWarOptions(warKeys),
ctx.db.nation.findMany({
where: { id: { gt: 0 } },
select: {
id: true,
name: true,
@@ -193,6 +247,8 @@ export const joinRouter = router({
},
orderBy: { id: 'asc' },
}),
ctx.db.general.count({ where: { npcState: { lt: 2 } } }),
ctx.db.general.count({ where: { npcState: { gte: 2 } } }),
]);
const nations = nationRows.map((nation) => {
@@ -209,7 +265,13 @@ export const joinRouter = router({
const inheritTotalPoint = ctx.auth?.user.id
? await readInheritancePoint(ctx.db, ctx.auth.user.id, 'previous')
: 0;
const selectionPool = await getSelectionPoolStatus(
ctx.db,
worldState,
ctx.auth?.user.id ?? ''
);
const tickMinutes = Math.max(1, Math.round(worldState.tickSeconds / 60));
const maxGeneral = resolveSelectionMaxGeneral(worldState);
const inheritCitiesRaw = await ctx.db.city.findMany({
where: { level: { in: [5, 6] }, nationId: 0 },
select: { id: true, name: true, level: true, region: true },
@@ -239,6 +301,14 @@ export const joinRouter = router({
],
warSpecials,
nations,
serverInfo: {
currentYear: worldState.currentYear,
currentMonth: worldState.currentMonth,
tickMinutes,
maxGeneral,
userGeneralCount,
npcGeneralCount,
},
inherit: {
totalPoint: inheritTotalPoint,
costs: {
@@ -251,8 +321,93 @@ export const joinRouter = router({
turnTimeZones: buildTurnTimeZones(tickMinutes),
availableSpecialWar: warSpecials,
},
selectionPool,
};
}),
getSelectionPool: authedProcedure.mutation(async ({ ctx }) => {
const userId = ctx.auth?.user.id;
if (!userId) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
const worldState = await ctx.db.worldState.findFirst();
if (!worldState) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'World state is not initialized.',
});
}
return reserveSelectionPool({
db: ctx.db,
worldState,
userId,
seedOwnerIdentity: ctx.auth?.user.legacyMemberNo ?? userId,
});
}),
selectPoolGeneral: engineAuthedProcedure
.input(
z.object({
uniqueName: z.string().min(1).max(20),
personality: z.string().min(1),
clientRequestId: z.string().uuid().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const auth = ctx.auth;
if (!auth) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
const userId = auth.user.id;
if (auth.identity?.canCreateGeneral === false) {
throw new TRPCError({
code: 'FORBIDDEN',
message: '이 서버에서는 카카오 인증을 완료해야 장수를 생성할 수 있습니다.',
});
}
const commandRequestId = resolveSelectionRequestId(
ctx.requestId,
userId,
input.clientRequestId,
'create'
);
const result = await ctx.turnDaemon.requestCommand({
type: 'selectPoolCreate',
...(commandRequestId ? { requestId: commandRequestId } : {}),
userId,
ownerDisplayName: auth.user.displayName,
uniqueName: input.uniqueName,
personality: input.personality,
seedOwnerIdentity: auth.user.legacyMemberNo ?? userId,
});
return resolveSelectionCommandResult(result, 'selectPoolCreate');
}),
reselectPoolGeneral: engineAuthedProcedure
.input(
z.object({
uniqueName: z.string().min(1).max(20),
clientRequestId: z.string().uuid().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const auth = ctx.auth;
if (!auth) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
const userId = auth.user.id;
const commandRequestId = resolveSelectionRequestId(
ctx.requestId,
userId,
input.clientRequestId,
'reselect'
);
const result = await ctx.turnDaemon.requestCommand({
type: 'selectPoolReselect',
...(commandRequestId ? { requestId: commandRequestId } : {}),
userId,
ownerDisplayName: auth.user.displayName,
uniqueName: input.uniqueName,
});
return resolveSelectionCommandResult(result, 'selectPoolReselect');
}),
createGeneral: authedProcedure
.input(
z.object({
@@ -287,6 +442,13 @@ export const joinRouter = router({
message: 'World state is not initialized.',
});
}
const selectionPool = await getSelectionPoolStatus(ctx.db, worldState, userId);
if (selectionPool.enabled) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: '장수 선택 목록에서 장수를 골라 주세요.',
});
}
const joinPolicy = resolveJoinPolicy(worldState);
if (joinPolicy.blockGeneralCreate === 1) {
+4 -1
View File
@@ -1,6 +1,7 @@
import { TRPCError } from '@trpc/server';
import { zWorldStateConfig, zWorldStateMeta } from '../../context.js';
import { isSelectionPoolWorld } from '../../services/selectPool.js';
import { procedure, router } from '../../trpc.js';
export const lobbyRouter = router({
@@ -27,12 +28,13 @@ export const lobbyRouter = router({
if (ctx.auth?.user.id) {
const general = await ctx.db.general.findFirst({
where: { userId: ctx.auth.user.id },
select: { name: true, picture: true },
select: { name: true, picture: true, imageServer: true },
});
if (general) {
myGeneral = {
name: general.name,
picture: general.picture,
imageServer: general.imageServer,
};
}
}
@@ -51,6 +53,7 @@ export const lobbyRouter = router({
turntime: worldState.meta.turntime ?? '',
otherTextInfo: worldState.meta.otherTextInfo ?? '',
isUnited: worldState.meta.isUnited ?? 0,
selectionPoolEnabled: isSelectionPoolWorld(rawWorldState),
myGeneral,
};
}),
+13
View File
@@ -5,11 +5,14 @@ import { asNumber, asRecord } from '@sammo-ts/common';
import {
createIncomeActionContext,
DomesticTraitLoader,
EventDomesticTraitLoader,
isDomesticTraitKey,
isEventDomesticTraitKey,
isNationTraitKey,
isPersonalityTraitKey,
isWarTraitKey,
loadDomesticTraitModules,
loadEventDomesticTraitModules,
loadNationTraitModules,
loadPersonalityTraitModules,
loadWarTraitModules,
@@ -301,12 +304,22 @@ export const loadTraitNames = async (keys: Array<string | null>, kind: keyof Tra
if (kind === 'domestic') {
const filtered = missing.filter((key) => isDomesticTraitKey(key));
const eventFiltered = missing.filter((key) => isEventDomesticTraitKey(key));
if (filtered.length) {
const modules = await loadDomesticTraitModules(filtered, new DomesticTraitLoader());
for (const module of modules) {
cache.set(module.key, { name: module.name, info: module.info ?? '' });
}
}
if (eventFiltered.length) {
const modules = await loadEventDomesticTraitModules(
eventFiltered,
new EventDomesticTraitLoader()
);
for (const module of modules) {
cache.set(module.key, { name: module.name, info: module.info ?? '' });
}
}
} else if (kind === 'war') {
const filtered = missing.filter((key) => isWarTraitKey(key));
if (filtered.length) {
+12
View File
@@ -0,0 +1,12 @@
export {
buildSelectPoolSeed,
claimWeightedSelectionCandidates,
getSelectionPoolStatus,
isSelectionPoolWorld,
reserveSelectionPool,
resolveSelectionMaxGeneral,
SelectPoolError,
type SelectPoolCandidateDto,
type SelectPoolCandidateInfo,
type SelectPoolReservationDto,
} from '@sammo-ts/game-engine';
+5
View File
@@ -71,6 +71,11 @@ export const router = t.router;
export const procedure = t.procedure.use(inputEventMiddleware);
export const authedProcedure: typeof procedure = procedure.use(requireAuthMiddleware);
// 턴 데몬이 ENGINE input_event와 world/DB 변경을 자체 transaction으로
// 커밋하는 mutation에 사용한다. API input-event transaction으로 한 번 더
// 감싸면 daemon이 아직 commit되지 않은 command를 볼 수 없어 교착된다.
export const engineAuthedProcedure: typeof procedure = t.procedure.use(requireAuthMiddleware);
// 페이지 조회 계측처럼 game state/input-event 원장과 무관한 세션 보조
// mutation에 사용한다. gameplay state 변경에는 사용하지 않는다.
export const sessionActivityProcedure = t.procedure;
@@ -300,6 +300,16 @@ describe('battle sim processor', () => {
expect(strong.killed).toBeGreaterThan(baseline.killed ?? 0);
});
it('applies the event-domestic trait carried by an imported general', () => {
const baseline = processBattleSimJob(buildPayload('battle'));
const payload = buildPayload('battle');
payload.attackerGeneral.special = 'che_event_무쌍';
const eventDomestic = processBattleSimJob(payload);
expect(eventDomestic.killed).toBeGreaterThan(baseline.killed ?? 0);
expect(eventDomestic).not.toEqual(baseline);
});
it('keeps StrongAttacker city combat identical but applies MoreEffect to it', () => {
const baselinePayload = buildPayload('battle');
baselinePayload.defenderGenerals = [];
@@ -427,6 +427,7 @@ describe('battle simulator general import permissions', () => {
bookCode: null,
itemCode: null,
personalCode: null,
specialCode: null,
special2Code: null,
meta: {},
...overrides,
@@ -447,6 +448,7 @@ describe('battle simulator general import permissions', () => {
weaponCode: 'che_의천검',
bookCode: 'che_손자병법',
itemCode: 'che_옥새',
specialCode: 'che_event_신산',
meta: {
dex1: 10000,
rank_warnum: 33,
@@ -483,6 +485,7 @@ describe('battle simulator general import permissions', () => {
warnum: 33,
killnum: 22,
killcrew: 1111,
special: 'che_event_신산',
});
const redacted = await foreign.battle.getGeneralDetail({ generalId: ally.id });
@@ -499,6 +502,7 @@ describe('battle simulator general import permissions', () => {
warnum: 0,
killnum: 0,
killcrew: 0,
special: 'che_event_신산',
});
});
@@ -2,7 +2,11 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
import { DuplicateInputEventError, executeInputEvent } from '../src/inputEventBoundary.js';
import { ConflictingTurnDaemonCommandError, DatabaseTurnDaemonTransport } from '../src/daemon/databaseTransport.js';
import {
ConflictingTurnDaemonCommandError,
DatabaseTurnDaemonTransport,
FailedTurnDaemonCommandError,
} from '../src/daemon/databaseTransport.js';
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
@@ -144,4 +148,25 @@ integration('API input event boundary', () => {
ConflictingTurnDaemonCommandError
);
});
it('distinguishes a stored terminal engine failure from a result timeout', async () => {
const transport = new DatabaseTurnDaemonTransport(db, 100);
const requestId = 'integration:api:engine-failed';
const command = { type: 'vacation' as const, requestId, generalId: 7 };
await transport.sendCommand(command);
await db.inputEvent.update({
where: { requestId },
data: {
status: 'FAILED',
error: 'injected terminal engine failure',
completedAt: new Date(),
},
});
await expect(transport.requestCommand(command)).rejects.toMatchObject({
name: FailedTurnDaemonCommandError.name,
requestId,
storedError: 'injected terminal engine failure',
});
});
});
+23
View File
@@ -274,6 +274,29 @@ describe('appRouter', () => {
});
});
it('reports the authenticated game access-token identity', async () => {
const caller = appRouter.createCaller(buildContext({ auth: buildAuth() }));
await expect(caller.auth.status()).resolves.toEqual({ userId: 'user-1' });
});
it('rejects unauthenticated or game-blocked auth status checks', async () => {
await expect(
appRouter.createCaller(buildContext({ auth: null })).auth.status()
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
await expect(
appRouter
.createCaller(
buildContext({
auth: buildAuth({
suspendedUntil: '2099-01-01T00:00:00.000Z',
}),
})
)
.auth.status()
).rejects.toMatchObject({ code: 'FORBIDDEN' });
});
it('does not apply an expired or message-only restriction to other game APIs', async () => {
const caller = appRouter.createCaller(
buildContext({
@@ -0,0 +1,582 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { RANK_DATA_TYPES } from '@sammo-ts/common';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import {
createTurnDaemonRuntime,
seedScenarioToDatabase,
type TurnDaemonRuntime,
} from '@sammo-ts/game-engine';
import {
createGamePostgresConnector,
type GamePrisma,
type GamePrismaClient,
type RedisConnector,
} from '@sammo-ts/infra';
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js';
import type { GameApiContext } from '../src/context.js';
import { DatabaseTurnDaemonTransport } from '../src/daemon/databaseTransport.js';
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
import { appRouter } from '../src/router.js';
const databaseUrl = process.env.SELECT_POOL_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
const userId = 'select-pool-integration-user';
const otherUserId = 'select-pool-integration-other-user';
const foreignUserId = 'select-pool-integration-foreign-user';
const failureUserId = 'select-pool-integration-failure-user';
const profile = 'hwe:903';
const schemaName = databaseUrl ? new URL(databaseUrl).searchParams.get('schema') ?? '' : '';
const assertDedicatedDatabase = (rawUrl: string): void => {
const schema = new URL(rawUrl).searchParams.get('schema');
if (!schema?.endsWith('select_pool_integration')) {
throw new Error(`Refusing to mutate non-dedicated schema: ${schema ?? '(missing)'}`);
}
if (!/^[a-z0-9_]+$/.test(schema)) {
throw new Error(`Refusing unsafe schema name: ${schema}`);
}
};
const auth: GameSessionTokenPayload = {
version: 1,
profile,
issuedAt: '2026-07-30T00:00:00.000Z',
expiresAt: '2026-08-30T00:00:00.000Z',
sessionId: 'select-pool-integration-session',
user: {
id: userId,
username: 'select-pool-user',
displayName: '선택사용자',
roles: ['user'],
},
sanctions: {},
};
const otherAuth: GameSessionTokenPayload = {
...auth,
sessionId: 'select-pool-integration-other-session',
user: {
...auth.user,
id: otherUserId,
username: 'select-pool-other',
displayName: '다른사용자',
},
};
const foreignAuth: GameSessionTokenPayload = {
...auth,
sessionId: 'select-pool-integration-foreign-session',
user: {
...auth.user,
id: foreignUserId,
username: 'select-pool-foreign',
displayName: '외국사용자',
},
};
const failureAuth: GameSessionTokenPayload = {
...auth,
sessionId: 'select-pool-integration-failure-session',
user: {
...auth.user,
id: failureUserId,
username: 'select-pool-failure',
displayName: '실패사용자',
},
};
integration('scenario 903 select pool through the durable turn daemon', () => {
let db: GamePrismaClient;
let closeDb: (() => Promise<void>) | undefined;
let runtime: TurnDaemonRuntime | undefined;
let daemonLoop: Promise<void> | undefined;
let turnDaemon: TurnDaemonTransport;
let worldStateId: number;
const buildContext = (
requestId: string,
actorAuth: GameSessionTokenPayload = auth
): GameApiContext => {
const redisClient = {
get: async () => null,
set: async () => null,
};
return {
requestId,
db,
redis: redisClient as unknown as RedisConnector['client'],
turnDaemon,
battleSim: new InMemoryBattleSimTransport(),
profile: { id: 'hwe', scenario: '903', name: profile },
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
auth: actorAuth,
accessTokenStore: new RedisAccessTokenStore(redisClient, profile),
flushStore: new InMemoryFlushStore(),
gameTokenSecret: 'select-pool-test-secret',
};
};
beforeAll(async () => {
assertDedicatedDatabase(databaseUrl!);
const previousSeed = process.env.INTEGRATION_WORLD_SEED;
process.env.INTEGRATION_WORLD_SEED = 'select-pool-integration-seed';
try {
await seedScenarioToDatabase({
scenarioId: 903,
databaseUrl: databaseUrl!,
now: new Date('2099-07-30T12:00:00.000Z'),
installOptions: {
turnTermMinutes: 5,
npcMode: 2,
showImgLevel: 3,
serverId: profile,
season: 1,
},
});
} finally {
if (previousSeed === undefined) {
delete process.env.INTEGRATION_WORLD_SEED;
} else {
process.env.INTEGRATION_WORLD_SEED = previousSeed;
}
}
const connector = createGamePostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
closeDb = () => connector.disconnect();
await db.inputEvent.deleteMany();
await db.logEntry.deleteMany();
worldStateId = (await db.worldState.findFirstOrThrow()).id;
runtime = await createTurnDaemonRuntime({
profile,
databaseUrl: databaseUrl!,
enableDatabaseFlush: true,
enableLeaseHeartbeat: false,
leaseOwnerId: 'select-pool-integration-daemon',
});
turnDaemon = new DatabaseTurnDaemonTransport(db, 10_000);
daemonLoop = runtime.lifecycle.start();
await expect(turnDaemon.requestStatus(10_000)).resolves.toMatchObject({
state: expect.any(String),
});
}, 60_000);
afterAll(async () => {
if (runtime) {
await runtime.lifecycle.stop('select-pool integration complete');
await daemonLoop;
await runtime.close();
}
await closeDb?.();
}, 30_000);
it('creates and reselects in one durable DB and in-memory command boundary', async () => {
await expect(
appRouter.createCaller(buildContext('select-pool-public-lobby')).lobby.info()
).resolves.toMatchObject({ selectionPoolEnabled: true });
await expect(
appRouter.createCaller(buildContext('select-pool-config')).join.getConfig()
).resolves.toMatchObject({
serverInfo: {
currentYear: 180,
currentMonth: 1,
tickMinutes: 5,
maxGeneral: 500,
},
});
const [firstReservation, concurrentReservation] = await Promise.all([
appRouter.createCaller(buildContext('select-pool-reserve-a')).join.getSelectionPool(),
appRouter.createCaller(buildContext('select-pool-reserve-b')).join.getSelectionPool(),
]);
expect(concurrentReservation).toEqual(firstReservation);
expect(firstReservation.candidates).toHaveLength(14);
expect(await db.selectPoolEntry.count({ where: { ownerUserId: userId } })).toBe(14);
const attempts = await Promise.allSettled([
appRouter.createCaller(buildContext('select-pool-create-a')).join.selectPoolGeneral({
uniqueName: firstReservation.candidates[0]!.uniqueName,
personality: 'che_안전',
}),
appRouter.createCaller(buildContext('select-pool-create-b')).join.selectPoolGeneral({
uniqueName: firstReservation.candidates[1]!.uniqueName,
personality: 'che_유지',
}),
]);
expect(attempts.filter((attempt) => attempt.status === 'fulfilled')).toHaveLength(1);
expect(attempts.filter((attempt) => attempt.status === 'rejected')).toHaveLength(1);
const initial = await db.general.findFirstOrThrow({ where: { userId } });
const initialRuntime = runtime!.world.getGeneralById(initial.id);
expect(initialRuntime).toMatchObject({
id: initial.id,
userId,
name: initial.name,
imageServer: initial.imageServer,
stats: {
leadership: initial.leadership,
strength: initial.strength,
intelligence: initial.intel,
},
});
expect(initial.id).toBeGreaterThan(
Math.max(
...runtime!.world
.listGenerals()
.filter((general) => general.id !== initial.id)
.map((general) => general.id)
)
);
expect(
await db.generalTurn.findMany({
where: { generalId: initial.id },
orderBy: { turnIdx: 'asc' },
select: { turnIdx: true, actionCode: true, arg: true },
})
).toEqual(
Array.from({ length: 30 }, (_, turnIdx) => ({
turnIdx,
actionCode: '휴식',
arg: {},
}))
);
await expect(
db.generalTurnRevision.findUniqueOrThrow({ where: { generalId: initial.id } })
).resolves.toMatchObject({ revision: 0, leaseOwner: null, leaseExpiresAt: null });
const initialRankRows = await db.rankData.findMany({
where: { generalId: initial.id },
orderBy: { type: 'asc' },
select: { nationId: true, type: true, value: true },
});
expect(initialRankRows).toHaveLength(RANK_DATA_TYPES.length);
expect(initialRankRows.map(({ type }) => type).sort()).toEqual(
[...RANK_DATA_TYPES].sort()
);
expect(initialRankRows.every(({ nationId, value }) => nationId === 0 && value === 0)).toBe(
true
);
expect(await db.selectPoolEntry.count({ where: { generalId: initial.id } })).toBe(1);
expect(await db.selectPoolEntry.count({ where: { ownerUserId: userId } })).toBe(0);
expect(
await db.logEntry.count({
where: { meta: { path: ['ownerUserId'], equals: userId } },
})
).toBe(2);
await expect(
appRouter.createCaller(buildContext('select-pool-cooldown')).join.getSelectionPool()
).rejects.toMatchObject({ message: '아직 다시 고를 수 없습니다' });
const cooledAt = '2026-07-29T00:00:00.000Z';
await expect(
turnDaemon.requestCommand({
type: 'patchGeneral',
requestId: 'select-pool-cooldown-patch',
generalId: initial.id,
patch: {
meta: {
next_change: cooledAt,
nextChangeAt: cooledAt,
},
},
})
).resolves.toMatchObject({ type: 'patchGeneral', ok: true, generalId: initial.id });
const reselection = await appRouter
.createCaller(buildContext('select-pool-reserve-reselection'))
.join.getSelectionPool();
const target = reselection.candidates.find(
(candidate) => candidate.generalName !== initial.name
)!;
await expect(
appRouter
.createCaller(buildContext('select-pool-reselect'))
.join.reselectPoolGeneral({ uniqueName: target.uniqueName })
).resolves.toEqual({ ok: true, generalId: initial.id });
const updated = await db.general.findUniqueOrThrow({ where: { id: initial.id } });
expect(updated).toMatchObject({
id: initial.id,
userId,
name: target.generalName,
leadership: target.leadership,
strength: target.strength,
intel: target.intel,
personalCode: initial.personalCode,
specialCode: target.specialDomestic,
imageServer: target.imageServer,
picture: target.picture,
});
expect(runtime!.world.getGeneralById(initial.id)).toMatchObject({
id: initial.id,
userId,
name: target.generalName,
imageServer: target.imageServer,
picture: target.picture,
stats: {
leadership: target.leadership,
strength: target.strength,
intelligence: target.intel,
},
});
expect(await db.selectPoolEntry.count({ where: { generalId: initial.id } })).toBe(1);
expect(
await db.selectPoolEntry.findUniqueOrThrow({ where: { uniqueName: target.uniqueName } })
).toMatchObject({ generalId: initial.id, ownerUserId: null, reservedUntil: null });
expect(
await db.logEntry.count({
where: { meta: { path: ['ownerUserId'], equals: userId } },
})
).toBe(4);
await expect(
turnDaemon.requestCommand({
type: 'patchGeneral',
requestId: 'select-pool-post-reselection-flush',
generalId: initial.id,
patch: { meta: { postReselectionFlush: 1 } },
})
).resolves.toMatchObject({ type: 'patchGeneral', ok: true });
await expect(
db.general.findUniqueOrThrow({ where: { id: initial.id } })
).resolves.toMatchObject({
name: target.generalName,
leadership: target.leadership,
strength: target.strength,
intel: target.intel,
});
const fullWorld = await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } });
const fullConfig = fullWorld.config as Record<string, unknown>;
await db.worldState.update({
where: { id: worldStateId },
data: { config: { ...fullConfig, maxGeneral: 1 } as GamePrisma.InputJsonValue },
});
const secondCooledAt = '2026-07-28T00:00:00.000Z';
await turnDaemon.requestCommand({
type: 'patchGeneral',
requestId: 'select-pool-full-cooldown-patch',
generalId: initial.id,
patch: {
meta: {
next_change: secondCooledAt,
nextChangeAt: secondCooledAt,
},
},
});
const fullReselection = await appRouter
.createCaller(buildContext('select-pool-full-reselection-reserve'))
.join.getSelectionPool();
await expect(
appRouter
.createCaller(buildContext('select-pool-full-reselection'))
.join.reselectPoolGeneral({
uniqueName: fullReselection.candidates[0]!.uniqueName,
})
).resolves.toEqual({ ok: true, generalId: initial.id });
const otherReservation = await appRouter
.createCaller(buildContext('select-pool-full-new-user-reserve', otherAuth))
.join.getSelectionPool();
await expect(
appRouter
.createCaller(buildContext('select-pool-full-new-user-create', otherAuth))
.join.selectPoolGeneral({
uniqueName: otherReservation.candidates[0]!.uniqueName,
personality: 'che_안전',
})
).rejects.toMatchObject({ message: '더 이상 등록 할 수 없습니다.' });
expect(await db.general.count({ where: { userId: otherUserId } })).toBe(0);
await db.worldState.update({
where: { id: worldStateId },
data: { config: fullConfig as GamePrisma.InputJsonValue },
});
}, 30_000);
it('keeps a stable ENGINE event for retries and rejects reservation bypasses', async () => {
const reservation = await appRouter
.createCaller(buildContext('select-pool-other-reserve', otherAuth))
.join.getSelectionPool();
const candidate = reservation.candidates[0]!;
await expect(
appRouter
.createCaller(buildContext('select-pool-foreign-token', foreignAuth))
.join.selectPoolGeneral({
uniqueName: candidate.uniqueName,
personality: 'che_안전',
})
).rejects.toMatchObject({ message: '유효한 장수 목록이 없습니다.' });
expect(await db.general.count({ where: { userId: foreignUserId } })).toBe(0);
await db.selectPoolEntry.update({
where: { uniqueName: candidate.uniqueName },
data: { reservedUntil: new Date(Date.now() - 60_000) },
});
await expect(
appRouter
.createCaller(buildContext('select-pool-expired-token', otherAuth))
.join.selectPoolGeneral({
uniqueName: candidate.uniqueName,
personality: 'che_안전',
})
).rejects.toMatchObject({ message: '유효한 장수 목록이 없습니다.' });
expect(await db.general.count({ where: { userId: otherUserId } })).toBe(0);
await expect(
appRouter
.createCaller(buildContext('select-pool-generic-bypass', otherAuth))
.join.createGeneral({
name: '우회장수',
leadership: 55,
strength: 55,
intel: 55,
character: 'che_안전',
})
).rejects.toMatchObject({ message: '장수 선택 목록에서 장수를 골라 주세요.' });
const input = {
uniqueName: reservation.candidates[1]!.uniqueName,
personality: 'che_안전',
};
await db.selectPoolEntry.updateMany({
where: { ownerUserId: otherUserId, generalId: null },
data: { reservedUntil: new Date(Date.now() + 60_000) },
});
const runtimeAllocatorBefore = runtime!.world.getState().meta.lastGeneralId;
const persistedAllocatorBefore = (
(await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } }))
.meta as Record<string, unknown>
).lastGeneralId;
await expect(
appRouter
.createCaller(buildContext('select-pool-invalid-personality', otherAuth))
.join.selectPoolGeneral({
...input,
personality: 'not-a-personality',
clientRequestId: '11111111-1111-4111-8111-111111111111',
})
).rejects.toMatchObject({ message: '올바르지 않은 성격입니다.' });
expect(runtime!.world.getState().meta.lastGeneralId).toBe(runtimeAllocatorBefore);
expect(
(
(await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } }))
.meta as Record<string, unknown>
).lastGeneralId
).toBe(persistedAllocatorBefore);
expect(await db.general.count({ where: { userId: otherUserId } })).toBe(0);
const stableClientRequestId = '22222222-2222-4222-8222-222222222222';
const stableInput = { ...input, clientRequestId: stableClientRequestId };
const first = await appRouter
.createCaller(buildContext('select-pool-http-attempt-a', otherAuth))
.join.selectPoolGeneral(stableInput);
const retried = await appRouter
.createCaller(buildContext('select-pool-http-attempt-b', otherAuth))
.join.selectPoolGeneral(stableInput);
expect(retried).toEqual(first);
expect(await db.general.count({ where: { userId: otherUserId } })).toBe(1);
await expect(
db.inputEvent.findUniqueOrThrow({
where: {
requestId: `select-pool:${otherUserId}:${stableClientRequestId}:create`,
},
})
).resolves.toMatchObject({
status: 'SUCCEEDED',
attempts: 1,
actorUserId: otherUserId,
});
}, 30_000);
it('rolls back a hard failure and retries the same ENGINE event exactly once', async () => {
const reservation = await appRouter
.createCaller(buildContext('select-pool-failure-reserve', failureAuth))
.join.getSelectionPool();
const candidate = reservation.candidates[0]!;
const requestUuid = '33333333-3333-4333-8333-333333333333';
const requestId = `select-pool:${failureUserId}:${requestUuid}:create`;
const triggerName = 'select_pool_fail_first_log';
const functionName = 'select_pool_fail_first_log_fn';
await db.$executeRawUnsafe(`
CREATE OR REPLACE FUNCTION "${schemaName}"."${functionName}"()
RETURNS trigger AS $$
BEGIN
IF NEW.meta ->> 'ownerUserId' = '${failureUserId}'
AND EXISTS (
SELECT 1
FROM "${schemaName}"."input_event"
WHERE "request_id" = '${requestId}'
AND "status" = 'PROCESSING'
AND "attempts" = 1
)
THEN
RAISE EXCEPTION 'injected first selection log failure';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql
`);
await db.$executeRawUnsafe(`
CREATE TRIGGER "${triggerName}"
BEFORE INSERT ON "${schemaName}"."log_entry"
FOR EACH ROW EXECUTE FUNCTION "${schemaName}"."${functionName}"()
`);
try {
await expect(
appRouter
.createCaller(buildContext('select-pool-failure-http', failureAuth))
.join.selectPoolGeneral({
uniqueName: candidate.uniqueName,
personality: 'che_안전',
clientRequestId: requestUuid,
})
).resolves.toMatchObject({ ok: true, generalId: expect.any(Number) });
} finally {
await db.$executeRawUnsafe(
`DROP TRIGGER IF EXISTS "${triggerName}" ON "${schemaName}"."log_entry"`
);
await db.$executeRawUnsafe(
`DROP FUNCTION IF EXISTS "${schemaName}"."${functionName}"()`
);
}
const created = await db.general.findFirstOrThrow({ where: { userId: failureUserId } });
expect(runtime!.world.getGeneralById(created.id)).toMatchObject({
id: created.id,
userId: failureUserId,
name: created.name,
});
expect(await db.general.count({ where: { userId: failureUserId } })).toBe(1);
expect(await db.generalTurn.count({ where: { generalId: created.id } })).toBe(30);
expect(await db.generalTurnRevision.count({ where: { generalId: created.id } })).toBe(1);
expect(await db.rankData.count({ where: { generalId: created.id } })).toBe(
RANK_DATA_TYPES.length
);
expect(await db.generalAccessLog.count({ where: { generalId: created.id } })).toBe(1);
expect(await db.selectPoolEntry.count({ where: { generalId: created.id } })).toBe(1);
expect(
await db.logEntry.count({
where: { meta: { path: ['ownerUserId'], equals: failureUserId } },
})
).toBe(2);
await expect(
db.inputEvent.findUniqueOrThrow({ where: { requestId } })
).resolves.toMatchObject({
status: 'SUCCEEDED',
attempts: 2,
actorUserId: failureUserId,
error: null,
});
}, 30_000);
});
+75
View File
@@ -0,0 +1,75 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import {
buildSelectPoolSeed,
claimWeightedSelectionCandidates,
} from '../src/services/selectPool.js';
interface PoolResource {
data: Array<
[
string,
number,
number,
number,
string,
[number, number, number, number, number],
0 | 1,
string,
]
>;
}
const loadWeightedRows = async (): Promise<Array<[{ id: number }, number]>> => {
const filePath = path.resolve(
import.meta.dirname,
'../../../resources/general-pool/SPoolUnderU30.json'
);
const resource = JSON.parse(await fs.readFile(filePath, 'utf8')) as PoolResource;
return resource.data.map((row, index) => [
{ id: index + 1 },
row[5].reduce((sum, value) => sum + value, 0),
]);
};
const drawVector = async (
hiddenSeed: string
): Promise<{ selected: number[]; draws: number[] }> => {
const weighted = await loadWeightedRows();
const now = new Date('2026-07-30T03:34:56.000Z');
const draws: number[] = [];
const selected = await claimWeightedSelectionCandidates({
weighted,
rng: new RandUtil(new LiteHashDRBG(buildSelectPoolSeed(hiddenSeed, 42, now))),
count: 14,
claim: async () => true,
onDraw: (candidate) => draws.push(candidate.id),
});
return { selected: selected.map((candidate) => candidate.id), draws };
};
describe('select pool Ref RNG parity', () => {
it('uses the legacy seed serialization and fixed UnderS30 draw vector', async () => {
const now = new Date('2026-07-30T03:34:56.000Z');
expect(buildSelectPoolSeed('vector-hidden', 42, now)).toBe(
'str(13,vector-hidden)|str(10,selectPool)|int(42)|str(19,2026-07-30 12:34:56)'
);
await expect(drawVector('vector-hidden')).resolves.toEqual({
selected: [72, 1283, 110, 1659, 608, 1408, 1543, 1573, 1096, 1081, 278, 1256, 872, 1369],
draws: [72, 1283, 110, 1659, 608, 1408, 1543, 1573, 1096, 1081, 278, 1256, 872, 1369],
});
});
it('consumes duplicate draws without removing the candidate from the weighted pool', async () => {
await expect(drawVector('vector-hidden-28')).resolves.toEqual({
selected: [314, 865, 1485, 1382, 110, 550, 27, 368, 399, 1298, 152, 39, 189, 760],
draws: [314, 865, 1485, 1382, 110, 550, 27, 368, 399, 1298, 27, 152, 39, 189, 760],
});
});
});
+10 -1
View File
@@ -11,6 +11,12 @@
"@sammo-ts/common/*": [
"../../packages/common/src/*"
],
"@sammo-ts/game-engine": [
"../../app/game-engine/src/index.ts"
],
"@sammo-ts/game-engine/*": [
"../../app/game-engine/src/*"
],
"@sammo-ts/infra": [
"../../packages/infra/src/index.ts"
],
@@ -39,6 +45,9 @@
},
{
"path": "../../packages/logic"
},
{
"path": "../game-engine"
}
]
}
}
+2
View File
@@ -12,6 +12,7 @@ export * from './lifecycle/turnDaemonLifecycle.js';
export * from './lifecycle/getNextTickTime.js';
export * from './scenario/scenarioLoader.js';
export * from './scenario/scenarioComposition.js';
export * from './scenario/generalPoolLoader.js';
export * from './scenario/databaseUrl.js';
export * from './scenario/mapLoader.js';
export * from './scenario/scenarioSeeder.js';
@@ -22,6 +23,7 @@ export * from './turn/engineStateManager.js';
export * from './turn/inMemoryStateStore.js';
export * from './turn/inMemoryTurnProcessor.js';
export * from './turn/databaseHooks.js';
export * from './turn/selectPoolService.js';
export * from './turn/turnDaemon.js';
export * from './turn/cli.js';
@@ -17,6 +17,7 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
private readonly localQueue: TurnDaemonCommand[] = [];
private readonly workerId = randomUUID();
private readonly leaseDurationMs = 60_000;
private readonly maxAttempts = 3;
constructor(private readonly db: GamePrismaClient) {}
@@ -62,6 +63,48 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
await this.complete(requestId, result);
}
async publishCommandError(requestId: string, error: unknown): Promise<void> {
const message = error instanceof Error ? error.message : 'Unknown command error.';
await this.db.$transaction(async (transaction) => {
const event = await transaction.inputEvent.findUnique({
where: { requestId },
select: {
status: true,
target: true,
lockedBy: true,
attempts: true,
},
});
if (
!event ||
event.target !== 'ENGINE' ||
event.status !== 'PROCESSING' ||
event.lockedBy !== this.workerId
) {
return;
}
const terminal = event.attempts >= this.maxAttempts;
await transaction.inputEvent.updateMany({
where: {
requestId,
target: 'ENGINE',
status: 'PROCESSING',
lockedBy: this.workerId,
attempts: event.attempts,
},
data: {
status: terminal ? 'FAILED' : 'PENDING',
processingAt: null,
lockedBy: null,
leaseUntil: null,
completedAt: terminal ? new Date() : null,
result: GamePrisma.DbNull,
error: message,
},
});
});
}
private async claimPending(limit = 100): Promise<TurnDaemonCommand[]> {
await this.recoverExpiredLeases();
return this.db.$transaction(async (transaction) => {
@@ -132,8 +175,13 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
}
private async complete(requestId: string, result: unknown): Promise<void> {
await this.db.inputEvent.update({
where: { requestId },
const completed = await this.db.inputEvent.updateMany({
where: {
requestId,
target: 'ENGINE',
status: 'PROCESSING',
lockedBy: this.workerId,
},
data: {
status: 'SUCCEEDED',
result: asJson(result),
@@ -143,6 +191,24 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
leaseUntil: null,
},
});
if (completed.count > 0) {
return;
}
// Database hooks commit mutation results atomically with game state and
// may already have set SUCCEEDED. Only the worker that still owns the
// lease may clear that committed row's claim metadata.
await this.db.inputEvent.updateMany({
where: {
requestId,
target: 'ENGINE',
status: 'SUCCEEDED',
lockedBy: this.workerId,
},
data: {
lockedBy: null,
leaseUntil: null,
},
});
}
private async recoverExpiredLeases(): Promise<void> {
@@ -310,6 +310,17 @@ export class TurnDaemonLifecycle {
this.status.paused = true;
this.errorPaused = true;
this.status.lastError = error instanceof Error ? error.message : 'Unknown command error.';
if (command.requestId && this.commandResponder?.publishCommandError) {
try {
await this.commandResponder.publishCommandError(command.requestId, error);
} catch (reportError) {
const reportMessage =
reportError instanceof Error
? reportError.message
: 'Unknown command failure reporting error.';
this.status.lastError = `${this.status.lastError} (failure report: ${reportMessage})`;
}
}
await this.hooks?.onRunError?.(error);
return;
}
+1
View File
@@ -33,6 +33,7 @@ export interface TurnDaemonCommandExecutionContext {
export interface TurnDaemonCommandResponder {
publishStatus(requestId: string, status: TurnDaemonStatus): Promise<void>;
publishCommandResult(requestId: string, result: TurnDaemonCommandResult): Promise<void>;
publishCommandError?(requestId: string, error: unknown): Promise<void>;
}
export type { Clock } from '@sammo-ts/common';
@@ -0,0 +1,93 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { isRecord } from '@sammo-ts/common';
import { isEventDomesticTraitKey } from '@sammo-ts/logic';
import { resolveWorkspaceRoot } from '../paths.js';
const DEFAULT_GENERAL_POOL_ROOT = path.resolve(resolveWorkspaceRoot(), 'resources', 'general-pool');
const SUPPORTED_POOL = 'SPoolUnderU30';
const EXPECTED_COLUMNS = [
'generalName',
'leadership',
'strength',
'intel',
'specialDomestic',
'dex',
'imgsvr',
'picture',
] as const;
export interface GeneralPoolSeedEntry {
uniqueName: string;
info: Record<string, unknown>;
}
export interface GeneralPoolLoaderOptions {
generalPoolRoot?: string;
}
const readPoolResource = async (filePath: string): Promise<unknown> =>
JSON.parse(await fs.readFile(filePath, 'utf8')) as unknown;
const normalizePoolRow = (row: unknown, index: number): GeneralPoolSeedEntry => {
if (!Array.isArray(row) || row.length !== EXPECTED_COLUMNS.length) {
throw new Error(`General pool row ${index} does not match the expected ${EXPECTED_COLUMNS.length} columns.`);
}
const info = Object.fromEntries(EXPECTED_COLUMNS.map((column, columnIndex) => [column, row[columnIndex]]));
const uniqueName = info.generalName;
if (typeof uniqueName !== 'string' || uniqueName.length === 0) {
throw new Error(`General pool row ${index} has no generalName.`);
}
if (uniqueName.length > 20) {
throw new Error(`General pool row ${index} has a generalName longer than the select_pool key.`);
}
if (
!Number.isInteger(info.leadership) ||
!Number.isInteger(info.strength) ||
!Number.isInteger(info.intel) ||
typeof info.specialDomestic !== 'string' ||
!isEventDomesticTraitKey(info.specialDomestic) ||
!Array.isArray(info.dex) ||
info.dex.length !== 5 ||
info.dex.some((value) => typeof value !== 'number' || !Number.isInteger(value) || value < 0) ||
info.dex.reduce((sum, value) => sum + Number(value), 0) <= 0 ||
(info.imgsvr !== 0 && info.imgsvr !== 1) ||
typeof info.picture !== 'string'
) {
throw new Error(`General pool row ${index} contains invalid candidate data.`);
}
return {
uniqueName,
info: {
...info,
uniqueName,
},
};
};
export const loadGeneralPoolEntries = async (
poolName: string,
options?: GeneralPoolLoaderOptions
): Promise<GeneralPoolSeedEntry[]> => {
if (poolName !== SUPPORTED_POOL) {
throw new Error(`Unsupported general pool: ${poolName}.`);
}
const root = path.resolve(options?.generalPoolRoot ?? DEFAULT_GENERAL_POOL_ROOT);
const raw = await readPoolResource(path.resolve(root, `${poolName}.json`));
if (!isRecord(raw) || !Array.isArray(raw.columns) || !Array.isArray(raw.data)) {
throw new Error(`General pool ${poolName} is not a valid resource.`);
}
if (
raw.columns.length !== EXPECTED_COLUMNS.length ||
raw.columns.some((column, index) => column !== EXPECTED_COLUMNS[index])
) {
throw new Error(`General pool ${poolName} has an unexpected column contract.`);
}
const entries = raw.data.map(normalizePoolRow);
if (new Set(entries.map((entry) => entry.uniqueName)).size !== entries.length) {
throw new Error(`General pool ${poolName} contains duplicate unique names.`);
}
return entries;
};
+33 -6
View File
@@ -1,3 +1,5 @@
import { randomBytes } from 'node:crypto';
import { createGamePostgresConnector, type InputJsonValue, type TurnEngineEventCreateManyInput } from '@sammo-ts/infra';
import { asRecord } from '@sammo-ts/common';
import {
@@ -14,6 +16,8 @@ import type { ScenarioLoaderOptions } from './scenarioLoader.js';
import { loadScenarioDefinitionById } from './scenarioLoader.js';
import type { UnitSetLoaderOptions } from './unitSetLoader.js';
import { loadUnitSetDefinitionByName } from './unitSetLoader.js';
import type { GeneralPoolLoaderOptions } from './generalPoolLoader.js';
import { loadGeneralPoolEntries } from './generalPoolLoader.js';
import { applyInitialChangeCityEvents } from '../turn/monthlyChangeCityAction.js';
const DEFAULT_TICK_SECONDS = 120 * 60;
@@ -51,6 +55,7 @@ export interface ScenarioSeedOptions {
scenarioOptions?: ScenarioLoaderOptions;
mapOptions?: MapLoaderOptions;
unitSetOptions?: UnitSetLoaderOptions;
generalPoolOptions?: GeneralPoolLoaderOptions;
resetTables?: boolean;
now?: Date;
tickSeconds?: number;
@@ -209,6 +214,11 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
const scenarioDefinition = includeExtendedGeneral ? scenario : { ...scenario, generalsEx: [] };
const map = await loadMapDefinitionByName(scenario.config.environment.mapName, options.mapOptions);
const unitSet = await loadUnitSetDefinitionByName(scenario.config.environment.unitSet, options.unitSetOptions);
const targetGeneralPool =
typeof scenario.config.map.targetGeneralPool === 'string' ? scenario.config.map.targetGeneralPool : null;
const generalPoolEntries = targetGeneralPool
? await loadGeneralPoolEntries(targetGeneralPool, options.generalPoolOptions)
: [];
const { seed, warnings } = buildScenarioBootstrap({
scenario: scenarioDefinition,
@@ -271,10 +281,11 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
worldMeta.serverId = install.serverId.trim();
}
const integrationSeed = process.env[INTEGRATION_WORLD_SEED_ENV];
if (typeof integrationSeed === 'string' && integrationSeed.trim().length > 0) {
worldMeta.hiddenSeed = integrationSeed.trim();
}
const integrationSeed = process.env[INTEGRATION_WORLD_SEED_ENV]?.trim();
worldMeta.hiddenSeed =
integrationSeed && integrationSeed.length > 0
? integrationSeed
: randomBytes(16).toString('hex');
if (install?.preopenAt) {
worldMeta.preopenAt = formatDateTime(install.preopenAt);
@@ -286,6 +297,8 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
options: install.autorunUser.options,
};
}
const archivedWorldMeta = { ...worldMeta };
delete archivedWorldMeta.hiddenSeed;
await connector.connect();
try {
@@ -297,6 +310,11 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
if (eventTableReady) {
await prisma.event.deleteMany();
}
await prisma.selectPoolEntry.deleteMany();
await prisma.generalTurn.deleteMany();
await prisma.generalTurnRevision.deleteMany();
await prisma.rankData.deleteMany();
await prisma.generalAccessLog.deleteMany();
await prisma.diplomacy.deleteMany();
await prisma.general.deleteMany();
await prisma.troop.deleteMany();
@@ -316,6 +334,15 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
},
});
if (generalPoolEntries.length > 0) {
await prisma.selectPoolEntry.createMany({
data: generalPoolEntries.map((entry) => ({
uniqueName: entry.uniqueName,
info: asJson(entry.info),
})),
});
}
if (typeof worldMeta.serverId === 'string' && worldMeta.serverId) {
await prisma.gameHistory.upsert({
where: { serverId: worldMeta.serverId },
@@ -332,7 +359,7 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
scenarioName: String(seed.scenarioMeta?.title ?? ''),
env: asJson({
config: scenarioConfig,
meta: worldMeta,
meta: archivedWorldMeta,
}),
},
update: {
@@ -347,7 +374,7 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
scenarioName: String(seed.scenarioMeta?.title ?? ''),
env: asJson({
config: scenarioConfig,
meta: worldMeta,
meta: archivedWorldMeta,
}),
},
});
@@ -243,6 +243,28 @@ const zPatchGeneral = z.object({
}),
});
const zSelectPoolCreate = z
.object({
type: z.literal('selectPoolCreate'),
requestId: z.string().optional(),
userId: z.string().min(1),
ownerDisplayName: z.string().min(1),
uniqueName: z.string().min(1).max(20),
personality: z.string().min(1),
seedOwnerIdentity: z.union([z.string().min(1), zFiniteNumber]),
})
.strict();
const zSelectPoolReselect = z
.object({
type: z.literal('selectPoolReselect'),
requestId: z.string().optional(),
userId: z.string().min(1),
ownerDisplayName: z.string().min(1),
uniqueName: z.string().min(1).max(20),
})
.strict();
const zGetStatus = z.object({
type: z.literal('getStatus'),
requestId: z.string().optional(),
@@ -484,6 +506,22 @@ const normalizePatchGeneral: CommandNormalizer<'patchGeneral'> = (envelope) => {
return { ...command, requestId: envelope.requestId };
};
const normalizeSelectPoolCreate: CommandNormalizer<'selectPoolCreate'> = (envelope) => {
const command = parseWith(zSelectPoolCreate, envelope.command);
if (!command) {
return null;
}
return { ...command, requestId: envelope.requestId };
};
const normalizeSelectPoolReselect: CommandNormalizer<'selectPoolReselect'> = (envelope) => {
const command = parseWith(zSelectPoolReselect, envelope.command);
if (!command) {
return null;
}
return { ...command, requestId: envelope.requestId };
};
const normalizeGetStatus: CommandNormalizer<'getStatus'> = (envelope) => {
const command = parseWith(zGetStatus, envelope.command);
if (!command) {
@@ -547,6 +585,8 @@ const normalizers: CommandNormalizerMap = {
adjustGeneralMeta: normalizeAdjustGeneralMeta,
tournamentMatchResult: normalizeTournamentMatchResult,
patchGeneral: normalizePatchGeneral,
selectPoolCreate: normalizeSelectPoolCreate,
selectPoolReselect: normalizeSelectPoolReselect,
getStatus: normalizeGetStatus,
run: normalizeRun,
pause: normalizePause,
+15
View File
@@ -351,6 +351,8 @@ const buildGeneralUpdate = (
bornYear: general.bornYear,
deadYear: general.deadYear,
picture: general.picture ?? null,
imageServer: general.imageServer ?? 0,
startAge: general.startAge ?? general.age,
npcState: general.npcState,
horseCode: toCode(general.role.items.horse),
weaponCode: toCode(general.role.items.weapon),
@@ -369,6 +371,7 @@ const buildGeneralCreate = (
general: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['generals'][number]
): TurnEngineGeneralCreateManyInput => ({
id: general.id,
userId: general.userId ?? null,
name: general.name,
nationId: general.nationId,
cityId: general.cityId,
@@ -392,6 +395,8 @@ const buildGeneralCreate = (
bornYear: general.bornYear,
deadYear: general.deadYear,
picture: general.picture ?? null,
imageServer: general.imageServer ?? 0,
startAge: general.startAge ?? general.age,
horseCode: toCode(general.role.items.horse),
weaponCode: toCode(general.role.items.weapon),
bookCode: toCode(general.role.items.book),
@@ -799,6 +804,14 @@ export const createDatabaseTurnHooks = async (
}
if (deletedGenerals.length > 0) {
await prisma.selectPoolEntry.updateMany({
where: { generalId: { in: deletedGenerals } },
data: {
generalId: null,
ownerUserId: null,
reservedUntil: null,
},
});
if (prisma.generalTurnRevision) {
await prisma.generalTurnRevision.deleteMany({
where: { generalId: { in: deletedGenerals } },
@@ -969,6 +982,8 @@ export const createDatabaseTurnHooks = async (
result: asJson(commandCompletion.result),
completedAt: new Date(),
error: null,
lockedBy: null,
leaseUntil: null,
},
});
}
+3 -4
View File
@@ -881,10 +881,9 @@ export class InMemoryTurnWorld {
getNextGeneralId(): number {
const meta = this.state.meta as Record<string, unknown>;
let lastId = (meta.lastGeneralId as number | undefined) ?? 0;
if (lastId === 0) {
const currentIds = Array.from(this.generals.keys());
lastId = currentIds.length > 0 ? Math.max(...currentIds) : 0;
}
const currentIds = Array.from(this.generals.keys());
const currentMaxId = currentIds.length > 0 ? Math.max(...currentIds) : 0;
lastId = Math.max(lastId, currentMaxId);
const nextId = lastId + 1;
this.state = {
@@ -0,0 +1,889 @@
import { z } from 'zod';
import {
asNumber,
asRecord,
JosaUtil,
LiteHashDRBG,
RandUtil,
} from '@sammo-ts/common';
import { GamePrisma, LogCategory, LogScope } from '@sammo-ts/infra';
import {
EventDomesticTraitLoader,
isEventDomesticTraitKey,
isPersonalityTraitKey,
PERSONALITY_TRAIT_KEYS,
simpleSerialize,
} from '@sammo-ts/logic';
import type { DatabaseClient, GamePrisma as GamePrismaTypes } from '@sammo-ts/infra';
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
import type { TurnGeneral } from './types.js';
type WorldStateRow = GamePrismaTypes.WorldStateGetPayload<Record<string, never>>;
export type SelectPoolErrorCode =
| 'BAD_REQUEST'
| 'PRECONDITION_FAILED'
| 'CONFLICT'
| 'INTERNAL_SERVER_ERROR';
export class SelectPoolError extends Error {
constructor(
readonly code: SelectPoolErrorCode,
message: string
) {
super(message);
this.name = 'SelectPoolError';
}
}
const SUPPORTED_POOL = 'SPoolUnderU30';
const RESERVATION_COUNT = 14;
const RESERVATION_TURN_MULTIPLIER = 2;
const RESELECTION_TURN_MULTIPLIER = 12;
const DEFAULT_MAX_GENERAL = 500;
const DEFAULT_CREW_TYPE_ID = 1100;
const MAX_GENERAL_TURNS = 30;
const DEFAULT_TURN_ACTION = '휴식';
const LEGACY_TIMEZONE_OFFSET_MS = 9 * 60 * 60 * 1000;
const zCandidateInfo = z.object({
uniqueName: z.string().min(1),
generalName: z.string().min(1),
leadership: z.number().int(),
strength: z.number().int(),
intel: z.number().int(),
specialDomestic: z.string().min(1),
specialWar: z.string().min(1).optional(),
ego: z.string().min(1).optional(),
experience: z.number().int().optional(),
dedication: z.number().int().optional(),
dex: z.tuple([z.number(), z.number(), z.number(), z.number(), z.number()]),
imgsvr: z.union([z.literal(0), z.literal(1)]),
picture: z.string(),
});
export type SelectPoolCandidateInfo = z.infer<typeof zCandidateInfo>;
interface SelectPoolRow {
id: number;
uniqueName: string;
ownerUserId: string | null;
generalId: number | null;
reservedUntil: Date | null;
info: unknown;
}
export interface SelectPoolCandidateDto {
uniqueName: string;
generalName: string;
leadership: number;
strength: number;
intel: number;
specialDomestic: string;
specialDomesticName: string;
specialDomesticInfo: string;
specialWar: string | null;
ego: string | null;
dex: [number, number, number, number, number];
imageServer: 0 | 1;
picture: string;
}
export interface SelectPoolReservationDto {
poolName: typeof SUPPORTED_POOL;
hasGeneral: boolean;
validUntil: string;
candidates: SelectPoolCandidateDto[];
}
const fail = (
code: SelectPoolErrorCode,
message: string
): never => {
throw new SelectPoolError(code, message);
};
const resolvePoolName = (worldState: WorldStateRow): string | null => {
const config = asRecord(worldState.config);
const map = asRecord(config.map);
return typeof map.targetGeneralPool === 'string' ? map.targetGeneralPool : null;
};
const resolvePoolAllowOptions = (worldState: WorldStateRow): string[] => {
const map = asRecord(asRecord(worldState.config).map);
return Array.isArray(map.generalPoolAllowOption)
? map.generalPoolAllowOption.filter((value): value is string => typeof value === 'string')
: [];
};
const resolveTurnTermMinutes = (worldState: WorldStateRow): number => {
const config = asRecord(worldState.config);
const configured = asNumber(config.turnTermMinutes, Math.round(worldState.tickSeconds / 60));
return Math.max(1, Math.abs(Math.trunc(configured)));
};
export const isSelectionPoolWorld = (worldState: WorldStateRow): boolean => {
const config = asRecord(worldState.config);
return asNumber(config.npcMode, 0) === 2 && resolvePoolName(worldState) === SUPPORTED_POOL;
};
export const resolveSelectionMaxGeneral = (worldState: WorldStateRow): number => {
const config = asRecord(worldState.config);
const configConst = asRecord(config.const);
return Math.max(
0,
Math.floor(
asNumber(
config.maxGeneral ??
configConst.defaultMaxGeneral ??
configConst.maxGeneral,
DEFAULT_MAX_GENERAL
)
)
);
};
const requirePoolWorld = (worldState: WorldStateRow): void => {
if (!isSelectionPoolWorld(worldState)) {
fail('PRECONDITION_FAILED', '선택 가능한 서버가 아닙니다');
}
};
const parseCandidate = (row: Pick<SelectPoolRow, 'uniqueName' | 'info'>): SelectPoolCandidateInfo => {
const info = zCandidateInfo.safeParse(row.info);
if (!info.success || !info.data) {
throw new SelectPoolError(
'INTERNAL_SERVER_ERROR',
`장수 선택 후보 정보가 올바르지 않습니다: ${row.uniqueName}`
);
}
const candidate = info.data;
if (candidate.uniqueName !== row.uniqueName) {
throw new SelectPoolError(
'INTERNAL_SERVER_ERROR',
`장수 선택 후보 정보가 올바르지 않습니다: ${row.uniqueName}`
);
}
return candidate;
};
const candidateWeight = (candidate: SelectPoolCandidateInfo): number =>
candidate.dex.reduce((sum, value) => sum + value, 0);
const eventDomesticTraitLoader = new EventDomesticTraitLoader();
const toCandidateDto = async (
candidate: SelectPoolCandidateInfo
): Promise<SelectPoolCandidateDto> => {
const trait = isEventDomesticTraitKey(candidate.specialDomestic)
? await eventDomesticTraitLoader.load(candidate.specialDomestic)
: null;
return {
uniqueName: candidate.uniqueName,
generalName: candidate.generalName,
leadership: candidate.leadership,
strength: candidate.strength,
intel: candidate.intel,
specialDomestic: candidate.specialDomestic,
specialDomesticName:
trait?.name ?? candidate.specialDomestic.replace(/^che_event_/, ''),
specialDomesticInfo: trait?.info ?? '',
specialWar: candidate.specialWar ?? null,
ego: candidate.ego ?? null,
dex: candidate.dex,
imageServer: candidate.imgsvr,
picture: candidate.picture,
};
};
const toReservationDto = (
rows: Array<Pick<SelectPoolRow, 'id' | 'uniqueName' | 'reservedUntil' | 'info'>>,
hasGeneral: boolean
): Promise<SelectPoolReservationDto> => {
const validUntil = rows[0]?.reservedUntil;
if (!validUntil) {
throw new SelectPoolError(
'INTERNAL_SERVER_ERROR',
'장수 선택 후보의 유효기간이 없습니다.'
);
}
const expiresAt = validUntil;
const sorted = rows
.map((row) => ({ id: row.id, info: parseCandidate(row) }))
.sort(
(left, right) =>
candidateWeight(left.info) - candidateWeight(right.info) || left.id - right.id
);
return Promise.all(sorted.map((entry) => toCandidateDto(entry.info))).then((candidates) => ({
poolName: SUPPORTED_POOL,
hasGeneral,
validUntil: expiresAt.toISOString(),
candidates,
}));
};
const formatLegacySeedTime = (value: Date): string => {
const pad = (part: number): string => String(part).padStart(2, '0');
const koreaTime = new Date(value.getTime() + LEGACY_TIMEZONE_OFFSET_MS);
return `${koreaTime.getUTCFullYear()}-${pad(koreaTime.getUTCMonth() + 1)}-${pad(
koreaTime.getUTCDate()
)} ${pad(koreaTime.getUTCHours())}:${pad(koreaTime.getUTCMinutes())}:${pad(
koreaTime.getUTCSeconds()
)}`;
};
export const buildSelectPoolSeed = (
hiddenSeed: string | number,
ownerIdentity: string | number,
now: Date
): string => simpleSerialize(hiddenSeed, 'selectPool', ownerIdentity, formatLegacySeedTime(now));
export const claimWeightedSelectionCandidates = async <T extends { id: number }>(options: {
weighted: [T, number][];
rng: RandUtil;
count: number;
claim(candidate: T): Promise<boolean>;
onDraw?(candidate: T): void;
maxAttempts?: number;
}): Promise<T[]> => {
const claimed: T[] = [];
const claimedIds = new Set<number>();
const maxAttempts = options.maxAttempts ?? Math.max(options.weighted.length * 8, 1000);
let attempts = 0;
while (claimed.length < options.count && attempts < maxAttempts) {
attempts += 1;
const candidate = options.rng.choiceUsingWeightPair(options.weighted);
options.onDraw?.(candidate);
if (claimedIds.has(candidate.id) || !(await options.claim(candidate))) {
continue;
}
claimedIds.add(candidate.id);
claimed.push(candidate);
}
return claimed;
};
const readNextChangeAt = (generalMeta: unknown): Date | null => {
const meta = asRecord(generalMeta);
const raw = meta.next_change ?? meta.nextChangeAt;
if (typeof raw !== 'string') {
return null;
}
const parsed = new Date(raw);
return Number.isNaN(parsed.getTime()) ? null : parsed;
};
const getWorldHiddenSeed = (worldState: WorldStateRow): string | number => {
const meta = asRecord(worldState.meta);
const value = meta.hiddenSeed ?? meta.seed;
return typeof value === 'string' || typeof value === 'number'
? value
: fail('INTERNAL_SERVER_ERROR', '장수 선택 비밀 seed가 설정되지 않았습니다.');
};
const lockSelectionUser = async (db: DatabaseClient, userId: string): Promise<void> => {
await db.$executeRaw(
GamePrisma.sql`SELECT pg_advisory_xact_lock(hashtextextended(${`select_pool:${userId}`}, 903))`
);
};
const requireSelectionToken = async (
db: DatabaseClient,
userId: string,
uniqueName: string,
now: Date
): Promise<SelectPoolRow> => {
const token = await db.selectPoolEntry.findFirst({
where: {
ownerUserId: userId,
uniqueName,
reservedUntil: { gte: now },
generalId: null,
},
});
if (!token) {
fail('PRECONDITION_FAILED', '유효한 장수 목록이 없습니다.');
}
return token as SelectPoolRow;
};
export const reserveSelectionPool = async (options: {
db: DatabaseClient;
worldState: WorldStateRow;
userId: string;
now?: Date;
seedOwnerIdentity?: string | number;
}): Promise<SelectPoolReservationDto> => {
const { db, worldState, userId } = options;
requirePoolWorld(worldState);
const now = options.now ?? new Date();
await lockSelectionUser(db, userId);
const general = await db.general.findFirst({
where: { userId },
select: { id: true, meta: true },
});
const nextChangeAt = general ? readNextChangeAt(general.meta) : null;
if (nextChangeAt && nextChangeAt.getTime() > now.getTime()) {
fail('PRECONDITION_FAILED', '아직 다시 고를 수 없습니다');
}
const existing = await db.selectPoolEntry.findMany({
where: {
ownerUserId: userId,
reservedUntil: { gte: now },
generalId: null,
},
orderBy: { id: 'asc' },
});
if (existing.length > 0) {
return toReservationDto(existing as SelectPoolRow[], Boolean(general));
}
await db.selectPoolEntry.updateMany({
where: {
reservedUntil: { lt: now },
generalId: null,
},
data: {
ownerUserId: null,
reservedUntil: null,
},
});
const available = (await db.selectPoolEntry.findMany({
where: {
ownerUserId: null,
reservedUntil: null,
generalId: null,
},
orderBy: { id: 'asc' },
})) as SelectPoolRow[];
if (available.length < RESERVATION_COUNT) {
fail('PRECONDITION_FAILED', 'pool 부족');
}
const rng = new RandUtil(
new LiteHashDRBG(
buildSelectPoolSeed(
getWorldHiddenSeed(worldState),
options.seedOwnerIdentity ?? userId,
now
)
)
);
const weighted = available.map((row) => [row, candidateWeight(parseCandidate(row))] as [SelectPoolRow, number]);
const reservedUntil = new Date(
now.getTime() + resolveTurnTermMinutes(worldState) * RESERVATION_TURN_MULTIPLIER * 60_000
);
const selected = await claimWeightedSelectionCandidates({
weighted,
rng,
count: RESERVATION_COUNT,
claim: async (candidate) => {
const claimed = await db.selectPoolEntry.updateMany({
where: {
id: candidate.id,
ownerUserId: null,
reservedUntil: null,
generalId: null,
},
data: {
ownerUserId: userId,
reservedUntil,
},
});
return claimed.count > 0;
},
});
const reserved = selected.map((candidate) => ({
...candidate,
ownerUserId: userId,
reservedUntil,
}));
if (reserved.length !== RESERVATION_COUNT) {
fail('CONFLICT', '장수 선택 후보를 예약하지 못했습니다. 다시 시도해 주세요.');
}
return toReservationDto(reserved, Boolean(general));
};
const lockSelectionMutationTables = async (db: DatabaseClient): Promise<void> => {
await db.$executeRaw(GamePrisma.sql`LOCK TABLE "general" IN SHARE ROW EXCLUSIVE MODE`);
await db.$executeRaw(GamePrisma.sql`LOCK TABLE "select_pool" IN SHARE ROW EXCLUSIVE MODE`);
};
const assertGeneralIdSnapshotMatches = async (
db: DatabaseClient,
world: InMemoryTurnWorld
): Promise<void> => {
const persistedIds = (
await db.general.findMany({
select: { id: true },
orderBy: { id: 'asc' },
})
).map(({ id }) => id);
const runtimeIds = world
.listGenerals()
.map(({ id }) => id)
.sort((left, right) => left - right);
if (
persistedIds.length !== runtimeIds.length ||
persistedIds.some((id, index) => id !== runtimeIds[index])
) {
throw new Error(
'DB와 턴 데몬의 장수 번호 목록이 일치하지 않아 장수를 생성할 수 없습니다.'
);
}
};
const clearUnusedReservations = async (db: DatabaseClient, userId: string, now: Date): Promise<void> => {
await db.selectPoolEntry.updateMany({
where: {
generalId: null,
OR: [{ ownerUserId: userId }, { reservedUntil: { lt: now } }],
},
data: {
ownerUserId: null,
reservedUntil: null,
},
});
};
const resolveSpecialityAges = (worldState: WorldStateRow, age: number): { domestic: number; war: number } => {
const configConst = asRecord(asRecord(worldState.config).const);
const retirementYear = asNumber(configConst.retirementYear, 80);
const scenarioMeta = asRecord(asRecord(worldState.meta).scenarioMeta);
const startYear = asNumber(scenarioMeta.startYear, worldState.currentYear);
const relativeYear = Math.max(worldState.currentYear - startYear, 0);
const build = (divisor: number): number =>
Math.max(Math.round((retirementYear - age) / divisor - relativeYear / 2), 3) + age;
return { domestic: build(12), war: build(6) };
};
const resolveRandomPersonality = (
worldState: WorldStateRow,
ownerIdentity: string | number,
uniqueName: string
): string =>
new RandUtil(
new LiteHashDRBG(
simpleSerialize(
getWorldHiddenSeed(worldState),
'selectPickedGeneralPersonality',
ownerIdentity,
uniqueName
)
)
).choice([...PERSONALITY_TRAIT_KEYS]);
const resolveSelectedPersonality = (
worldState: WorldStateRow,
ownerIdentity: string | number,
uniqueName: string,
requested: string
): string => {
if (!resolvePoolAllowOptions(worldState).includes('ego')) {
return 'None';
}
if (requested === 'Random') {
return resolveRandomPersonality(worldState, ownerIdentity, uniqueName);
}
if (!isPersonalityTraitKey(requested)) {
fail('BAD_REQUEST', '올바르지 않은 성격입니다.');
}
return requested;
};
const resolvePoolRng = (
worldState: WorldStateRow,
ownerIdentity: string | number,
uniqueName: string
): RandUtil =>
new RandUtil(
new LiteHashDRBG(
simpleSerialize(
getWorldHiddenSeed(worldState),
'selectPickedGeneral',
ownerIdentity,
uniqueName
)
)
);
const resolveTurnTimeBase = (worldState: WorldStateRow, now: Date): Date => {
const raw = asRecord(worldState.meta).turntime;
if (typeof raw === 'string') {
const parsed = new Date(raw);
if (!Number.isNaN(parsed.getTime())) {
return parsed;
}
}
return now;
};
const buildInitialTurnTime = (rng: RandUtil, worldState: WorldStateRow, now: Date): Date => {
const termSeconds = resolveTurnTermMinutes(worldState) * 60;
const seconds = rng.nextRangeInt(0, termSeconds - 1);
const microseconds = rng.nextRangeInt(0, 999_999);
return new Date(resolveTurnTimeBase(worldState, now).getTime() + seconds * 1000 + microseconds / 1000);
};
const appendSelectionLogs = async (options: {
db: DatabaseClient;
worldState: WorldStateRow;
generalId: number;
ownerUserId: string;
generalText: string;
globalText: string;
}): Promise<void> => {
const common = {
year: options.worldState.currentYear,
month: options.worldState.currentMonth,
nationId: null,
userId: null,
meta: { ownerUserId: options.ownerUserId },
};
await options.db.logEntry.createMany({
data: [
{
...common,
scope: LogScope.GENERAL,
category: LogCategory.HISTORY,
generalId: options.generalId,
text: options.generalText,
},
{
...common,
scope: LogScope.SYSTEM,
category: LogCategory.ACTION,
generalId: null,
text: options.globalText,
},
],
});
};
export const createGeneralFromSelectionPool = async (options: {
db: DatabaseClient;
world: InMemoryTurnWorld;
worldState: WorldStateRow;
userId: string;
ownerDisplayName: string;
uniqueName: string;
personality: string;
now?: Date;
seedOwnerIdentity?: string | number;
}): Promise<{ ok: true; generalId: number }> => {
const { db, world, worldState, userId, ownerDisplayName, uniqueName } = options;
requirePoolWorld(worldState);
const now = options.now ?? new Date();
await lockSelectionUser(db, userId);
await lockSelectionMutationTables(db);
await assertGeneralIdSnapshotMatches(db, world);
if (
world.listGenerals().some((general) => general.userId === userId) ||
(await db.general.findFirst({ where: { userId }, select: { id: true } }))
) {
fail('PRECONDITION_FAILED', '이미 장수를 생성했습니다.');
}
const token = await requireSelectionToken(db, userId, uniqueName, now);
const info = parseCandidate(token);
const config = asRecord(worldState.config);
const configConst = asRecord(config.const);
const maxGeneral = resolveSelectionMaxGeneral(worldState);
const activeCount = await db.general.count({ where: { npcState: { lt: 2 } } });
if (activeCount >= maxGeneral) {
fail('PRECONDITION_FAILED', '더 이상 등록 할 수 없습니다.');
}
const seedOwnerIdentity = options.seedOwnerIdentity ?? userId;
const rng = resolvePoolRng(worldState, seedOwnerIdentity, uniqueName);
const affinity = rng.nextRangeInt(1, 150);
const cities = await db.city.findMany({ select: { id: true, name: true }, orderBy: { id: 'asc' } });
if (cities.length === 0) {
fail('PRECONDITION_FAILED', '생성 가능한 도시가 없습니다.');
}
const city = rng.choice(cities);
const turnTime = buildInitialTurnTime(rng, worldState, now);
const age = 20;
const specialityAges = resolveSpecialityAges(worldState, age);
const nextChangeAt = new Date(
now.getTime() + resolveTurnTermMinutes(worldState) * RESELECTION_TURN_MULTIPLIER * 60_000
);
const showImgLevel = asNumber(config.showImgLevel, 0);
const picture = showImgLevel >= 3 ? info.picture : 'default.jpg';
const defaultSpecialWar =
typeof configConst.defaultSpecialWar === 'string' ? configConst.defaultSpecialWar : 'None';
const personality = resolveSelectedPersonality(
worldState,
seedOwnerIdentity,
uniqueName,
options.personality
);
// 모든 사용자 입력과 DB 선조건을 검증한 뒤에만 allocator를 변경한다.
// SelectPoolError는 정상 command 결과로 commit되므로 이보다 먼저
// getNextGeneralId()를 호출하면 실패한 요청도 lastGeneralId를 소비한다.
const generalId = world.getNextGeneralId();
const general: TurnGeneral = {
id: generalId,
userId,
name: info.generalName,
nationId: 0,
cityId: city.id,
troopId: 0,
npcState: 0,
affinity,
bornYear: worldState.currentYear - age,
deadYear: worldState.currentYear + 60,
picture,
imageServer: info.imgsvr,
stats: {
leadership: info.leadership,
strength: info.strength,
intelligence: info.intel,
},
experience: info.experience ?? age * 100,
dedication: info.dedication ?? age * 100,
officerLevel: 0,
injury: 0,
gold: 1000,
rice: 1000,
crew: 0,
crewTypeId: DEFAULT_CREW_TYPE_ID,
train: 0,
atmos: 0,
turnTime,
age,
startAge: age,
role: {
personality,
specialDomestic: info.specialDomestic,
specialWar: info.specialWar ?? defaultSpecialWar,
items: {
horse: null,
weapon: null,
book: null,
item: null,
},
},
triggerState: {
flags: {},
counters: {},
modifiers: {},
meta: {},
},
lastTurn: { command: DEFAULT_TURN_ACTION },
penalty: {},
refreshScoreTotal: 0,
meta: {
createdBy: 'select_pool',
ownerName: ownerDisplayName,
owner_name: ownerDisplayName,
killturn: 5,
specage: specialityAges.domestic,
specage2: specialityAges.war,
dex1: info.dex[0],
dex2: info.dex[1],
dex3: info.dex[2],
dex4: info.dex[3],
dex5: info.dex[4],
next_change: nextChangeAt.toISOString(),
nextChangeAt: nextChangeAt.toISOString(),
npc_org: 0,
},
};
if (!world.addGeneral(general)) {
throw new Error(`장수 번호 ${generalId}를 할당할 수 없습니다.`);
}
await db.generalTurn.createMany({
data: Array.from({ length: MAX_GENERAL_TURNS }, (_, turnIdx) => ({
generalId,
turnIdx,
actionCode: DEFAULT_TURN_ACTION,
arg: {},
})),
});
await db.generalTurnRevision.create({
data: {
generalId,
revision: 0,
},
});
const occupied = await db.selectPoolEntry.updateMany({
where: {
id: token.id,
ownerUserId: userId,
reservedUntil: { gte: now },
generalId: null,
},
data: {
generalId,
ownerUserId: null,
reservedUntil: null,
},
});
if (occupied.count === 0) {
throw new Error('장수 등록 중 선택 후보 점유에 실패했습니다.');
}
await db.generalAccessLog.upsert({
where: { generalId },
update: { userId, lastRefresh: now },
create: { generalId, userId, lastRefresh: now },
});
await clearUnusedReservations(db, userId, now);
const ownerJosaYi = JosaUtil.pick(ownerDisplayName, '이');
const generalJosaRo = JosaUtil.pick(info.generalName, '로');
await appendSelectionLogs({
db,
worldState,
generalId,
ownerUserId: userId,
generalText: `<Y>${info.generalName}</>, <G>${city.name}</>에서 등장`,
globalText: `<G><b>${city.name}</b></>에서 <Y>${ownerDisplayName}</>${ownerJosaYi} <Y>${info.generalName}</>${generalJosaRo} 등장합니다.`,
});
return { ok: true, generalId };
};
export const reselectGeneralFromSelectionPool = async (options: {
db: DatabaseClient;
world: InMemoryTurnWorld;
worldState: WorldStateRow;
userId: string;
ownerDisplayName: string;
uniqueName: string;
now?: Date;
}): Promise<{ ok: true; generalId: number }> => {
const { db, world, worldState, userId, ownerDisplayName, uniqueName } = options;
requirePoolWorld(worldState);
const now = options.now ?? new Date();
await lockSelectionUser(db, userId);
await lockSelectionMutationTables(db);
const persistedGeneral = await db.general.findFirst({ where: { userId } });
const general = world.listGenerals().find((candidate) => candidate.userId === userId);
if (!persistedGeneral || !general) {
throw new SelectPoolError(
'PRECONDITION_FAILED',
'장수가 생성하지 않았습니다. 이미 사망하지 않았는지 확인해보세요.'
);
}
if (persistedGeneral.id !== general.id) {
fail('INTERNAL_SERVER_ERROR', 'DB와 턴 데몬의 장수 소유 정보가 일치하지 않습니다.');
}
const nextChangeAt = readNextChangeAt(general.meta);
if (nextChangeAt && nextChangeAt.getTime() > now.getTime()) {
fail('PRECONDITION_FAILED', '아직 다시 고를 수 없습니다');
}
const token = await requireSelectionToken(db, userId, uniqueName, now);
const info = parseCandidate(token);
const provisionalGeneralId = -general.id;
const claimed = await db.selectPoolEntry.updateMany({
where: {
id: token.id,
ownerUserId: userId,
reservedUntil: { gte: now },
generalId: null,
},
data: {
generalId: provisionalGeneralId,
ownerUserId: null,
reservedUntil: null,
},
});
if (claimed.count === 0) {
throw new Error('장수 재선택 중 선택 후보 점유에 실패했습니다.');
}
await db.selectPoolEntry.updateMany({
where: { generalId: general.id },
data: { generalId: null, ownerUserId: null, reservedUntil: null },
});
const finalized = await db.selectPoolEntry.updateMany({
where: {
id: token.id,
generalId: provisionalGeneralId,
},
data: {
generalId: general.id,
},
});
if (finalized.count === 0) {
throw new Error('장수 재선택 중 선택 후보 확정에 실패했습니다.');
}
const currentMeta = asRecord(general.meta);
const cooldown = new Date(
now.getTime() + resolveTurnTermMinutes(worldState) * RESELECTION_TURN_MULTIPLIER * 60_000
);
const updatedMeta = {
...currentMeta,
ownerName: ownerDisplayName,
owner_name: ownerDisplayName,
dex1: info.dex[0],
dex2: info.dex[1],
dex3: info.dex[2],
dex4: info.dex[3],
dex5: info.dex[4],
next_change: cooldown.toISOString(),
nextChangeAt: cooldown.toISOString(),
};
const updated = world.updateGeneral(general.id, {
name: info.generalName,
stats: {
leadership: info.leadership,
strength: info.strength,
intelligence: info.intel,
},
role: {
...general.role,
personality: info.ego ?? general.role.personality,
specialDomestic: info.specialDomestic,
specialWar: info.specialWar ?? general.role.specialWar,
},
picture: info.picture,
imageServer: info.imgsvr,
meta: updatedMeta as unknown as TurnGeneral['meta'],
});
if (!updated) {
throw new Error('턴 데몬에서 장수 정보를 갱신하지 못했습니다.');
}
await clearUnusedReservations(db, userId, now);
const ownerJosaYi = JosaUtil.pick(ownerDisplayName, '이');
const generalJosaRo = JosaUtil.pick(info.generalName, '로');
await appendSelectionLogs({
db,
worldState,
generalId: general.id,
ownerUserId: userId,
generalText: `장수를 <Y>${general.name}</>에서 <Y>${info.generalName}</>${generalJosaRo} 변경`,
globalText: `<Y>${ownerDisplayName}</>${ownerJosaYi} 장수를 <Y>${general.name}</>에서 <Y>${info.generalName}</>${generalJosaRo} 변경합니다.`,
});
return { ok: true, generalId: general.id };
};
export const getSelectionPoolStatus = async (
db: DatabaseClient,
worldState: WorldStateRow,
userId: string
): Promise<{
enabled: boolean;
poolName: string | null;
allowOptions: string[];
hasGeneral: boolean;
nextChangeAt: string | null;
}> => {
const poolName = resolvePoolName(worldState);
const enabled = isSelectionPoolWorld(worldState);
const general = await db.general.findFirst({ where: { userId }, select: { meta: true } });
return {
enabled,
poolName,
allowOptions: resolvePoolAllowOptions(worldState),
hasGeneral: Boolean(general),
nextChangeAt: general ? readNextChangeAt(general.meta)?.toISOString() ?? null : null,
};
};
+1
View File
@@ -27,6 +27,7 @@ export interface TurnGeneral extends General {
deadYear?: number;
affinity?: number | null;
picture?: string | null;
imageServer?: number;
turnTime: Date;
recentWarTime?: Date | null;
lastTurn?: GeneralLastTurn;
+118 -1
View File
@@ -4,7 +4,7 @@ import type {
TurnDaemonCommandExecutionContext,
TurnDaemonCommandResult,
} from '../lifecycle/types.js';
import { GamePrisma } from '@sammo-ts/infra';
import { GamePrisma, type DatabaseClient } from '@sammo-ts/infra';
import { asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import {
LogCategory,
@@ -40,6 +40,11 @@ import {
IMMEDIATE_TROOP_JOIN_MOVE_HANDLER,
LEGACY_TROOP_JOIN_EVENT,
} from './scenarioStaticEvents.js';
import {
createGeneralFromSelectionPool,
reselectGeneralFromSelectionPool,
SelectPoolError,
} from './selectPoolService.js';
let itemRegistryPromise: Promise<Map<string, ItemModule>> | null = null;
@@ -117,6 +122,108 @@ interface CommandHandlerContext {
tournamentRewardFinalizer?: TournamentRewardFinalizer;
}
const requireCommandDatabase = (ctx: CommandHandlerContext): DatabaseClient => {
if (!ctx.commandDb) {
throw new Error('ENGINE mutation transaction is required for selection-pool commands.');
}
return ctx.commandDb as unknown as DatabaseClient;
};
const resolveCommandAcceptedAt = async (
db: DatabaseClient,
command: Extract<TurnDaemonCommand, { type: 'selectPoolCreate' | 'selectPoolReselect' }>
): Promise<Date> => {
if (!command.requestId) {
throw new Error(`${command.type} requestId is required.`);
}
const event = await db.inputEvent.findUnique({
where: { requestId: command.requestId },
select: { createdAt: true },
});
if (!event) {
throw new Error(`ENGINE input event ${command.requestId} is missing.`);
}
return event.createdAt;
};
async function handleSelectPoolCreate(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'selectPoolCreate' }>
): Promise<TurnDaemonCommandResult> {
const db = requireCommandDatabase(ctx);
const worldState = await db.worldState.findUnique({
where: { id: ctx.world.getState().id },
});
if (!worldState) {
throw new Error('Selection-pool world state is missing.');
}
const acceptedAt = await resolveCommandAcceptedAt(db, command);
try {
return {
type: 'selectPoolCreate',
...(await createGeneralFromSelectionPool({
db,
world: ctx.world,
worldState,
userId: command.userId,
ownerDisplayName: command.ownerDisplayName,
uniqueName: command.uniqueName,
personality: command.personality,
seedOwnerIdentity: command.seedOwnerIdentity,
now: acceptedAt,
})),
};
} catch (error) {
if (error instanceof SelectPoolError) {
return {
type: 'selectPoolCreate',
ok: false,
code: error.code,
reason: error.message,
};
}
throw error;
}
}
async function handleSelectPoolReselect(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'selectPoolReselect' }>
): Promise<TurnDaemonCommandResult> {
const db = requireCommandDatabase(ctx);
const worldState = await db.worldState.findUnique({
where: { id: ctx.world.getState().id },
});
if (!worldState) {
throw new Error('Selection-pool world state is missing.');
}
const acceptedAt = await resolveCommandAcceptedAt(db, command);
try {
return {
type: 'selectPoolReselect',
...(await reselectGeneralFromSelectionPool({
db,
world: ctx.world,
worldState,
userId: command.userId,
ownerDisplayName: command.ownerDisplayName,
uniqueName: command.uniqueName,
now: acceptedAt,
})),
};
} catch (error) {
if (error instanceof SelectPoolError) {
return {
type: 'selectPoolReselect',
ok: false,
code: error.code,
reason: error.message,
};
}
throw error;
}
}
interface AuctionFinalizer {
finalize(auctionId: number, db?: GamePrisma.TransactionClient): Promise<TurnDaemonCommandResult>;
}
@@ -1845,6 +1952,16 @@ export const createTurnDaemonCommandHandler = (options: {
handleTournamentMatchResult(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentMatchResult' }>),
patchGeneral: (command) =>
handlePatchGeneral(ctx, command as Extract<TurnDaemonCommand, { type: 'patchGeneral' }>),
selectPoolCreate: (command) =>
handleSelectPoolCreate(
ctx,
command as Extract<TurnDaemonCommand, { type: 'selectPoolCreate' }>
),
selectPoolReselect: (command) =>
handleSelectPoolReselect(
ctx,
command as Extract<TurnDaemonCommand, { type: 'selectPoolReselect' }>
),
shiftSchedule: (command) =>
handleShiftSchedule(ctx, command as Extract<TurnDaemonCommand, { type: 'shiftSchedule' }>),
};
+1
View File
@@ -237,6 +237,7 @@ const mapGeneralRow = (
deadYear: row.deadYear,
affinity: row.affinity,
picture: row.picture,
imageServer: row.imageServer,
triggerState: {
flags: {},
counters: {},
@@ -93,4 +93,48 @@ integration('database command queue', () => {
lockedBy: 'active-worker',
});
});
it('retries an owned command twice and then records a terminal failure', async () => {
const requestId = 'integration:engine:bounded-failure';
await db.inputEvent.create({
data: {
requestId,
target: 'ENGINE',
eventType: 'vacation',
payload: {
type: 'vacation',
requestId,
generalId: 10,
} as GamePrisma.InputJsonValue,
},
});
const owner = new DatabaseTurnDaemonCommandQueue(db);
const stale = new DatabaseTurnDaemonCommandQueue(db);
for (const attempt of [1, 2, 3]) {
await expect(owner.drain()).resolves.toEqual([
{ type: 'vacation', requestId, generalId: 10 },
]);
await stale.publishCommandError(requestId, new Error('stale worker failure'));
await expect(
db.inputEvent.findUniqueOrThrow({ where: { requestId } })
).resolves.toMatchObject({
status: 'PROCESSING',
attempts: attempt,
});
await owner.publishCommandError(requestId, new Error('injected command failure'));
await expect(
db.inputEvent.findUniqueOrThrow({ where: { requestId } })
).resolves.toMatchObject({
status: attempt < 3 ? 'PENDING' : 'FAILED',
attempts: attempt,
error: 'injected command failure',
lockedBy: null,
leaseUntil: null,
});
}
await expect(owner.drain()).resolves.toEqual([]);
});
});
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest';
import { loadGeneralPoolEntries } from '../src/scenario/generalPoolLoader.js';
describe('SPoolUnderU30 resource', () => {
it('preserves the Ref UnderS30 row contract and ordering', async () => {
const entries = await loadGeneralPoolEntries('SPoolUnderU30');
const weights = entries.map((entry) => {
const dex = entry.info.dex as number[];
return dex.reduce((sum, value) => sum + value, 0);
});
expect(entries).toHaveLength(1844);
expect(new Set(entries.map((entry) => entry.uniqueName)).size).toBe(1844);
expect(Math.min(...weights)).toBe(100122);
expect(Math.max(...weights)).toBe(2582699);
const traitFrequencies = entries.reduce<Record<string, number>>((result, entry) => {
const key = String(entry.info.specialDomestic);
result[key] = (result[key] ?? 0) + 1;
return result;
}, {});
expect(traitFrequencies).toEqual({
che_event_격노: 152,
che_event_견고: 91,
che_event_공성: 8,
che_event_궁병: 12,
che_event_귀병: 38,
che_event_기병: 12,
che_event_돌격: 98,
che_event_무쌍: 100,
che_event_반계: 81,
che_event_보병: 10,
che_event_신산: 99,
che_event_신중: 106,
che_event_위압: 85,
che_event_의술: 37,
che_event_저격: 251,
che_event_집중: 125,
che_event_징병: 169,
che_event_척사: 166,
che_event_필살: 156,
che_event_환술: 48,
});
expect(entries[0]).toEqual({
uniqueName: '⑨탈곡기',
info: {
generalName: '⑨탈곡기',
leadership: 69,
strength: 12,
intel: 80,
specialDomestic: 'che_event_징병',
dex: [12066, 27302, 29463, 307356, 16448],
imgsvr: 1,
picture: '9ed8be6.gif?=20190417',
uniqueName: '⑨탈곡기',
},
});
expect(entries.at(-1)?.uniqueName).toBe('④야부키 나코');
});
it('rejects an unsupported pool instead of silently substituting data', async () => {
await expect(loadGeneralPoolEntries('SPoolUnknown')).rejects.toThrow('Unsupported general pool');
});
});
@@ -254,6 +254,7 @@ describe('input event atomicity', () => {
resolveError = resolve;
});
const publishCommandResult = vi.fn(async () => {});
const publishCommandError = vi.fn(async () => {});
let engineState = { value: 'before' };
const stateManager = new EngineStateManager();
stateManager.register('test', {
@@ -287,6 +288,7 @@ describe('input event atomicity', () => {
commandResponder: {
publishStatus: async () => {},
publishCommandResult,
publishCommandError,
},
},
{
@@ -305,6 +307,10 @@ describe('input event atomicity', () => {
lastError: 'injected commit failure',
});
expect(publishCommandResult).not.toHaveBeenCalled();
expect(publishCommandError).toHaveBeenCalledWith(
'event-2',
expect.objectContaining({ message: 'injected commit failure' })
);
expect(engineState).toEqual({ value: 'before' });
expect(stateManager.getRevision()).toBe(0);
@@ -320,6 +326,7 @@ describe('input event atomicity', () => {
});
const commitCommand = vi.fn(async () => {});
const publishCommandResult = vi.fn(async () => {});
const publishCommandError = vi.fn(async () => {});
let engineState = { value: 'before' };
const stateManager = new EngineStateManager();
stateManager.register('test', {
@@ -351,6 +358,7 @@ describe('input event atomicity', () => {
commandResponder: {
publishStatus: async () => {},
publishCommandResult,
publishCommandError,
},
},
{
@@ -370,6 +378,10 @@ describe('input event atomicity', () => {
});
expect(commitCommand).not.toHaveBeenCalled();
expect(publishCommandResult).not.toHaveBeenCalled();
expect(publishCommandError).toHaveBeenCalledWith(
'event-3',
expect.objectContaining({ message: 'injected handler failure' })
);
expect(engineState).toEqual({ value: 'before' });
expect(stateManager.getRevision()).toBe(0);
+171
View File
@@ -30,6 +30,12 @@ type ScenarioSeederPrismaClient = {
count(): Promise<number>;
findFirst(): Promise<{ age: number; startAge: number; meta: unknown } | null>;
};
selectPoolEntry: {
count(): Promise<number>;
findFirst(args: {
orderBy: { id: 'asc' | 'desc' };
}): Promise<{ uniqueName: string; info: unknown } | null>;
};
diplomacy: {
count(): Promise<number>;
findFirst(args: {
@@ -48,6 +54,10 @@ type ScenarioSeederPrismaClient = {
currentMonth: number;
} | null>;
};
gameHistory: {
findUnique(args: { where: { serverId: string } }): Promise<{ env: unknown } | null>;
deleteMany(args: { where: { serverId: string } }): Promise<{ count: number }>;
};
};
const requiredTables = ['world_state', 'nation', 'city', 'general', 'diplomacy', 'troop', 'event'];
@@ -254,6 +264,167 @@ describeDb('scenario database seed', () => {
await connector.disconnect();
}
});
test('seeds the exact scenario 903 UnderS30 selection pool', async () => {
await seedScenarioToDatabase({
scenarioId: 903,
databaseUrl,
});
const connector = createGamePostgresConnector({ url: databaseUrl });
await connector.connect();
try {
const prisma = connector.prisma as unknown as ScenarioSeederPrismaClient;
expect(await prisma.selectPoolEntry.count()).toBe(1844);
expect(await prisma.selectPoolEntry.findFirst({ orderBy: { id: 'asc' } })).toMatchObject({
uniqueName: '⑨탈곡기',
info: {
generalName: '⑨탈곡기',
specialDomestic: 'che_event_징병',
},
});
expect(await prisma.selectPoolEntry.findFirst({ orderBy: { id: 'desc' } })).toMatchObject({
uniqueName: '④야부키 나코',
});
} finally {
await connector.disconnect();
}
});
test('clears general lifecycle rows before reusing general ids on reseed', async () => {
await seedScenarioToDatabase({
scenarioId: 903,
databaseUrl,
});
const connector = createGamePostgresConnector({ url: databaseUrl });
await connector.connect();
try {
const prisma = connector.prisma;
const generalId = 990_903;
const createSelectedGeneral = async (): Promise<void> => {
await prisma.general.create({
data: {
id: generalId,
userId: 'scenario-reseed-user',
name: '재설치선택장수',
turnTime: new Date('2026-07-30T12:00:00.000Z'),
},
});
};
const createLifecycleRows = async (): Promise<void> => {
await prisma.generalTurn.create({
data: {
generalId,
turnIdx: 0,
actionCode: '휴식',
},
});
await prisma.generalTurnRevision.create({
data: {
generalId,
revision: 7,
},
});
await prisma.rankData.create({
data: {
nationId: 0,
generalId,
type: 'experience',
value: 123,
},
});
await prisma.generalAccessLog.create({
data: {
generalId,
userId: 'scenario-reseed-user',
},
});
};
const expectLifecycleRows = async (count: number): Promise<void> => {
await expect(
Promise.all([
prisma.generalTurn.count({ where: { generalId } }),
prisma.generalTurnRevision.count({ where: { generalId } }),
prisma.rankData.count({ where: { generalId } }),
prisma.generalAccessLog.count({ where: { generalId } }),
])
).resolves.toEqual([count, count, count, count]);
};
await createSelectedGeneral();
await createLifecycleRows();
await expectLifecycleRows(1);
await seedScenarioToDatabase({
scenarioId: 903,
databaseUrl,
});
await expectLifecycleRows(0);
await expect(prisma.general.findUnique({ where: { id: generalId } })).resolves.toBeNull();
await createSelectedGeneral();
await createLifecycleRows();
await expectLifecycleRows(1);
await seedScenarioToDatabase({
scenarioId: 903,
databaseUrl,
});
await expectLifecycleRows(0);
} finally {
await connector.disconnect();
}
});
test('persists a private hidden seed without copying it into game history', async () => {
const envName = 'INTEGRATION_WORLD_SEED';
const originalSeed = process.env[envName];
const serverId = 'scenario-seeder-hidden-seed-test';
const connector = createGamePostgresConnector({ url: databaseUrl });
try {
process.env[envName] = 'scenario-seeder-explicit-hidden-seed';
await seedScenarioToDatabase({
scenarioId: 903,
databaseUrl,
installOptions: { serverId },
});
await connector.connect();
const prisma = connector.prisma as unknown as ScenarioSeederPrismaClient;
const explicitWorld = await prisma.worldState.findFirst();
expect(explicitWorld?.meta).toMatchObject({
hiddenSeed: 'scenario-seeder-explicit-hidden-seed',
});
const explicitHistory = await prisma.gameHistory.findUnique({ where: { serverId } });
expect((explicitHistory?.env as { meta?: Record<string, unknown> })?.meta).not.toHaveProperty(
'hiddenSeed'
);
delete process.env[envName];
await seedScenarioToDatabase({
scenarioId: 903,
databaseUrl,
installOptions: { serverId },
});
const randomWorld = await prisma.worldState.findFirst();
const randomSeed = (randomWorld?.meta as Record<string, unknown>)?.hiddenSeed;
expect(randomSeed).toMatch(/^[0-9a-f]{32}$/);
expect(randomSeed).not.toBe('scenario-seeder-explicit-hidden-seed');
const randomHistory = await prisma.gameHistory.findUnique({ where: { serverId } });
expect((randomHistory?.env as { meta?: Record<string, unknown> })?.meta).not.toHaveProperty(
'hiddenSeed'
);
await prisma.gameHistory.deleteMany({ where: { serverId } });
} finally {
if (originalSeed === undefined) {
delete process.env[envName];
} else {
process.env[envName] = originalSeed;
}
await connector.disconnect();
}
});
});
describe('tracked scenario composition', () => {
@@ -0,0 +1,274 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import {
createGamePostgresConnector,
type GamePrisma,
type GamePrismaClient,
} from '@sammo-ts/infra';
import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import { loadTurnWorldFromDatabase } from '../src/turn/worldLoader.js';
const databaseUrl = process.env.SELECT_POOL_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
const generalId = 990_904;
const cityId = 990_904;
const scenarioCode = 'select-pool-release-integration';
const assertDedicatedDatabase = (rawUrl: string): void => {
const schema = new URL(rawUrl).searchParams.get('schema');
if (!schema?.endsWith('select_pool_integration')) {
throw new Error(`Refusing to mutate non-dedicated schema: ${schema ?? '(missing)'}`);
}
};
integration('select pool release during general deletion', () => {
let db: GamePrismaClient;
let closeDb: (() => Promise<void>) | undefined;
beforeAll(async () => {
assertDedicatedDatabase(databaseUrl!);
const connector = createGamePostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
closeDb = () => connector.disconnect();
await db.$executeRawUnsafe('DROP TABLE IF EXISTS "select_pool_delete_blocker"');
await db.selectPoolEntry.deleteMany({
where: { uniqueName: 'release-candidate' },
});
await db.general.deleteMany({ where: { id: generalId } });
await db.city.deleteMany({ where: { id: cityId } });
await db.worldState.deleteMany({ where: { scenarioCode } });
await db.worldState.create({
data: {
scenarioCode,
currentYear: 180,
currentMonth: 1,
tickSeconds: 300,
config: {
npcMode: 2,
turnTermMinutes: 5,
stat: {
total: 165,
min: 15,
max: 80,
npcTotal: 165,
npcMax: 80,
npcMin: 15,
chiefMin: 40,
},
iconPath: '.',
map: {
targetGeneralPool: 'SPoolUnderU30',
generalPoolAllowOption: ['ego'],
},
const: {},
environment: {
mapName: 'che',
unitSet: 'che',
},
},
meta: {
hiddenSeed: 'select-pool-release-seed',
killturn: 5,
turntime: '2026-07-30T12:00:00.000Z',
},
},
});
await db.city.create({
data: {
id: cityId,
name: '해제성',
level: 5,
nationId: 0,
population: 10_000,
populationMax: 20_000,
agriculture: 1_000,
agricultureMax: 2_000,
commerce: 1_000,
commerceMax: 2_000,
security: 1_000,
securityMax: 2_000,
defence: 1_000,
defenceMax: 2_000,
wall: 1_000,
wallMax: 2_000,
region: 1,
},
});
await db.general.create({
data: {
id: generalId,
userId: 'select-pool-release-user',
name: '해제대상',
nationId: 0,
cityId,
troopId: 0,
npcState: 0,
affinity: 1,
bornYear: 160,
deadYear: 240,
picture: 'default.jpg',
imageServer: 0,
leadership: 50,
strength: 50,
intel: 50,
experience: 2_000,
dedication: 2_000,
officerLevel: 0,
turnTime: new Date('2026-07-30T12:01:00.000Z'),
age: 20,
startAge: 20,
personalCode: 'che_안전',
specialCode: 'che_event_신산',
special2Code: 'che_무쌍',
meta: {
killturn: 5,
dex1: 100_000,
dex2: 100_000,
dex3: 100_000,
dex4: 100_000,
dex5: 100_000,
},
},
});
await db.selectPoolEntry.create({
data: {
uniqueName: 'release-candidate',
ownerUserId: null,
generalId,
reservedUntil: null,
info: {
uniqueName: 'release-candidate',
generalName: '해제대상',
leadership: 50,
strength: 50,
intel: 50,
specialDomestic: 'che_event_신산',
dex: [100_000, 100_000, 100_000, 100_000, 100_000],
imgsvr: 0,
picture: 'default.jpg',
} as GamePrisma.InputJsonValue,
},
});
});
afterAll(async () => {
if (!db) {
await closeDb?.();
return;
}
await db.$executeRawUnsafe('DROP TABLE IF EXISTS "select_pool_delete_blocker"');
await db.selectPoolEntry.deleteMany({
where: { uniqueName: 'release-candidate' },
});
await db.general.deleteMany({ where: { id: generalId } });
await db.city.deleteMany({ where: { id: cityId } });
await db.worldState.deleteMany({ where: { scenarioCode } });
await closeDb?.();
});
it('round-trips independent event-domestic and war trait slots through a dirty flush', async () => {
const loaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
const world = new InMemoryTurnWorld(loaded.state, loaded.snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 5 }] },
});
const before = world.getGeneralById(generalId);
expect(before?.role).toMatchObject({
specialDomestic: 'che_event_신산',
specialWar: 'che_무쌍',
});
expect(world.updateGeneral(generalId, { gold: (before?.gold ?? 0) + 1 })).not.toBeNull();
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
try {
await hooks.hooks.flushChanges?.({
lastTurnTime: loaded.state.lastTurnTime.toISOString(),
processedGenerals: 0,
processedTurns: 0,
durationMs: 0,
partial: false,
});
} finally {
await hooks.close();
}
await expect(db.general.findUniqueOrThrow({ where: { id: generalId } })).resolves.toMatchObject({
specialCode: 'che_event_신산',
special2Code: 'che_무쌍',
});
const reloaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
expect(
reloaded.snapshot.generals.find((general) => general.id === generalId)?.role
).toMatchObject({
specialDomestic: 'che_event_신산',
specialWar: 'che_무쌍',
});
});
it('rolls back a failed flush, then releases all Ref fields before deleting the general', async () => {
const loaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
const world = new InMemoryTurnWorld(loaded.state, loaded.snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 5 }] },
});
expect(world.removeGeneral(generalId)).toBe(true);
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
try {
await db.$executeRawUnsafe(`
CREATE TABLE "select_pool_delete_blocker" (
"general_id" INTEGER PRIMARY KEY
REFERENCES "general"("id") ON DELETE RESTRICT
)
`);
await db.$executeRawUnsafe(
`INSERT INTO "select_pool_delete_blocker" ("general_id") VALUES (${generalId})`
);
await expect(
hooks.hooks.flushChanges?.({
lastTurnTime: loaded.state.lastTurnTime.toISOString(),
processedGenerals: 0,
processedTurns: 0,
durationMs: 0,
partial: false,
})
).rejects.toThrow();
await expect(db.general.findUnique({ where: { id: generalId } })).resolves.not.toBeNull();
await expect(
db.selectPoolEntry.findUniqueOrThrow({
where: { uniqueName: 'release-candidate' },
})
).resolves.toMatchObject({
generalId,
ownerUserId: null,
reservedUntil: null,
});
await db.$executeRawUnsafe('DROP TABLE "select_pool_delete_blocker"');
await hooks.hooks.flushChanges?.({
lastTurnTime: loaded.state.lastTurnTime.toISOString(),
processedGenerals: 0,
processedTurns: 0,
durationMs: 0,
partial: false,
});
await expect(db.general.findUnique({ where: { id: generalId } })).resolves.toBeNull();
await expect(
db.selectPoolEntry.findUniqueOrThrow({
where: { uniqueName: 'release-candidate' },
})
).resolves.toMatchObject({
generalId: null,
ownerUserId: null,
reservedUntil: null,
});
} finally {
await hooks.close();
}
});
});
+4 -1
View File
@@ -135,7 +135,7 @@ describe('InMemoryTurnProcessor ordering', () => {
currentMonth: 1,
tickSeconds: 3600,
lastTurnTime: baseTime,
meta: {},
meta: { lastGeneralId: 1 },
};
const world = new InMemoryTurnWorld(state, snapshot, {
@@ -157,5 +157,8 @@ describe('InMemoryTurnProcessor ordering', () => {
});
expect(executed).toEqual([2, 3, 1]);
expect(world.getNextGeneralId()).toBe(4);
expect(world.getNextGeneralId()).toBe(5);
expect(world.getState().meta).toMatchObject({ lastGeneralId: 5 });
});
});
+60 -2
View File
@@ -52,6 +52,7 @@ const simulatorOptions = {
],
},
nationTypes: [{ key: 'che_중립', name: '중립', info: '특별한 효과 없음' }],
eventDomesticTraits: [{ key: 'che_event_신산', name: '신산', info: '계략 강화' }],
warTraits: [{ key: 'che_필살', name: '필살', info: '필살 확률 증가' }],
personalities: [{ key: 'che_대담', name: '대담', info: '공격적인 성격' }],
items: { horse: [], weapon: [], book: [], item: [] },
@@ -163,6 +164,7 @@ type Fixture = {
queueFirst?: boolean;
pollingCount: number;
requests: string[];
simulationPayloads: unknown[];
};
const installImages = async (page: Page) => {
@@ -185,8 +187,14 @@ const installApi = async (page: Page, fixture: Fixture) => {
});
await page.route('**/che/api/trpc/**', async (route) => {
const operations = operationNames(route);
const results = operations.map((operation) => {
const rawRequestBody: unknown = route.request().postData() ? route.request().postDataJSON() : {};
const requestBody =
rawRequestBody && typeof rawRequestBody === 'object'
? (rawRequestBody as Record<string, unknown>)
: {};
const results = operations.map((operation, operationIndex) => {
fixture.requests.push(operation);
if (operation === 'auth.status') return response({ userId: 'battle-sim-user' });
if (operation === 'lobby.info') {
return response({
year: 205,
@@ -206,6 +214,18 @@ const installApi = async (page: Page, fixture: Fixture) => {
}
if (operation === 'battle.getGeneralDetail') return response(importedGeneral);
if (operation === 'battle.simulate') {
const rawPayload =
requestBody[String(operationIndex)] ?? (operations.length === 1 ? requestBody : undefined);
const payload =
rawPayload && typeof rawPayload === 'object'
? (rawPayload as {
json?: unknown;
input?: { json?: unknown };
})
: undefined;
fixture.simulationPayloads.push(
payload?.json ?? payload?.input?.json ?? rawPayload
);
if (fixture.failNextSimulation) {
fixture.failNextSimulation = false;
return errorResponse(operation, '시뮬레이터 입력 오류');
@@ -244,7 +264,13 @@ const gotoSimulator = async (page: Page) => {
};
test('operates independent/game presets, imports my general, and renders battle logs', async ({ page }) => {
const fixture: Fixture = { hasGeneral: true, queueFirst: true, pollingCount: 0, requests: [] };
const fixture: Fixture = {
hasGeneral: true,
queueFirst: true,
pollingCount: 0,
requests: [],
simulationPayloads: [],
};
await installApi(page, fixture);
await page.setViewportSize({ width: 1280, height: 900 });
await gotoSimulator(page);
@@ -265,6 +291,9 @@ test('operates independent/game presets, imports my general, and renders battle
await page.getByRole('button', { name: '내 장수를 출병자로' }).click();
await expect(page.getByLabel('이름').first()).toHaveValue('유비');
await expect(page.getByLabel('병사').first()).toHaveValue('4321');
const attackerDomesticTrait = page.getByLabel('내정특기').first();
await attackerDomesticTrait.selectOption('che_event_신산');
await expect(attackerDomesticTrait).toHaveValue('che_event_신산');
const battleButton = page.getByRole('button', { name: '전투', exact: true });
await battleButton.hover();
@@ -276,6 +305,34 @@ test('operates independent/game presets, imports my general, and renders battle
await expect(page.getByText('5', { exact: true })).toBeVisible();
expect(fixture.pollingCount).toBe(2);
expect(fixture.requests).toContain('battle.getSimulation');
expect(fixture.simulationPayloads[0]).toMatchObject({
attackerGeneral: { special: 'che_event_신산' },
});
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: '모두 저장' }).click();
const download = await downloadPromise;
const downloadPath = await download.path();
expect(downloadPath).not.toBeNull();
const exportedBattle = JSON.parse(await readFile(downloadPath!, 'utf8')) as {
objType: string;
data: { attackerGeneral: { special?: string | null } };
};
expect(exportedBattle).toMatchObject({
objType: 'battle',
data: { attackerGeneral: { special: 'che_event_신산' } },
});
await attackerDomesticTrait.selectOption({ label: '-' });
await expect(attackerDomesticTrait).toHaveValue('-');
await page.locator('.header-actions input[type="file"]').setInputFiles(downloadPath!);
await expect(attackerDomesticTrait).toHaveValue('che_event_신산');
await battleButton.click();
await expect.poll(() => fixture.simulationPayloads.length).toBe(2);
expect(fixture.simulationPayloads[1]).toMatchObject({
attackerGeneral: { special: 'che_event_신산' },
});
if (artifactRoot) {
await page.screenshot({
@@ -292,6 +349,7 @@ test('keeps simulation available without a game general and preserves input afte
failNextSimulation: true,
pollingCount: 0,
requests: [],
simulationPayloads: [],
};
await installApi(page, fixture);
await page.setViewportSize({ width: 500, height: 900 });
@@ -28,6 +28,7 @@ export default defineConfig({
'commandArguments.spec.ts',
'commandArgumentsLive.spec.ts',
'mainNavigation.spec.ts',
'session-auth.spec.ts',
],
fullyParallel: false,
workers: 1,
@@ -0,0 +1,40 @@
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { defineConfig, devices } from '@playwright/test';
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const port = Number(process.env.PLAYWRIGHT_FRONTEND_PORT ?? 15124);
const basePath = `/${(process.env.PLAYWRIGHT_GAME_BASE_PATH ?? 'hwe').replace(/^\/+|\/+$/g, '')}`;
const gameProfile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'hwe:903';
const baseURL = `http://127.0.0.1:${port}${basePath}/`;
const gameApiUrl = process.env.PLAYWRIGHT_GAME_API_URL ?? 'http://127.0.0.1:15125/trpc';
export default defineConfig({
testDir: '.',
testMatch: ['selectGeneralLive.spec.ts'],
fullyParallel: false,
workers: 1,
timeout: 60_000,
expect: {
timeout: 10_000,
},
reporter: [['list']],
outputDir: resolve(repositoryRoot, 'test-results/select-pool-live'),
use: {
baseURL,
...devices['Desktop Chrome'],
deviceScaleFactor: 1,
colorScheme: 'dark',
locale: 'ko-KR',
timezoneId: 'Asia/Seoul',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
},
webServer: {
command: `VITE_APP_BASE_PATH=${basePath} VITE_GAME_API_URL=${gameApiUrl} VITE_GAME_PROFILE=${gameProfile} VITE_GATEWAY_WEB_URL=/gateway/ pnpm --filter @sammo-ts/game-frontend dev --host 127.0.0.1 --port ${port}`,
cwd: repositoryRoot,
url: baseURL,
reuseExistingServer: false,
timeout: 120_000,
},
});
@@ -0,0 +1,519 @@
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import { expect, test, type Page } from '@playwright/test';
import { encryptGameSessionToken } from '@sammo-ts/common/auth/gameToken';
import {
createGamePostgresConnector,
type GamePrisma,
type GamePrismaClient,
} from '@sammo-ts/infra';
const gameTokenSecret = process.env.SELECT_POOL_LIVE_GAME_SECRET;
const databaseUrl = process.env.SELECT_POOL_LIVE_DATABASE_URL;
const userId = process.env.SELECT_POOL_LIVE_USER_ID;
const profile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'hwe:903';
const hasLiveFixture = Boolean(gameTokenSecret && databaseUrl && userId);
const workspaceRoot =
process.env.SAMMO_WORKSPACE_ROOT ??
path.resolve(import.meta.dirname, '../../../../../sam_rebuild');
const defaultIcon = path.resolve(
process.env.SELECT_POOL_LIVE_DEFAULT_ICON ??
path.join(workspaceRoot, 'image/icons/default.jpg')
);
const walnutTexture = path.join(workspaceRoot, 'image/game/back_walnut.jpg');
const greenTexture = path.join(workspaceRoot, 'image/game/back_green.jpg');
const fixtureNationIds = [990_901, 990_902, 990_903];
interface AssetTracker {
userIconRequests: number;
}
const installSession = async (page: Page, tracker?: AssetTracker): Promise<void> => {
const now = new Date();
const gameToken = encryptGameSessionToken(
{
version: 1,
profile,
issuedAt: now.toISOString(),
expiresAt: new Date(now.getTime() + 3_600_000).toISOString(),
sessionId: `select-pool-live-${randomUUID()}`,
user: {
id: userId!,
username: 'select-pool-live',
displayName: '선택실사용자',
roles: ['user'],
legacyMemberNo: 42,
},
sanctions: {},
identity: {
kakaoVerified: true,
canCreateGeneral: true,
requiresKakaoVerification: false,
graceEndsAt: null,
},
},
gameTokenSecret!
);
await page.addInitScript(
({ token, gameProfile }) => {
if (!window.localStorage.getItem('sammo-game-token')) {
window.localStorage.setItem('sammo-game-token', token);
}
window.localStorage.setItem('sammo-game-profile', gameProfile);
},
{ token: gameToken, gameProfile: profile }
);
await page.addInitScript(() => {
const values = [1, 0];
Object.defineProperty(window.crypto, 'getRandomValues', {
configurable: true,
value: <T extends ArrayBufferView | null>(array: T): T => {
if (array && (array as Uint32Array).length > 0) {
(array as Uint32Array)[0] = values.shift() ?? 0;
}
return array;
},
});
});
await page.route('**/image/icons/**', (route) =>
route.fulfill({ path: defaultIcon, contentType: 'image/jpeg' })
);
await page.route('**/gateway/api/user-icons/**', (route) => {
if (tracker) {
tracker.userIconRequests += 1;
}
return route.fulfill({ status: 404, body: '' });
});
await page.route('**/image/game/back_walnut.jpg', (route) =>
route.fulfill({ path: walnutTexture, contentType: 'image/jpeg' })
);
await page.route('**/image/game/back_green.jpg', (route) =>
route.fulfill({ path: greenTexture, contentType: 'image/jpeg' })
);
};
const waitForPool = async (page: Page): Promise<void> => {
await expect(page.locator('.card-holder > .general-card')).toHaveCount(14);
await page.evaluate(async () => {
await document.fonts.ready;
});
};
test.describe('scenario 903 live selection pool', () => {
test.skip(!hasLiveFixture, 'live selection-pool token and database are required');
let db: GamePrismaClient;
let closeDb: (() => Promise<void>) | undefined;
test.beforeAll(async () => {
const connector = createGamePostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
closeDb = () => connector.disconnect();
});
test.afterAll(async () => {
await closeDb?.();
});
test('renders Ref-width desktop/mobile cards, tooltip, focus, and expiration states', async ({
page,
}, testInfo) => {
await page.clock.install({ time: new Date() });
const assetTracker: AssetTracker = { userIconRequests: 0 };
await installSession(page, assetTracker);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('select-general');
await waitForPool(page);
await expect(page.locator('.server-info-table')).toContainText(
'현재 : 180年 1月 (5분 턴 서버)'
);
await expect(page.locator('.server-info-table')).toContainText(
'등록 장수 : 유저 0 / 500 명'
);
await expect(page.locator('.invitation-table')).toContainText('임관 권유 메시지');
const geometry = await page.evaluate(() => {
const root = document.querySelector<HTMLElement>('.select-pool-page')!;
const cards = Array.from(
document.querySelectorAll<HTMLElement>('.card-holder > .general-card')
);
const images = Array.from(
document.querySelectorAll<HTMLImageElement>('.card-holder .portrait img')
);
const selectionBody = document.querySelector<HTMLElement>('.selection-body')!;
const createSection = document.querySelector<HTMLElement>('.create-section')!;
const createBody = document.querySelector<HTMLElement>('.create-body')!;
const pageTitle = document.querySelector<HTMLElement>('.page-title')!;
const serverInfoTable =
document.querySelector<HTMLElement>('.server-info-table')!;
const invitationTable =
document.querySelector<HTMLElement>('.invitation-table')!;
const footerBack = document.querySelector<HTMLElement>('.footer-back')!;
const footerBanner = document.querySelector<HTMLElement>('.footer-banner')!;
const firstButton = document.querySelector<HTMLElement>(
'.card-holder .select-button'
)!;
return {
root: root.getBoundingClientRect().toJSON(),
pageTitle: pageTitle.getBoundingClientRect().toJSON(),
serverInfoTable: serverInfoTable.getBoundingClientRect().toJSON(),
invitationTable: invitationTable.getBoundingClientRect().toJSON(),
cards: cards.map((card) => card.getBoundingClientRect().toJSON()),
images: images.map((image) => ({
rect: image.getBoundingClientRect().toJSON(),
naturalWidth: image.naturalWidth,
naturalHeight: image.naturalHeight,
objectFit: getComputedStyle(image).objectFit,
})),
selectionBody: selectionBody.getBoundingClientRect().toJSON(),
createSection: createSection.getBoundingClientRect().toJSON(),
createBody: createBody.getBoundingClientRect().toJSON(),
footerBack: footerBack.getBoundingClientRect().toJSON(),
footerBanner: footerBanner.getBoundingClientRect().toJSON(),
firstButton: firstButton.getBoundingClientRect().toJSON(),
rootStyle: {
fontFamily: getComputedStyle(root).fontFamily,
fontSize: getComputedStyle(root).fontSize,
lineHeight: getComputedStyle(root).lineHeight,
backgroundImage: getComputedStyle(root).backgroundImage,
},
scrollWidth: document.documentElement.scrollWidth,
};
});
expect(geometry.root).toMatchObject({ x: 100, y: 8, width: 1000 });
expect(geometry.pageTitle).toMatchObject({ x: 100, y: 8, width: 1000 });
expect(Math.abs(geometry.pageTitle.height - 42.1875)).toBeLessThan(0.6);
expect(Math.abs(geometry.serverInfoTable.y - 50.1875)).toBeLessThan(0.6);
expect(Math.abs(geometry.serverInfoTable.height - 40.375)).toBeLessThan(0.6);
expect(Math.abs(geometry.invitationTable.x - 553)).toBeLessThan(0.6);
expect(Math.abs(geometry.invitationTable.y - 90.5625)).toBeLessThan(0.6);
expect(geometry.invitationTable.width).toBe(94);
expect(Math.abs(geometry.invitationTable.height - 20.1875)).toBeLessThan(0.1);
expect(geometry.rootStyle).toMatchObject({
fontSize: '14px',
lineHeight: '18.2px',
});
expect(geometry.rootStyle.fontFamily).toContain('Pretendard');
expect(geometry.rootStyle.backgroundImage).toContain('back_walnut.jpg');
expect(geometry.cards.every((card) => card.width === 127)).toBe(true);
expect(new Set(geometry.cards.map((card) => card.y)).size).toBe(2);
const shortestCard = Math.min(...geometry.cards.map((card) => card.height));
expect(Math.abs(shortestCard - 254.875)).toBeLessThan(0.6);
expect(
geometry.images.every(
(image, index) =>
image.rect.width === 64 &&
image.rect.height === 64 &&
Math.abs(
image.rect.x -
(geometry.cards[index]!.x +
(geometry.cards[index]!.width - image.rect.width) / 2)
) < 0.1
)
).toBe(true);
expect(
geometry.images.every(
(image) => image.naturalWidth > 0 && image.naturalHeight > 0
)
).toBe(true);
expect(geometry.images.every((image) => image.objectFit === 'fill')).toBe(true);
const fallbackImages = page.locator(
'.card-holder .portrait img[data-fallback-applied="true"]'
);
await expect(fallbackImages).not.toHaveCount(0);
expect(assetTracker.userIconRequests).toBe(await fallbackImages.count());
expect(Math.abs(geometry.selectionBody.y - 130.9375)).toBeLessThan(0.6);
expect(Math.abs(geometry.createSection.height - 87.375)).toBeLessThan(0.6);
expect(geometry.firstButton.height).toBe(19);
expect(geometry.footerBanner.height).toBeCloseTo(20.1875, 3);
await expect(page.locator('.invitation-table tbody tr')).toHaveCount(0);
await expect(page.locator('.footer-banner')).toContainText(
'삼국지 모의전투 HiDCHe core2026'
);
await expect(page.locator('.footer-banner a')).toHaveText('Credit');
const firstTrait = page.locator('.card-holder .trait-tooltip').first();
await firstTrait.hover();
await expect(firstTrait.getByRole('tooltip')).toBeVisible();
await page.screenshot({
path: testInfo.outputPath('select-general-desktop-hover.png'),
fullPage: true,
});
const firstButton = page.locator('.card-holder .select-button').first();
await firstButton.focus();
await expect(firstButton).toHaveCSS('outline-style', 'auto');
await expect(firstButton).toHaveCSS('outline-width', '1px');
await firstButton.hover();
await expect(firstButton).toHaveCSS('background-color', 'rgb(25, 25, 25)');
await page.setViewportSize({ width: 500, height: 900 });
await expect
.poll(() => page.evaluate(() => document.documentElement.scrollWidth))
.toBeGreaterThanOrEqual(1008);
await page.screenshot({
path: testInfo.outputPath('select-general-mobile.png'),
fullPage: true,
});
const validText = await page.locator('.selection-body small span').textContent();
expect(validText).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
const displayedExpiry = new Date(`${validText!.replace(' ', 'T')}+09:00`).getTime();
const delta = Math.max(displayedExpiry - Date.now(), 0);
await page.clock.fastForward(delta);
await expect(page.locator('.expired-text')).toHaveCount(0);
await page.clock.fastForward(2_000);
await expect(page.locator('.expired-text')).toHaveText('- 만료 -');
});
test('shuffles non-neutral invitation nations with Ref-style row content and colors', async ({
page,
}) => {
await db.nation.deleteMany({ where: { id: { in: fixtureNationIds } } });
await db.nation.createMany({
data: [
{
id: fixtureNationIds[0]!,
name: '테스트국A',
color: '#330000',
level: 1,
meta: { infoText: 'A 권유' },
},
{
id: fixtureNationIds[1]!,
name: '테스트국B',
color: '#FFFF00',
level: 1,
meta: { infoText: 'B 권유' },
},
{
id: fixtureNationIds[2]!,
name: '테스트국C',
color: '#000080',
level: 1,
meta: { infoText: 'C 권유' },
},
],
});
try {
await installSession(page);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('select-general');
await waitForPool(page);
const rows = page.locator('.invitation-table tbody tr');
await expect(rows.locator('.invitation-nation')).toHaveText([
'테스트국C',
'테스트국A',
'테스트국B',
]);
await expect(rows.locator('.invitation-message')).toHaveText([
'C 권유',
'A 권유',
'B 권유',
]);
await expect(rows.nth(0)).toHaveCSS('background-color', 'rgb(0, 0, 128)');
await expect(rows.nth(1)).toHaveCSS('background-color', 'rgb(51, 0, 0)');
await expect(rows.nth(2)).toHaveCSS('background-color', 'rgb(255, 255, 0)');
await expect(rows.nth(0)).toHaveCSS('color', 'rgb(255, 255, 255)');
await expect(rows.nth(2)).toHaveCSS('color', 'rgb(0, 0, 0)');
const invitationGeometry = await page.locator('.invitation-table').evaluate((table) => {
const rect = table.getBoundingClientRect();
return { x: rect.x, width: rect.width };
});
expect(invitationGeometry).toEqual({ x: 100, width: 1000 });
} finally {
await db.nation.deleteMany({ where: { id: { in: fixtureNationIds } } });
}
});
test('creates, rejects cooldown, exposes MyPage action, and reselects through the live API', async ({
page,
}) => {
const dialogs: string[] = [];
const createClientRequestIds: string[] = [];
let injectCreateTimeout = true;
page.on('dialog', async (dialog) => {
dialogs.push(dialog.message());
await dialog.accept();
});
await page.route('**/trpc/join.selectPoolGeneral?batch=1', async (route) => {
const findClientRequestId = (value: unknown): string | undefined => {
if (!value || typeof value !== 'object') return undefined;
if (
'clientRequestId' in value &&
typeof value.clientRequestId === 'string'
) {
return value.clientRequestId;
}
for (const nested of Object.values(value)) {
const found = findClientRequestId(nested);
if (found) return found;
}
return undefined;
};
const clientRequestId = findClientRequestId(route.request().postDataJSON());
expect(clientRequestId).toMatch(/^[0-9a-f-]{36}$/);
createClientRequestIds.push(clientRequestId!);
if (!injectCreateTimeout) {
await route.continue();
return;
}
injectCreateTimeout = false;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{
error: {
message:
'장수 선택 요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다. 같은 요청으로 다시 시도해 주세요.',
code: -32008,
data: {
code: 'TIMEOUT',
httpStatus: 408,
path: 'join.selectPoolGeneral',
},
},
},
]),
});
});
const assetTracker: AssetTracker = { userIconRequests: 0 };
await installSession(page, assetTracker);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('select-general');
await waitForPool(page);
const candidateCards = page.locator('.card-holder > .general-card');
const fallbackCard = candidateCards
.filter({ has: page.locator('img[data-fallback-applied="true"]') })
.first();
await expect(fallbackCard).toBeVisible();
const initialName = await fallbackCard.locator('h4').first().textContent();
const userIconRequestsBeforePreview = assetTracker.userIconRequests;
await fallbackCard.locator('.select-button').click();
await expect(page.locator('.selected-card')).toHaveCount(1);
await expect(
page.locator('.selected-card img[data-fallback-applied="true"]')
).toBeVisible();
expect(assetTracker.userIconRequests).toBeGreaterThan(userIconRequestsBeforePreview);
await page.locator('.custom-form select').selectOption('che_안전');
await page.getByRole('button', { name: '다시입력' }).click();
await expect(page.locator('.custom-form select')).toHaveValue('Random');
await expect(page.locator('.selected-card')).toHaveCount(1);
await page.locator('.custom-form select').selectOption('che_안전');
await page.locator('#build-general').click();
await expect.poll(() => dialogs).toContain(
'실패했습니다: 장수 선택 요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다. 같은 요청으로 다시 시도해 주세요.'
);
await waitForPool(page);
const retryCard = page
.locator('.card-holder > .general-card')
.filter({ has: page.locator('h4', { hasText: initialName?.trim() ?? '' }) })
.first();
await retryCard.locator('.select-button').click();
await page.locator('.custom-form select').selectOption('che_안전');
await page.locator('#build-general').click();
await expect(page).toHaveURL(/\/hwe\/$/);
expect(dialogs.filter((message) => message === '이 장수로 생성할까요?')).toHaveLength(2);
await expect.poll(() => dialogs).toContain('선택한 장수로 생성했습니다.');
expect(createClientRequestIds).toHaveLength(2);
expect(createClientRequestIds[1]).toBe(createClientRequestIds[0]);
const created = await db.general.findFirstOrThrow({ where: { userId } });
expect(created.name).toBe(initialName?.trim());
expect(created.personalCode).toBe('che_안전');
expect(created.specialCode).toMatch(/^che_event_/);
const createEvent = await db.inputEvent.findFirstOrThrow({
where: { actorUserId: userId, eventType: 'selectPoolCreate' },
orderBy: { sequence: 'desc' },
});
expect(createEvent).toMatchObject({ status: 'SUCCEEDED', attempts: 1 });
expect(createEvent.requestId).toMatch(
new RegExp(`^select-pool:${userId}:[0-9a-f-]{36}:create$`)
);
expect(
await page.evaluate(() =>
window.sessionStorage.getItem('sammo-select-pool-pending-action')
)
).toBeNull();
await page.goto('my-page');
const actionLink = page.locator('.select-general-link');
await expect(actionLink).toBeVisible();
await expect(actionLink.locator('..')).toContainText(
/다른 장수 선택\s*\(\d{4}-\d{2}-\d{2}/
);
await expect(actionLink).toHaveCSS('width', '160px');
await expect(actionLink).toHaveCSS('height', '30px');
dialogs.length = 0;
await page.goto('select-general');
await expect.poll(() => dialogs).toContain('실패했습니다: 아직 다시 고를 수 없습니다');
await expect(page.locator('.error-text')).toHaveText('아직 다시 고를 수 없습니다');
const availableAt = '2026-07-29T00:00:00.000Z';
const cooldownRequestId = `select-pool-live-cooldown-${randomUUID()}`;
await db.inputEvent.create({
data: {
requestId: cooldownRequestId,
target: 'ENGINE',
eventType: 'patchGeneral',
payload: {
type: 'patchGeneral',
requestId: cooldownRequestId,
generalId: created.id,
patch: {
meta: {
next_change: availableAt,
nextChangeAt: availableAt,
},
},
} as GamePrisma.InputJsonValue,
},
});
await expect
.poll(
async () =>
(
await db.inputEvent.findUniqueOrThrow({
where: { requestId: cooldownRequestId },
})
).status
)
.toBe('SUCCEEDED');
dialogs.length = 0;
await page.reload();
await waitForPool(page);
const cards = page.locator('.card-holder > .general-card');
const names = await cards.locator('h4').allTextContents();
const targetIndex = names.findIndex((name) => name.trim() !== created.name);
expect(targetIndex).toBeGreaterThanOrEqual(0);
const targetName = names[targetIndex]!.trim();
await cards.nth(targetIndex).locator('.select-button').click();
await expect(page).toHaveURL(/\/hwe\/$/);
await expect.poll(() => dialogs).toContain(`이 장수를 선택할까요? : ${targetName}`);
await expect.poll(() => dialogs).toContain('선택한 장수로 변경했습니다.');
await expect
.poll(async () => (await db.general.findUniqueOrThrow({ where: { id: created.id } })).name)
.toBe(targetName);
const reselectEvent = await db.inputEvent.findFirstOrThrow({
where: { actorUserId: userId, eventType: 'selectPoolReselect' },
orderBy: { sequence: 'desc' },
});
expect(reselectEvent).toMatchObject({ status: 'SUCCEEDED', attempts: 1 });
expect(reselectEvent.requestId).toMatch(
new RegExp(`^select-pool:${userId}:[0-9a-f-]{36}:reselect$`)
);
expect(
await page.evaluate(() =>
window.sessionStorage.getItem('sammo-select-pool-pending-action')
)
).toBeNull();
});
});
+114
View File
@@ -0,0 +1,114 @@
import { expect, test, type Page, type Route } from '@playwright/test';
const response = (data: unknown) => ({ result: { data } });
const operationNames = (route: Route): string[] => {
const url = new URL(route.request().url());
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
};
const publicResponse = (operation: string): unknown => {
if (operation === 'public.getMapLayout') {
return response({ mapName: 'che', cityList: [] });
}
if (operation === 'public.getCachedMap') {
return response({ year: 180, month: 1, cityList: [], nationList: [], history: [] });
}
if (operation === 'public.getWorldTrend') {
return response({ year: 180, month: 1, turnTerm: 5 });
}
if (operation === 'public.getNationList' || operation === 'public.getGeneralList') {
return response([]);
}
throw new Error(`Unhandled public tRPC operation: ${operation}`);
};
const seedGameStorage = async (page: Page, gameToken: string): Promise<void> => {
await page.addInitScript((token) => {
window.localStorage.setItem('sammo-game-token', token);
window.localStorage.setItem('sammo-game-profile', 'che:default');
}, gameToken);
};
test('removes an invalid ga_ token and redirects an authenticated route to public', async ({
page,
}) => {
await seedGameStorage(page, 'ga_invalid');
let gatewayRequests = 0;
await page.route('http://127.0.0.1:15120/api/trpc/**', async (route) => {
gatewayRequests += 1;
await route.abort();
});
await page.route('**/che/api/trpc/**', async (route) => {
const operations = operationNames(route);
if (operations.includes('auth.status')) {
expect(route.request().headers().authorization).toBe('Bearer ga_invalid');
await route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ error: 'invalid token' }),
});
return;
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(operations.map(publicResponse)),
});
});
await page.goto('select-general');
await expect(page).toHaveURL(/\/che\/public$/);
await expect(page.getByRole('heading', { name: '공개 동향' })).toBeVisible();
expect(await page.evaluate(() => window.localStorage.getItem('sammo-game-token'))).toBeNull();
expect(gatewayRequests).toBe(0);
});
test('keeps a valid ga_ token when only lobby.info is unavailable', async ({ page }) => {
await seedGameStorage(page, 'ga_valid');
page.on('dialog', (dialog) => dialog.accept());
let gatewayRequests = 0;
let statusRequests = 0;
let lobbyRequests = 0;
await page.route('http://127.0.0.1:15120/api/trpc/**', async (route) => {
gatewayRequests += 1;
await route.abort();
});
await page.route('**/che/api/trpc/**', async (route) => {
const operations = operationNames(route);
if (operations.includes('auth.status')) {
statusRequests += 1;
expect(route.request().headers().authorization).toBe('Bearer ga_valid');
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([response({ userId: 'valid-user' })]),
});
return;
}
if (operations.includes('lobby.info')) {
lobbyRequests += 1;
await route.fulfill({
status: 503,
contentType: 'application/json',
body: JSON.stringify({ error: 'lobby unavailable' }),
});
return;
}
await route.fulfill({
status: 503,
contentType: 'application/json',
body: JSON.stringify({ error: 'fixture page data unavailable' }),
});
});
await page.goto('select-general');
await expect(page).toHaveURL(/\/che\/select-general$/);
await expect(page.locator('.page-title')).toContainText('장 수 선 택');
expect(await page.evaluate(() => window.localStorage.getItem('sammo-game-token'))).toBe(
'ga_valid'
);
expect(statusRequests).toBe(1);
expect(lobbyRequests).toBe(1);
expect(gatewayRequests).toBe(0);
});
@@ -206,6 +206,21 @@ const officerLevelOptions = [
<span>사기</span>
<input v-model.number="general.atmos" type="number" min="40" :max="options.config.maxAtmosByWar" />
</label>
<label class="field">
<span>내정특기</span>
<select v-model="general.special">
<option :value="null">-</option>
<option
v-for="trait in options.eventDomesticTraits"
:key="trait.key"
:value="trait.key"
>
{{ trait.name }}
</option>
</select>
</label>
</div>
<div class="form-row">
<label class="field">
<span>전특</span>
<select v-model="general.special2">
+9
View File
@@ -3,6 +3,7 @@ import MainView from '../views/MainView.vue';
import PublicView from '../views/PublicView.vue';
import LoginView from '../views/LoginView.vue';
import JoinView from '../views/JoinView.vue';
import SelectGeneralView from '../views/SelectGeneralView.vue';
import InheritView from '../views/InheritView.vue';
import AuctionView from '../views/AuctionView.vue';
import NationCitiesView from '../views/NationCitiesView.vue';
@@ -92,6 +93,14 @@ const routes = [
requiresNoGeneral: true,
},
},
{
path: '/select-general',
name: 'select-general',
component: SelectGeneralView,
meta: {
requiresAuth: true,
},
},
{
path: '/inherit',
name: 'inherit',
+35 -2
View File
@@ -101,7 +101,7 @@ export const useSessionStore = defineStore('session', {
},
async refreshGeneralStatus() {
if (!this.gameToken) {
this.status = 'authed';
this.status = this.sessionToken ? 'authed' : 'public';
return;
}
if (!isAccessToken(this.gameToken)) {
@@ -111,11 +111,44 @@ export const useSessionStore = defineStore('session', {
return;
}
}
try {
await gameTrpc.auth.status.query();
} catch {
this.setGameToken(null);
if (this.sessionToken && this.profile) {
try {
const issued = await gatewayTrpc.auth.issueGameSession.mutate({
sessionToken: this.sessionToken,
profile: this.profile,
});
this.setGameToken(issued.gameToken);
if (await this.exchangeGatewayToken()) {
await gameTrpc.auth.status.query();
} else {
throw new Error('Game token exchange failed.');
}
} catch {
this.setGameToken(null);
this.error = 'game_session_invalid';
this.status = 'public';
return;
}
} else {
this.error = 'game_session_invalid';
this.status = 'public';
return;
}
}
try {
const lobby = await gameTrpc.lobby.info.query();
this.status = lobby.myGeneral ? 'general' : 'authed';
} catch {
this.error = 'game_status_unavailable';
this.error = 'game_lobby_unavailable';
if (this.status === 'unknown' || this.status === 'public') {
this.status = 'authed';
}
}
},
async exchangeGatewayToken(): Promise<boolean> {
@@ -23,6 +23,7 @@ export type GeneralDraft = {
injury: number;
rice: number;
personal: string | null;
special: string | null;
special2: string | null;
crew: number;
crewtype: number;
@@ -57,6 +58,7 @@ export type BattleSimOptions = {
crewTypes: Array<{ id: number; name: string; armType: number }>;
};
nationTypes: Array<{ key: string; name: string; info: string }>;
eventDomesticTraits: Array<{ key: string; name: string; info: string }>;
warTraits: Array<{ key: string; name: string; info: string }>;
personalities: Array<{ key: string; name: string; info: string }>;
items: {
@@ -0,0 +1,22 @@
const KOREA_TIME_OFFSET_MS = 9 * 60 * 60 * 1000;
const pad = (value: number): string => String(value).padStart(2, '0');
export const formatSeoulDateTime = (value: string | Date): string => {
if (
typeof value === 'string' &&
!/(?:Z|[+-]\d{2}:?\d{2})$/i.test(value.trim())
) {
return value.trim().replace('T', ' ').slice(0, 19);
}
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) {
return typeof value === 'string' ? value.slice(0, 19) : '';
}
const koreaTime = new Date(date.getTime() + KOREA_TIME_OFFSET_MS);
return `${koreaTime.getUTCFullYear()}-${pad(koreaTime.getUTCMonth() + 1)}-${pad(
koreaTime.getUTCDate()
)} ${pad(koreaTime.getUTCHours())}:${pad(koreaTime.getUTCMinutes())}:${pad(
koreaTime.getUTCSeconds()
)}`;
};
@@ -134,6 +134,7 @@ const createGeneralDraft = (overrides?: Partial<GeneralDraft>): GeneralDraft =>
injury: 0,
rice: 5000,
personal: null,
special: null,
special2: null,
crew: 7000,
crewtype: baseCrew,
@@ -193,6 +194,7 @@ const applyGeneralExport = (target: GeneralDraft, data: GeneralExport) => {
target.injury = data.injury;
target.rice = data.rice;
target.personal = data.personal;
target.special = data.special;
target.special2 = data.special2;
target.crew = data.crew;
target.crewtype = data.crewtype;
@@ -228,6 +230,7 @@ const toExportedGeneral = (general: GeneralDraft): GeneralExport => ({
injury: general.injury,
rice: general.rice,
personal: general.personal,
special: general.special,
special2: general.special2,
crew: general.crew,
crewtype: general.crewtype,
@@ -401,6 +404,7 @@ const normalizeGeneralExport = (raw: Record<string, unknown>): GeneralExport =>
injury: readNumberValue(raw.injury, 0),
rice: readNumberValue(raw.rice, 0),
personal: readOptionalString(raw.personal),
special: readOptionalString(raw.special),
special2: readOptionalString(raw.special2),
crew: readNumberValue(raw.crew, 0),
crewtype: readNumberValue(raw.crewtype, 0),
@@ -438,6 +442,7 @@ const buildGeneralPayload = (
nation: nationId,
turntime: timestamp,
personal: general.personal,
special: general.special,
special2: general.special2,
crew: general.crew,
crewtype: general.crewtype,
@@ -872,6 +877,7 @@ const applyServerGeneral = async (target: GeneralDraft, generalId: number) => {
injury: response.general.injury,
rice: response.general.rice,
personal: response.general.personal,
special: response.general.special,
special2: response.general.special2,
crew: response.general.crew,
crewtype: response.general.crewtype,
+4
View File
@@ -194,6 +194,10 @@ const loadConfig = async () => {
error.value = null;
try {
const config = await trpc.join.getConfig.query();
if (config.selectionPool.enabled) {
await router.replace({ name: 'select-general' });
return;
}
joinConfig.value = config;
form.value.name = config.user.displayName || '';
applyBalancedStats();
+33 -1
View File
@@ -2,6 +2,7 @@
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { trpc } from '../utils/trpc';
import { formatLog } from '../utils/formatLog';
import { formatSeoulDateTime } from '../utils/legacyDateTime';
import { isDefenceTrainPenaltyWaivedByScenarioEffect } from '@sammo-ts/logic';
const SCREEN_MODE_KEY = 'sam.screenMode';
@@ -10,6 +11,7 @@ type ScreenMode = 'auto' | '500px' | '1000px';
type LogType = 'generalHistory' | 'battleDetail' | 'battleResult' | 'generalAction';
type ItemSlotKey = 'horse' | 'weapon' | 'book' | 'item';
type MyGeneralResponse = Awaited<ReturnType<typeof trpc.general.me.query>>;
type SelectionPoolStatus = Awaited<ReturnType<typeof trpc.join.getConfig.query>>['selectionPool'];
type WorldSnapshot = {
currentYear: number;
@@ -28,6 +30,7 @@ type SettingForm = {
const data = ref<MyGeneralResponse | null>(null);
const world = ref<WorldSnapshot>(null);
const selectionPoolStatus = ref<SelectionPoolStatus | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
const screenMode = ref<ScreenMode>('auto');
@@ -130,6 +133,11 @@ const actionAvailability = computed(() => {
selectOtherGeneral: Boolean(npcMode === 2 && general?.npcState === 0),
};
});
const formatSelectionAvailableAt = computed(() => {
const value = selectionPoolStatus.value?.nextChangeAt;
if (!value) return '';
return formatSeoulDateTime(value);
});
const applyCustomCss = (text: string) => {
let style = document.getElementById('sammo-custom-css') as HTMLStyleElement | null;
@@ -161,12 +169,14 @@ const loadPage = async () => {
loading.value = true;
error.value = null;
try {
const [general, state] = await Promise.all([
const [general, state, joinConfig] = await Promise.all([
trpc.general.me.query(),
trpc.world.getState.query() as Promise<WorldSnapshot>,
trpc.join.getConfig.query(),
]);
data.value = general;
world.value = state;
selectionPoolStatus.value = joinConfig.selectionPool;
if (general) {
Object.assign(form, general.settings);
}
@@ -399,6 +409,20 @@ onMounted(() => {
접경 귀환
</button>
</div>
<div
v-if="actionAvailability.selectOtherGeneral && selectionPoolStatus?.enabled"
class="action-line"
>
다른 장수 선택
<template v-if="formatSelectionAvailableAt">
({{ formatSelectionAvailableAt }} 부터)
</template>
<br />
<RouterLink class="action-button select-general-link" to="/select-general">
다른 장수 선택
</RouterLink>
<br /><br />
</div>
<div class="screen-mode-row">
<span>500px/1000px 모드<br />(모바일 전용, 즉시 설정)</span>
@@ -612,6 +636,14 @@ dt {
margin: 4px 0;
background: #225500;
}
.select-general-link {
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
color: #fff;
text-decoration: none;
}
.action-line {
margin: 12px 0;
}
@@ -0,0 +1,728 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useSessionStore } from '../stores/session';
import { formatSeoulDateTime } from '../utils/legacyDateTime';
import { trpc } from '../utils/trpc';
type JoinConfig = Awaited<ReturnType<typeof trpc.join.getConfig.query>>;
type Reservation = Awaited<ReturnType<typeof trpc.join.getSelectionPool.mutate>>;
type Candidate = Reservation['candidates'][number];
type Nation = JoinConfig['nations'][number];
type PendingSelectionAction = {
operation: 'create' | 'reselect';
uniqueName: string;
personality?: string;
clientRequestId: string;
};
const router = useRouter();
const session = useSessionStore();
const config = ref<JoinConfig | null>(null);
const reservation = ref<Reservation | null>(null);
const selectedUniqueName = ref<string | null>(null);
const nations = ref<Nation[]>([]);
const personality = ref('Random');
const loading = ref(true);
const submitting = ref(false);
const error = ref('');
const now = ref(Date.now());
let timer: number | null = null;
const pendingActionStorageKey = 'sammo-select-pool-pending-action';
const candidates = computed(() => reservation.value?.candidates ?? []);
const selectedCandidate = computed(
() => candidates.value.find((candidate) => candidate.uniqueName === selectedUniqueName.value) ?? null
);
const hasGeneral = computed(() => reservation.value?.hasGeneral ?? config.value?.selectionPool.hasGeneral ?? false);
const allowPersonality = computed(() => config.value?.selectionPool.allowOptions.includes('ego') ?? false);
const personalities = computed(() => config.value?.personalities ?? []);
const serverInfo = computed(() => config.value?.serverInfo ?? null);
const validUntil = computed(() => {
const value = reservation.value?.validUntil;
return value ? new Date(value).getTime() : 0;
});
const expired = computed(() => validUntil.value > 0 && now.value > validUntil.value);
const validUntilColor = computed(() => {
const remaining = validUntil.value - now.value;
if (remaining <= 0 || remaining > 30_000) {
return '#fff';
}
const channel = Math.max(0, Math.round((255 * remaining) / 30_000));
return `rgb(255, ${channel}, ${channel})`;
});
const errorText = (value: unknown): string =>
value instanceof Error ? value.message : typeof value === 'string' ? value : 'unknown_error';
const readPendingAction = (): PendingSelectionAction | null => {
try {
const raw = window.sessionStorage.getItem(pendingActionStorageKey);
if (!raw) return null;
const value = JSON.parse(raw) as Partial<PendingSelectionAction>;
if (
(value.operation !== 'create' && value.operation !== 'reselect') ||
typeof value.uniqueName !== 'string' ||
typeof value.clientRequestId !== 'string'
) {
return null;
}
return value as PendingSelectionAction;
} catch {
return null;
}
};
const getPendingAction = (
operation: PendingSelectionAction['operation'],
uniqueName: string,
requestedPersonality?: string
): PendingSelectionAction => {
const current = readPendingAction();
if (
current?.operation === operation &&
current.uniqueName === uniqueName &&
current.personality === requestedPersonality
) {
return current;
}
const next: PendingSelectionAction = {
operation,
uniqueName,
...(requestedPersonality ? { personality: requestedPersonality } : {}),
clientRequestId: crypto.randomUUID(),
};
window.sessionStorage.setItem(pendingActionStorageKey, JSON.stringify(next));
return next;
};
const clearPendingAction = (action: PendingSelectionAction): void => {
if (readPendingAction()?.clientRequestId === action.clientRequestId) {
window.sessionStorage.removeItem(pendingActionStorageKey);
}
};
const isIndeterminateTimeout = (value: unknown): boolean => {
if (!value || typeof value !== 'object' || !('data' in value)) return false;
const data = value.data;
return Boolean(
data &&
typeof data === 'object' &&
'code' in data &&
data.code === 'TIMEOUT'
);
};
const formatDateTime = (value: string | null | undefined): string => {
if (!value) return '';
return formatSeoulDateTime(value);
};
const shuffleNations = (source: Nation[]): Nation[] => {
const shuffled = [...source];
for (let index = shuffled.length - 1; index > 0; index -= 1) {
const random = new Uint32Array(1);
crypto.getRandomValues(random);
const swapIndex = random[0]! % (index + 1);
[shuffled[index], shuffled[swapIndex]] = [shuffled[swapIndex]!, shuffled[index]!];
}
return shuffled;
};
const userIconBaseUrl =
import.meta.env.VITE_GATEWAY_USER_ICON_BASE_URL ?? '/gateway/api/user-icons';
const imageUrl = (candidate: Candidate): string =>
candidate.imageServer
? `${userIconBaseUrl.replace(/\/$/, '')}/${candidate.picture}`
: `/image/icons/${candidate.picture}`;
const useFallbackImage = (event: Event): void => {
const image = event.currentTarget as HTMLImageElement;
if (image.dataset.fallbackApplied === 'true') return;
image.dataset.fallbackApplied = 'true';
image.src = '/image/icons/default.jpg';
};
const personalityName = (key: string | null): string | null => {
if (!key) return null;
return personalities.value.find((entry) => entry.key === key)?.name ?? key;
};
const personalityInfo = (key: string | null): string =>
key ? personalities.value.find((entry) => entry.key === key)?.info ?? '' : '';
const lightTextNationColors = new Set([
'',
'#330000',
'#FF0000',
'#800000',
'#A0522D',
'#FF6347',
'#808000',
'#008000',
'#2E8B57',
'#008080',
'#6495ED',
'#0000FF',
'#000080',
'#483D8B',
'#7B68EE',
'#800080',
'#A9A9A9',
'#000000',
]);
const nationTextColor = (color: string): string =>
lightTextNationColors.has(color.toUpperCase()) ? '#FFFFFF' : '#000000';
const selectCandidate = async (candidate: Candidate): Promise<void> => {
if (!hasGeneral.value) {
selectedUniqueName.value = candidate.uniqueName;
return;
}
if (!confirm(`이 장수를 선택할까요? : ${candidate.generalName}`)) {
return;
}
submitting.value = true;
const pending = getPendingAction('reselect', candidate.uniqueName);
try {
await trpc.join.reselectPoolGeneral.mutate({
uniqueName: candidate.uniqueName,
clientRequestId: pending.clientRequestId,
});
clearPendingAction(pending);
alert('선택한 장수로 변경했습니다.');
await session.refreshGeneralStatus();
await router.push('/');
} catch (cause) {
console.error(cause);
if (!isIndeterminateTimeout(cause)) {
clearPendingAction(pending);
}
alert(`실패했습니다: ${errorText(cause)}`);
await loadPage();
} finally {
submitting.value = false;
}
};
const createGeneral = async (): Promise<void> => {
const candidate = selectedCandidate.value;
if (!candidate) {
alert('장수를 선택해주세요!');
return;
}
if (!confirm('이 장수로 생성할까요?')) {
return;
}
submitting.value = true;
const pending = getPendingAction('create', candidate.uniqueName, personality.value);
try {
await trpc.join.selectPoolGeneral.mutate({
uniqueName: candidate.uniqueName,
personality: personality.value,
clientRequestId: pending.clientRequestId,
});
clearPendingAction(pending);
alert('선택한 장수로 생성했습니다.');
await session.refreshGeneralStatus();
await router.push('/');
} catch (cause) {
console.error(cause);
if (!isIndeterminateTimeout(cause)) {
clearPendingAction(pending);
}
alert(`실패했습니다: ${errorText(cause)}`);
await loadPage();
} finally {
submitting.value = false;
}
};
async function loadPage(): Promise<void> {
loading.value = true;
error.value = '';
selectedUniqueName.value = null;
try {
const nextConfig = await trpc.join.getConfig.query();
config.value = nextConfig;
nations.value = shuffleNations(nextConfig.nations);
if (!nextConfig.selectionPool.enabled) {
await router.replace(nextConfig.selectionPool.hasGeneral ? '/' : '/join');
return;
}
reservation.value = await trpc.join.getSelectionPool.mutate();
now.value = Date.now();
} catch (cause) {
console.error(cause);
error.value = errorText(cause);
alert(`실패했습니다: ${error.value}`);
} finally {
loading.value = false;
}
}
const goBack = (): void => {
if (window.history.length > 1) {
router.back();
return;
}
void router.push(hasGeneral.value ? '/' : '/join');
};
onMounted(() => {
timer = window.setInterval(() => {
now.value = Date.now();
}, 1_000);
void loadPage();
});
onBeforeUnmount(() => {
if (timer !== null) {
window.clearInterval(timer);
}
});
</script>
<template>
<main class="select-pool-page legacy-bg0">
<header class="page-title with-border">
<br />
<button class="legacy-button" type="button" @click="goBack">돌아가기</button>
</header>
<table v-if="serverInfo" class="server-info-table legacy-bg0">
<tbody>
<tr>
<td>
현재 : {{ serverInfo.currentYear }} {{ serverInfo.currentMonth }}
(<span class="cyan">{{ serverInfo.tickMinutes }} </span> 서버)<br />
등록 장수 : 유저 {{ serverInfo.userGeneralCount }} / {{ serverInfo.maxGeneral }} +
<span class="cyan">NPC {{ serverInfo.npcGeneralCount }} </span>
</td>
</tr>
</tbody>
</table>
<table class="invitation-table legacy-bg0">
<thead>
<tr>
<td colspan="2" class="legacy-bg1">임관 권유 메시지</td>
</tr>
</thead>
<tbody>
<tr
v-for="nation in nations"
:key="nation.id"
:style="{
color: nationTextColor(nation.color),
backgroundColor: nation.color,
}"
>
<td class="invitation-nation">{{ nation.name }}</td>
<td><div class="invitation-message">{{ nation.scoutMessage ?? '-' }}</div></td>
</tr>
</tbody>
</table>
<section class="selection-section">
<h1 class="section-title legacy-bg1 with-border">장수 선택</h1>
<div class="selection-body with-border">
<div v-if="loading">불러오는 중...</div>
<div v-else-if="error" class="error-text">{{ error }}</div>
<template v-else-if="reservation">
<small v-if="!expired">
(<span :style="{ color: validUntilColor }">{{ formatDateTime(reservation.validUntil) }}</span
>까지 유효)
</small>
<small v-else class="expired-text">- 만료 -</small>
<br />
<div class="card-holder">
<article
v-for="candidate in candidates"
:key="candidate.uniqueName"
class="general-card"
>
<h4 class="legacy-bg1 with-border">{{ candidate.generalName }}</h4>
<h4 class="portrait">
<img
:src="imageUrl(candidate)"
:alt="candidate.generalName"
width="64"
height="64"
@error="useFallbackImage"
/>
</h4>
<p>
{{ candidate.leadership }} / {{ candidate.strength }} / {{ candidate.intel }}<br />
<span v-if="candidate.ego" class="trait-tooltip" tabindex="0">
{{ personalityName(candidate.ego) }}
<span role="tooltip">{{ personalityInfo(candidate.ego) }}</span>
</span>
<br v-if="candidate.ego" />
<span class="trait-tooltip" tabindex="0">
{{ candidate.specialDomesticName }}
<span role="tooltip">{{ candidate.specialDomesticInfo }}</span>
</span>
/
<span>{{ candidate.specialWar ?? '-' }}</span
><br /><br />
보병: {{ Math.trunc(candidate.dex[0] / 1000) }}K<br />
궁병: {{ Math.trunc(candidate.dex[1] / 1000) }}K<br />
기병: {{ Math.trunc(candidate.dex[2] / 1000) }}K<br />
귀병: {{ Math.trunc(candidate.dex[3] / 1000) }}K<br />
차병: {{ Math.trunc(candidate.dex[4] / 1000) }}K<br />
</p>
<button
class="select-button with-border"
type="button"
:disabled="submitting"
@click="selectCandidate(candidate)"
>
선택하기
</button>
</article>
</div>
</template>
</div>
</section>
<section v-if="reservation && !hasGeneral" class="create-section">
<h1 class="section-title legacy-bg1 with-border">장수 생성</h1>
<div class="create-body with-border">
<div id="left-pad">
<article v-if="selectedCandidate" class="general-card selected-card">
<h4 class="legacy-bg1 with-border">{{ selectedCandidate.generalName }}</h4>
<h4 class="portrait">
<img
:src="imageUrl(selectedCandidate)"
:alt="selectedCandidate.generalName"
width="64"
height="64"
@error="useFallbackImage"
/>
</h4>
<p>
{{ selectedCandidate.leadership }} / {{ selectedCandidate.strength }} /
{{ selectedCandidate.intel }}<br />
<span v-if="selectedCandidate.ego" class="trait-tooltip" tabindex="0">
{{ personalityName(selectedCandidate.ego) }}
<span role="tooltip">{{ personalityInfo(selectedCandidate.ego) }}</span>
</span>
<br v-if="selectedCandidate.ego" />
<span class="trait-tooltip" tabindex="0">
{{ selectedCandidate.specialDomesticName }}
<span role="tooltip">{{ selectedCandidate.specialDomesticInfo }}</span>
</span>
/
<span>{{ selectedCandidate.specialWar ?? '-' }}</span
><br /><br />
보병: {{ Math.trunc(selectedCandidate.dex[0] / 1000) }}K<br />
궁병: {{ Math.trunc(selectedCandidate.dex[1] / 1000) }}K<br />
기병: {{ Math.trunc(selectedCandidate.dex[2] / 1000) }}K<br />
귀병: {{ Math.trunc(selectedCandidate.dex[3] / 1000) }}K<br />
차병: {{ Math.trunc(selectedCandidate.dex[4] / 1000) }}K<br />
</p>
<button class="select-button with-border" type="button">선택하기</button>
</article>
<template v-else>장수를<br />선택해주세요!</template>
</div>
<form class="custom-form" @submit.prevent="createGeneral">
<table>
<tbody>
<tr v-if="allowPersonality">
<th class="legacy-bg1">성격</th>
<td>
<select v-model="personality">
<option value="Random">????</option>
<option
v-for="entry in personalities.filter((item) => item.key !== 'Random')"
:key="entry.key"
:value="entry.key"
>
{{ entry.name }}
</option>
</select>
<span>
{{ personalities.find((entry) => entry.key === personality)?.info ?? '' }}
</span>
</td>
</tr>
<tr>
<td colspan="2" class="join-guidance">
임의의 도시에서 재야로 시작하며 건국과 임관은 게임 내에서 실행합니다.
</td>
</tr>
<tr>
<td class="create-action">
<button
id="build-general"
class="legacy-button"
type="submit"
:disabled="submitting"
>
장수생성
</button>
</td>
<td>
<button
class="legacy-button"
type="reset"
:disabled="submitting"
@click="personality = 'Random'"
>
다시입력
</button>
</td>
</tr>
</tbody>
</table>
</form>
</div>
</section>
<footer class="page-footer">
<div class="footer-back with-border">
<button class="legacy-button" type="button" @click="goBack">돌아가기</button>
</div>
<div class="footer-banner with-border">
<small>
삼국지 모의전투 HiDCHe core2026 / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
HideD /
<a
href="https://sam.hided.net/wiki/hidche/credit"
target="_blank"
rel="noopener noreferrer"
>Credit</a
>
</small>
</div>
</footer>
</main>
</template>
<style scoped>
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/static/pretendard.css');
.select-pool-page {
width: 1000px;
min-width: 1000px;
margin: 8px auto 0;
color: #fff;
line-height: 1.3;
text-align: center;
overflow: visible;
}
.with-border {
border: solid 1px;
border-top-color: gray;
border-left-color: gray;
border-right-color: #000;
border-bottom-color: #000;
}
.page-title {
padding: 0;
text-align: left;
}
.page-title .legacy-button,
.footer-back .legacy-button {
padding: 1px 6px;
font-weight: 400;
line-height: 1.3;
}
.server-info-table,
.invitation-table {
border: 1px solid;
border-collapse: collapse;
border-top-color: gray;
border-left-color: gray;
border-right-color: #000;
border-bottom-color: #000;
border-spacing: 0;
font-size: 14px;
text-align: center;
word-break: break-all;
}
.server-info-table {
width: 100%;
}
.server-info-table td,
.invitation-table td {
border: 1px solid gray;
padding: 0;
}
.server-info-table td {
padding: 1px 0;
}
.invitation-table {
margin: 0 auto;
}
.invitation-nation {
width: 130px;
}
.invitation-message {
width: 870px;
max-width: 870px;
max-height: 200px;
overflow: hidden;
}
.cyan {
color: cyan;
}
.selection-section,
.create-section {
margin: 0;
}
.section-title {
margin: 0;
padding: 0;
color: inherit;
font-size: 14px;
font-weight: 700;
line-height: 18.2px;
}
.selection-body {
padding: 0;
}
.card-holder {
text-align: center;
white-space: normal;
}
.general-card {
width: 125px;
display: inline-block;
box-sizing: content-box;
border: solid 1px;
border-top-color: gray;
border-left-color: gray;
border-right-color: #000;
border-bottom-color: #000;
vertical-align: top;
}
.general-card h4,
.general-card p {
margin: 0;
}
.general-card h4 {
padding: 0;
}
.portrait {
text-align: center;
}
.portrait img {
display: inline;
width: 64px;
height: 64px;
vertical-align: baseline;
object-fit: fill;
}
.select-button {
width: 100%;
height: 19px;
padding: 0 4px;
border-radius: 0;
background: #191919;
color: #fff;
line-height: normal;
}
.expired-text,
.error-text {
color: red;
}
.create-section {
margin-top: 10px;
}
.create-body {
display: flex;
text-align: left;
}
#left-pad {
flex: 1;
padding-top: 8px;
text-align: center;
}
.selected-card .select-button {
display: none;
}
.custom-form {
flex: 4;
}
.custom-form table {
width: 100%;
border-collapse: collapse;
}
.custom-form th,
.custom-form td {
padding: 0;
text-align: left;
}
.custom-form th {
width: 200px;
text-align: right;
}
.custom-form select {
color: #fff;
background: #000;
}
.custom-form .legacy-button {
padding: 3px 6px;
font-weight: 400;
}
.join-guidance {
text-align: center;
}
.create-action {
width: 200px;
text-align: right;
}
.footer-back,
.footer-banner {
text-align: left;
}
.footer-banner a {
color: #fff;
text-decoration: underline;
}
button:disabled {
cursor: default;
opacity: 0.55;
}
.select-button:disabled {
background: #333;
}
.select-button:focus-visible,
.custom-form select:focus-visible,
.trait-tooltip:focus-visible {
outline: auto 1px;
outline-offset: 0;
}
.trait-tooltip {
position: relative;
cursor: help;
}
.trait-tooltip [role='tooltip'] {
display: none;
position: absolute;
z-index: 20;
left: 50%;
bottom: calc(100% + 4px);
width: 220px;
padding: 5px 7px;
transform: translateX(-50%);
border: 1px solid #888;
background: #202020;
color: #fff;
text-align: left;
white-space: normal;
word-break: keep-all;
}
.trait-tooltip:hover [role='tooltip'],
.trait-tooltip:focus [role='tooltip'] {
display: block;
}
@media (max-width: 1000px) {
.select-pool-page {
margin-left: 8px;
margin-right: 0;
}
}
</style>
@@ -45,6 +45,7 @@ export class InMemoryGatewaySessionService implements GatewaySessionService {
sanctions: user.sanctions,
createdAt: user.createdAt,
issuedAt: new Date().toISOString(),
legacyMemberNo: user.legacyMemberNo,
};
this.sessions.set(sessionToken, {
info,
@@ -99,6 +100,7 @@ export class InMemoryGatewaySessionService implements GatewaySessionService {
sanctions: session.sanctions,
createdAt: session.createdAt,
issuedAt: new Date().toISOString(),
legacyMemberNo: session.legacyMemberNo,
};
const key = buildGameKey(profile, gameToken);
this.gameSessions.set(key, {
@@ -17,6 +17,14 @@ const readObject = <T extends object>(value: unknown, fallback: T): T => {
return value as T;
};
const readLegacyMemberNo = (value: unknown): number | undefined => {
const legacyData = readObject<Record<string, unknown>>(value, {});
const memberNo = legacyData.memberNo;
return typeof memberNo === 'number' && Number.isSafeInteger(memberNo) && memberNo > 0
? memberNo
: undefined;
};
const mapUser = (row: {
id: string;
loginId: string;
@@ -39,6 +47,7 @@ const mapUser = (row: {
kakaoGraceStartedAt: Date;
deleteAfter: Date | null;
createdAt: Date;
legacyData: GatewayPrisma.JsonValue;
}): UserRecord => ({
id: row.id,
username: row.loginId,
@@ -61,6 +70,7 @@ const mapUser = (row: {
passwordHash: row.passwordHash,
passwordSalt: row.passwordSalt,
createdAt: row.createdAt.toISOString(),
legacyMemberNo: readLegacyMemberNo(row.legacyData),
});
export const createPostgresUserRepository = (
@@ -56,6 +56,7 @@ export class RedisGatewaySessionService implements GatewaySessionService {
sanctions: user.sanctions,
createdAt: user.createdAt,
issuedAt: new Date().toISOString(),
legacyMemberNo: user.legacyMemberNo,
};
await this.client.set(this.keys.sessionKey(sessionToken), JSON.stringify(info), {
EX: this.sessionTtlSeconds,
@@ -108,6 +109,7 @@ export class RedisGatewaySessionService implements GatewaySessionService {
sanctions: session.sanctions,
createdAt: session.createdAt,
issuedAt: new Date().toISOString(),
legacyMemberNo: session.legacyMemberNo,
};
const gameKey = this.keys.gameSessionKey(profile, gameToken);
const gameSetKey = this.keys.sessionGameSetKey(sessionToken);
@@ -9,6 +9,7 @@ export interface GatewaySessionInfo {
sanctions: UserSanctions;
createdAt: string;
issuedAt: string;
legacyMemberNo?: number;
}
export interface GameSessionInfo {
@@ -22,6 +23,7 @@ export interface GameSessionInfo {
sanctions: UserSanctions;
createdAt: string;
issuedAt: string;
legacyMemberNo?: number;
}
export interface GatewaySessionConfig {
@@ -20,6 +20,7 @@ export interface UserRecord {
passwordHash: string;
passwordSalt: string;
createdAt: string;
legacyMemberNo?: number;
}
export interface PublicUser {
+1
View File
@@ -672,6 +672,7 @@ export const appRouter = router({
displayName: gameSession.displayName,
roles: gameSession.roles,
createdAt: gameSession.createdAt,
legacyMemberNo: gameSession.legacyMemberNo,
},
sanctions: gameSession.sanctions,
identity: {
+28
View File
@@ -516,6 +516,34 @@ describe('gateway auth flow', () => {
expect(validated?.user.username).toBe('tester');
});
it('keeps the migrated member number inside the encrypted game identity', async () => {
const { caller, users, sessions } = buildCaller();
const user = await users.createUser({
username: 'legacy-seed-user',
password: 'secretpass',
});
user.legacyMemberNo = 42;
const session = await sessions.createSession(user);
const issued = await caller.auth.issueGameSession({
sessionToken: session.sessionToken,
profile: 'che:default',
});
const payload = decryptGameSessionToken(issued.gameToken, 'test-secret');
expect(payload?.user.legacyMemberNo).toBe(42);
const validated = await caller.auth.validateGameSession({
profile: 'che:default',
gameToken: issued.gameToken,
});
expect(validated).toMatchObject({
user: {
id: user.id,
},
});
expect(validated?.user).not.toHaveProperty('legacyMemberNo');
});
it('revokes the gateway session and every linked game session on logout', async () => {
const { caller, users, sessions } = buildCaller();
const user = await users.createUser({
@@ -0,0 +1,159 @@
import { expect, test, type Page, type Route } from '@playwright/test';
const response = (data: unknown) => ({ result: { data } });
const operationNames = (route: Route): string[] => {
const url = new URL(route.request().url());
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
};
const fulfillTrpc = async (route: Route, results: unknown[]): Promise<void> => {
await route.fulfill({
status: 200,
contentType: 'application/json',
headers: {
'access-control-allow-origin': '*',
},
body: JSON.stringify(results),
});
};
const installFixture = async (page: Page) => {
const gameOperations: Array<{ operation: string; authorization: string | undefined }> = [];
await page.addInitScript(() => {
window.localStorage.setItem('sammo-session-token', 'gateway-lobby-session');
});
await page.route('http://127.0.0.1:15130/api/trpc/**', async (route) => {
const results = operationNames(route).map((operation) => {
if (operation === 'me') {
return response({
id: 'lobby-user',
username: 'lobby-user',
displayName: '로비사용자',
roles: ['user'],
kakaoVerified: true,
createdAt: '2026-07-30T00:00:00.000Z',
});
}
if (operation === 'lobby.notice') {
return response('');
}
if (operation === 'lobby.profiles') {
return response([
{
profileName: 'hwe:903',
profile: 'hwe',
scenario: '903',
status: 'RUNNING',
apiPort: 15015,
runtime: {
apiRunning: true,
daemonRunning: true,
auctionRunning: true,
battleSimRunning: true,
tournamentRunning: true,
},
korName: 'hwe',
color: '#ffffff',
localAccountPolicy: {
accessAllowed: true,
canCreateGeneral: true,
requiresKakaoVerification: false,
graceEndsAt: null,
},
},
]);
}
if (operation === 'auth.issueGameSession') {
return response({
profile: 'hwe:903',
gameToken: 'encrypted-gateway-game-token',
expiresAt: '2026-07-30T01:00:00.000Z',
});
}
throw new Error(`Unhandled gateway tRPC operation: ${operation}`);
});
await fulfillTrpc(route, results);
});
await page.route('http://localhost:15015/api/trpc/**', async (route) => {
const authorization = route.request().headers().authorization;
const results = operationNames(route).map((operation) => {
gameOperations.push({ operation, authorization });
if (operation === 'auth.exchangeGatewayToken') {
return response({
accessToken: 'ga_lobby-access-token',
profile: 'hwe:903',
expiresAt: '2026-07-30T01:00:00.000Z',
});
}
if (operation === 'lobby.info') {
return response({
year: 180,
month: 1,
userCnt: 1,
maxUserCnt: 500,
npcCnt: 0,
nationCnt: 0,
turnTerm: 5,
fictionMode: '가상',
starttime: '2026-07-30 00:00:00',
opentime: '2026-07-30 00:00:00',
turntime: '2026-07-30 00:05:00',
otherTextInfo: '',
isUnited: 0,
selectionPoolEnabled: true,
myGeneral: {
name: '선택장수',
picture: 'account-hash.png',
imageServer: 1,
},
});
}
if (operation === 'public.getMapLayout') {
return response({ mapName: 'che', cityList: [] });
}
if (operation === 'public.getCachedMap') {
return response({ year: 180, month: 1, cityList: [], nationList: [] });
}
throw new Error(`Unhandled game tRPC operation: ${operation}`);
});
await fulfillTrpc(route, results);
});
await page.route('**/gateway/api/user-icons/account-hash.png', async (route) => {
await route.fulfill({
status: 200,
contentType: 'image/png',
body: Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64'
),
});
});
return gameOperations;
};
test('exchanges the gateway token before loading authenticated lobby general data', async ({
page,
}) => {
const gameOperations = await installFixture(page);
await page.goto('lobby');
const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' });
await expect(row).toContainText('선택장수');
await expect(row.getByRole('button', { name: '입장' })).toBeVisible();
const portrait = row.locator('img');
await expect(portrait).toHaveAttribute(
'src',
'/gateway/api/user-icons/account-hash.png'
);
await expect.poll(() => portrait.evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(1);
expect(gameOperations.find(({ operation }) => operation === 'auth.exchangeGatewayToken')).toEqual({
operation: 'auth.exchangeGatewayToken',
authorization: undefined,
});
expect(gameOperations.find(({ operation }) => operation === 'lobby.info')).toEqual({
operation: 'lobby.info',
authorization: 'Bearer ga_lobby-access-token',
});
});
@@ -10,6 +10,7 @@ export default defineConfig({
'server-operations.spec.ts',
'admin-runtime-actions.spec.ts',
'lobby-admin-navigation.spec.ts',
'lobby-game-auth.spec.ts',
'logout.spec.ts',
],
fullyParallel: false,
+2 -1
View File
@@ -6,7 +6,7 @@ export type GameRouter = typeof appRouter;
const resolveProfileUrl = (template: string, profile: string): string =>
template.replaceAll('{profile}', encodeURIComponent(profile));
export const createGameTrpc = (profile: string, port: number) => {
export const createGameTrpc = (profile: string, port: number, gameToken?: string) => {
const urlTemplate = import.meta.env.VITE_GAME_API_URL_TEMPLATE;
const url = urlTemplate
? resolveProfileUrl(urlTemplate, profile)
@@ -15,6 +15,7 @@ export const createGameTrpc = (profile: string, port: number) => {
links: [
httpBatchLink({
url,
headers: gameToken ? { authorization: `Bearer ${gameToken}` } : undefined,
}),
],
});
+54 -5
View File
@@ -14,6 +14,7 @@ type GameRouterOutput = inferRouterOutputs<GameRouter>;
type MeOutput = GatewayRouterOutput['me'];
type LobbyProfile = GatewayRouterOutput['lobby']['profiles'][number];
type LobbyInfo = GameRouterOutput['lobby']['info'];
type LobbyGeneral = NonNullable<LobbyInfo['myGeneral']>;
type PublicMap = GameRouterOutput['public']['getCachedMap'];
type PublicMapLayout = GameRouterOutput['public']['getMapLayout'];
type MapPreviewBundle = {
@@ -38,9 +39,25 @@ const canAccessAdmin = computed(
) ?? false
);
const needsKakaoVerification = computed(() => me.value !== null && !me.value.kakaoVerified);
const userIconBaseUrl =
import.meta.env.VITE_GATEWAY_USER_ICON_BASE_URL ?? '/gateway/api/user-icons';
const formatGraceEndsAt = (value: string | null | undefined): string =>
value ? new Date(value).toLocaleString('ko-KR') : '';
const resolveGeneralPicture = (general: LobbyGeneral): string => {
const picture = general.picture?.trim() || 'default.jpg';
return general.imageServer
? `${userIconBaseUrl.replace(/\/$/, '')}/${encodeURIComponent(picture)}`
: `/image/icons/${encodeURIComponent(picture)}`;
};
const handleGeneralPictureError = (event: Event): void => {
const image = event.currentTarget as HTMLImageElement;
if (image.dataset.fallbackApplied === 'true') {
return;
}
image.dataset.fallbackApplied = 'true';
image.src = '/image/icons/default.jpg';
};
onMounted(async () => {
try {
@@ -52,12 +69,31 @@ onMounted(async () => {
notice.value = await trpc.lobby.notice.query();
profiles.value = await trpc.lobby.profiles.query();
const sessionToken = window.localStorage.getItem('sammo-session-token');
const detailTasks = profiles.value.map(async (profile) => {
if (profile.status !== 'RUNNING' && profile.status !== 'PREOPEN') {
return;
}
const gameTrpc = createGameTrpc(profile.profile, profile.apiPort);
const publicGameTrpc = createGameTrpc(profile.profile, profile.apiPort);
let gameToken: string | undefined;
if (sessionToken) {
try {
const issued = await trpc.auth.issueGameSession.mutate({
sessionToken,
profile: profile.profileName,
});
const exchanged = await publicGameTrpc.auth.exchangeGatewayToken.mutate({
gatewayToken: issued.gameToken,
});
gameToken = exchanged.accessToken;
} catch (error) {
console.error(`Failed to authenticate lobby game session for ${profile.profileName}`, error);
}
}
const gameTrpc = gameToken
? createGameTrpc(profile.profile, profile.apiPort, gameToken)
: publicGameTrpc;
const [infoResult, layoutResult, mapResult] = await Promise.allSettled([
gameTrpc.lobby.info.query(),
gameTrpc.public.getMapLayout.query(),
@@ -69,7 +105,6 @@ onMounted(async () => {
} else {
console.error(`Failed to fetch info for ${profile.profileName}`, infoResult.reason);
}
if (layoutResult.status === 'fulfilled' && mapResult.status === 'fulfilled') {
profileMapPreviews.value[profile.profileName] = {
mapLayout: layoutResult.value,
@@ -302,8 +337,13 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
class="w-12 h-12 mx-auto bg-zinc-800 rounded overflow-hidden border border-zinc-700"
>
<img
:src="profileDetails[profile.profileName]?.myGeneral?.picture ?? undefined"
:src="
resolveGeneralPicture(
profileDetails[profile.profileName]!.myGeneral!
)
"
class="w-full h-full object-cover"
@error="handleGeneralPictureError"
/>
</div>
</td>
@@ -332,12 +372,21 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
entryLoading[profile.profileName] ||
profile.localAccountPolicy?.canCreateGeneral === false
"
@click="handleEnter(profile, '/join')"
@click="
handleEnter(
profile,
profileDetails[profile.profileName]?.selectionPoolEnabled
? '/select-general'
: '/join'
)
"
>
{{
profile.localAccountPolicy?.canCreateGeneral === false
? '인증 필요'
: '장수생성'
: profileDetails[profile.profileName]?.selectionPoolEnabled
? '장수선택'
: '장수생성'
}}
</button>
</template>