feat: port scenario 903 select-pool flow
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface BattleSimGeneralPayload {
|
||||
nation: number;
|
||||
turntime: string;
|
||||
personal: string | null;
|
||||
special?: string | null;
|
||||
special2: string | null;
|
||||
crew: number;
|
||||
crewtype: number;
|
||||
|
||||
@@ -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())));
|
||||
}
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export {
|
||||
buildSelectPoolSeed,
|
||||
claimWeightedSelectionCandidates,
|
||||
getSelectionPoolStatus,
|
||||
isSelectionPoolWorld,
|
||||
reserveSelectionPool,
|
||||
resolveSelectionMaxGeneral,
|
||||
SelectPoolError,
|
||||
type SelectPoolCandidateDto,
|
||||
type SelectPoolCandidateInfo,
|
||||
type SelectPoolReservationDto,
|
||||
} from '@sammo-ts/game-engine';
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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' }>),
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -672,6 +672,7 @@ export const appRouter = router({
|
||||
displayName: gameSession.displayName,
|
||||
roles: gameSession.roles,
|
||||
createdAt: gameSession.createdAt,
|
||||
legacyMemberNo: gameSession.legacyMemberNo,
|
||||
},
|
||||
sanctions: gameSession.sanctions,
|
||||
identity: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user