feat: add secure troop management
This commit is contained in:
@@ -162,3 +162,5 @@ docker-compose.override.yml
|
|||||||
.turbo/
|
.turbo/
|
||||||
*_errors.txt
|
*_errors.txt
|
||||||
docs/image-storage.md
|
docs/image-storage.md
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
|||||||
@@ -1,73 +1,272 @@
|
|||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import type { TurnDaemonCommandResult } from '@sammo-ts/common';
|
||||||
|
import { isValidTroopNameWidth, normalizeTroopName, resolveTroopSecretPermission } from '@sammo-ts/logic';
|
||||||
|
|
||||||
import { authedProcedure, router } from '../../trpc.js';
|
import { authedProcedure, router } from '../../trpc.js';
|
||||||
|
import { getMyGeneral } from '../shared/general.js';
|
||||||
|
|
||||||
|
const troopNameSchema = z
|
||||||
|
.string()
|
||||||
|
.refine(isValidTroopNameWidth, '부대 이름은 전각 9자 또는 반각 18자 이하여야 합니다.');
|
||||||
|
|
||||||
|
const normalizeRequiredTroopName = (value: string): string => {
|
||||||
|
const name = normalizeTroopName(value);
|
||||||
|
if (!name) {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '부대 이름이 없습니다.' });
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
};
|
||||||
|
|
||||||
|
const assertCommandResult = <T extends 'troopCreate' | 'troopJoin' | 'troopExit' | 'troopKick' | 'troopRename'>(
|
||||||
|
result: TurnDaemonCommandResult | null,
|
||||||
|
expectedType: T
|
||||||
|
): never => {
|
||||||
|
if (!result) {
|
||||||
|
throw new TRPCError({ code: 'TIMEOUT', message: 'Turn daemon did not respond.' });
|
||||||
|
}
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'INTERNAL_SERVER_ERROR',
|
||||||
|
message: `Unexpected turn daemon response for ${expectedType}.`,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const troopRouter = router({
|
export const troopRouter = router({
|
||||||
join: authedProcedure
|
getList: authedProcedure.query(async ({ ctx }) => {
|
||||||
|
const me = await getMyGeneral(ctx);
|
||||||
|
if (me.nationId <= 0) {
|
||||||
|
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '국가에 소속되어 있지 않습니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [nation, troops, generals, cities] = await Promise.all([
|
||||||
|
ctx.db.nation.findUnique({
|
||||||
|
where: { id: me.nationId },
|
||||||
|
select: { id: true, name: true, meta: true },
|
||||||
|
}),
|
||||||
|
ctx.db.troop.findMany({
|
||||||
|
where: { nationId: me.nationId },
|
||||||
|
select: { troopLeaderId: true, nationId: true, name: true },
|
||||||
|
}),
|
||||||
|
ctx.db.general.findMany({
|
||||||
|
where: { nationId: me.nationId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
cityId: true,
|
||||||
|
troopId: true,
|
||||||
|
picture: true,
|
||||||
|
imageServer: true,
|
||||||
|
turnTime: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
ctx.db.city.findMany({
|
||||||
|
select: { id: true, name: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
if (!nation) {
|
||||||
|
throw new TRPCError({ code: 'NOT_FOUND', message: '국가 정보를 찾을 수 없습니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const troopLeaderIds = troops.map((troop) => troop.troopLeaderId);
|
||||||
|
const turns =
|
||||||
|
troopLeaderIds.length === 0
|
||||||
|
? []
|
||||||
|
: await ctx.db.generalTurn.findMany({
|
||||||
|
where: { generalId: { in: troopLeaderIds } },
|
||||||
|
select: { generalId: true, turnIdx: true, actionCode: true },
|
||||||
|
orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }],
|
||||||
|
});
|
||||||
|
const cityNames = new Map(cities.map((city) => [city.id, city.name]));
|
||||||
|
const generalMap = new Map(generals.map((general) => [general.id, general]));
|
||||||
|
const reservedByLeader = new Map<number, string[]>();
|
||||||
|
for (const turn of turns) {
|
||||||
|
const list = reservedByLeader.get(turn.generalId) ?? [];
|
||||||
|
list.push(turn.actionCode);
|
||||||
|
reservedByLeader.set(turn.generalId, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mappedTroops = troops
|
||||||
|
.map((troop) => {
|
||||||
|
const leader = generalMap.get(troop.troopLeaderId);
|
||||||
|
return {
|
||||||
|
id: troop.troopLeaderId,
|
||||||
|
name: troop.name,
|
||||||
|
nationId: troop.nationId,
|
||||||
|
turnTime: leader?.turnTime.toISOString() ?? null,
|
||||||
|
reservedCommands: reservedByLeader.get(troop.troopLeaderId) ?? [],
|
||||||
|
leader: leader
|
||||||
|
? {
|
||||||
|
id: leader.id,
|
||||||
|
name: leader.name,
|
||||||
|
cityId: leader.cityId,
|
||||||
|
cityName: cityNames.get(leader.cityId) ?? '알 수 없음',
|
||||||
|
picture: leader.picture,
|
||||||
|
imageServer: leader.imageServer,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
members: generals
|
||||||
|
.filter((general) => general.troopId === troop.troopLeaderId)
|
||||||
|
.map((general) => ({
|
||||||
|
id: general.id,
|
||||||
|
name: general.name,
|
||||||
|
cityId: general.cityId,
|
||||||
|
cityName: cityNames.get(general.cityId) ?? '알 수 없음',
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((left, right) => {
|
||||||
|
const timeOrder = (left.turnTime ?? '').localeCompare(right.turnTime ?? '');
|
||||||
|
return timeOrder || left.id - right.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
nation: { id: nation.id, name: nation.name },
|
||||||
|
me: { id: me.id, troopId: me.troopId },
|
||||||
|
permission: resolveTroopSecretPermission(me, nation.meta, false),
|
||||||
|
troops: mappedTroops,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
create: authedProcedure.input(z.object({ troopName: troopNameSchema })).mutation(async ({ ctx, input }) => {
|
||||||
|
const me = await getMyGeneral(ctx);
|
||||||
|
const troopName = normalizeRequiredTroopName(input.troopName);
|
||||||
|
if (me.troopId !== 0) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'PRECONDITION_FAILED',
|
||||||
|
message: '이미 부대에 소속되어 있습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (me.nationId <= 0) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'PRECONDITION_FAILED',
|
||||||
|
message: '국가에 소속되어 있지 않습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const result = await ctx.turnDaemon.requestCommand({
|
||||||
|
type: 'troopCreate',
|
||||||
|
generalId: me.id,
|
||||||
|
troopName,
|
||||||
|
});
|
||||||
|
if (!result || result.type !== 'troopCreate') {
|
||||||
|
return assertCommandResult(result, 'troopCreate');
|
||||||
|
}
|
||||||
|
if (!result.ok) {
|
||||||
|
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: result.reason });
|
||||||
|
}
|
||||||
|
return { ok: true, troopId: result.troopId, troopName: result.troopName };
|
||||||
|
}),
|
||||||
|
join: authedProcedure.input(z.object({ troopId: z.number().int().positive() })).mutation(async ({ ctx, input }) => {
|
||||||
|
const me = await getMyGeneral(ctx);
|
||||||
|
const result = await ctx.turnDaemon.requestCommand({
|
||||||
|
type: 'troopJoin',
|
||||||
|
generalId: me.id,
|
||||||
|
troopId: input.troopId,
|
||||||
|
});
|
||||||
|
if (!result || result.type !== 'troopJoin') {
|
||||||
|
return assertCommandResult(result, 'troopJoin');
|
||||||
|
}
|
||||||
|
if (!result.ok) {
|
||||||
|
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: result.reason });
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}),
|
||||||
|
exit: authedProcedure.mutation(async ({ ctx }) => {
|
||||||
|
const me = await getMyGeneral(ctx);
|
||||||
|
const result = await ctx.turnDaemon.requestCommand({
|
||||||
|
type: 'troopExit',
|
||||||
|
generalId: me.id,
|
||||||
|
});
|
||||||
|
if (!result || result.type !== 'troopExit') {
|
||||||
|
return assertCommandResult(result, 'troopExit');
|
||||||
|
}
|
||||||
|
if (!result.ok) {
|
||||||
|
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: result.reason });
|
||||||
|
}
|
||||||
|
return { ok: true, wasLeader: result.wasLeader };
|
||||||
|
}),
|
||||||
|
kick: authedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
generalId: z.number().int().positive(),
|
|
||||||
troopId: z.number().int().positive(),
|
troopId: z.number().int().positive(),
|
||||||
|
targetGeneralId: z.number().int().positive(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const result = await ctx.turnDaemon.requestCommand({
|
const me = await getMyGeneral(ctx);
|
||||||
type: 'troopJoin',
|
if (me.id !== input.troopId || me.troopId !== me.id) {
|
||||||
generalId: input.generalId,
|
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
|
||||||
troopId: input.troopId,
|
}
|
||||||
|
const target = await ctx.db.general.findUnique({
|
||||||
|
where: { id: input.targetGeneralId },
|
||||||
|
select: { id: true, troopId: true },
|
||||||
});
|
});
|
||||||
if (!result) {
|
if (!target) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '장수 정보를 찾을 수 없습니다.' });
|
||||||
code: 'TIMEOUT',
|
|
||||||
message: 'Turn daemon did not respond.',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (result.type !== 'troopJoin') {
|
if (target.troopId === 0) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '부대에 소속되어 있지 않습니다.' });
|
||||||
code: 'INTERNAL_SERVER_ERROR',
|
|
||||||
message: 'Unexpected turn daemon response.',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (!result.ok) {
|
if (target.troopId !== input.troopId) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '다른 부대에 소속되어 있습니다.' });
|
||||||
code: 'PRECONDITION_FAILED',
|
}
|
||||||
message: result.reason,
|
if (target.id === input.troopId) {
|
||||||
});
|
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '부대장을 추방할 수 없습니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const result = await ctx.turnDaemon.requestCommand({
|
||||||
|
type: 'troopKick',
|
||||||
|
generalId: me.id,
|
||||||
|
troopId: input.troopId,
|
||||||
|
targetGeneralId: input.targetGeneralId,
|
||||||
|
});
|
||||||
|
if (!result || result.type !== 'troopKick') {
|
||||||
|
return assertCommandResult(result, 'troopKick');
|
||||||
|
}
|
||||||
|
if (!result.ok) {
|
||||||
|
const code = result.reason === '권한이 부족합니다.' ? 'FORBIDDEN' : 'PRECONDITION_FAILED';
|
||||||
|
throw new TRPCError({ code, message: result.reason });
|
||||||
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}),
|
}),
|
||||||
exit: authedProcedure
|
rename: authedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
generalId: z.number().int().positive(),
|
troopId: z.number().int().positive(),
|
||||||
|
troopName: troopNameSchema,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const result = await ctx.turnDaemon.requestCommand({
|
const me = await getMyGeneral(ctx);
|
||||||
type: 'troopExit',
|
const troopName = normalizeRequiredTroopName(input.troopName);
|
||||||
generalId: input.generalId,
|
const nation = await ctx.db.nation.findUnique({
|
||||||
|
where: { id: me.nationId },
|
||||||
|
select: { meta: true },
|
||||||
});
|
});
|
||||||
if (!result) {
|
const permission = resolveTroopSecretPermission(me, nation?.meta ?? {}, false);
|
||||||
throw new TRPCError({
|
if (me.id !== input.troopId && permission < 4) {
|
||||||
code: 'TIMEOUT',
|
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
|
||||||
message: 'Turn daemon did not respond.',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (result.type !== 'troopExit') {
|
const troop = await ctx.db.troop.findUnique({
|
||||||
throw new TRPCError({
|
where: { troopLeaderId: input.troopId },
|
||||||
code: 'INTERNAL_SERVER_ERROR',
|
select: { nationId: true },
|
||||||
message: 'Unexpected turn daemon response.',
|
});
|
||||||
});
|
if (!troop || me.nationId <= 0 || troop.nationId !== me.nationId) {
|
||||||
}
|
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '부대가 없습니다.' });
|
||||||
if (!result.ok) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: 'PRECONDITION_FAILED',
|
|
||||||
message: result.reason,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ok: true, wasLeader: result.wasLeader };
|
const result = await ctx.turnDaemon.requestCommand({
|
||||||
|
type: 'troopRename',
|
||||||
|
generalId: me.id,
|
||||||
|
troopId: input.troopId,
|
||||||
|
troopName,
|
||||||
|
});
|
||||||
|
if (!result || result.type !== 'troopRename') {
|
||||||
|
return assertCommandResult(result, 'troopRename');
|
||||||
|
}
|
||||||
|
if (!result.ok) {
|
||||||
|
const code = result.reason === '권한이 부족합니다.' ? 'FORBIDDEN' : 'PRECONDITION_FAILED';
|
||||||
|
throw new TRPCError({ code, message: result.reason });
|
||||||
|
}
|
||||||
|
return { ok: true, troopName: result.troopName };
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||||
|
import type { RedisConnector } from '@sammo-ts/infra';
|
||||||
|
|
||||||
|
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
|
||||||
|
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
|
||||||
|
import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js';
|
||||||
|
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
|
||||||
|
import { appRouter } from '../src/router.js';
|
||||||
|
|
||||||
|
const buildGeneral = (overrides: Partial<GeneralRow> = {}): GeneralRow => ({
|
||||||
|
id: 1,
|
||||||
|
userId: 'user-1',
|
||||||
|
name: '부대장',
|
||||||
|
nationId: 1,
|
||||||
|
cityId: 1,
|
||||||
|
troopId: 0,
|
||||||
|
npcState: 0,
|
||||||
|
affinity: null,
|
||||||
|
bornYear: 180,
|
||||||
|
deadYear: 300,
|
||||||
|
picture: null,
|
||||||
|
imageServer: 0,
|
||||||
|
leadership: 50,
|
||||||
|
strength: 50,
|
||||||
|
intel: 50,
|
||||||
|
injury: 0,
|
||||||
|
experience: 0,
|
||||||
|
dedication: 0,
|
||||||
|
officerLevel: 1,
|
||||||
|
gold: 1000,
|
||||||
|
rice: 1000,
|
||||||
|
crew: 0,
|
||||||
|
crewTypeId: 0,
|
||||||
|
train: 0,
|
||||||
|
atmos: 0,
|
||||||
|
weaponCode: 'None',
|
||||||
|
bookCode: 'None',
|
||||||
|
horseCode: 'None',
|
||||||
|
itemCode: 'None',
|
||||||
|
turnTime: new Date('2026-01-01T00:00:00Z'),
|
||||||
|
recentWarTime: null,
|
||||||
|
age: 20,
|
||||||
|
startAge: 20,
|
||||||
|
personalCode: 'None',
|
||||||
|
specialCode: 'None',
|
||||||
|
special2Code: 'None',
|
||||||
|
lastTurn: {},
|
||||||
|
meta: {},
|
||||||
|
penalty: {},
|
||||||
|
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||||
|
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const auth: GameSessionTokenPayload = {
|
||||||
|
version: 1,
|
||||||
|
profile: 'che:default',
|
||||||
|
issuedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
expiresAt: '2026-01-02T00:00:00.000Z',
|
||||||
|
sessionId: 'session-1',
|
||||||
|
user: {
|
||||||
|
id: 'user-1',
|
||||||
|
username: 'tester',
|
||||||
|
displayName: 'Tester',
|
||||||
|
roles: [],
|
||||||
|
},
|
||||||
|
sanctions: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildContext = (options: {
|
||||||
|
me?: GeneralRow;
|
||||||
|
target?: GeneralRow | null;
|
||||||
|
troop?: { troopLeaderId: number; nationId: number; name: string } | null;
|
||||||
|
nationMeta?: Record<string, unknown>;
|
||||||
|
auth?: GameSessionTokenPayload | null;
|
||||||
|
result: Awaited<ReturnType<TurnDaemonTransport['requestCommand']>>;
|
||||||
|
}) => {
|
||||||
|
const me = options.me ?? buildGeneral();
|
||||||
|
const requestCommand = vi.fn(async () => options.result);
|
||||||
|
const db = {
|
||||||
|
general: {
|
||||||
|
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) =>
|
||||||
|
me.userId === where.userId ? me : null
|
||||||
|
),
|
||||||
|
findUnique: vi.fn(async ({ where }: { where: { id: number } }) => {
|
||||||
|
if (where.id === me.id) {
|
||||||
|
return me;
|
||||||
|
}
|
||||||
|
return options.target?.id === where.id ? options.target : null;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
nation: {
|
||||||
|
findUnique: vi.fn(async ({ where }: { where: { id: number } }) =>
|
||||||
|
where.id === me.nationId ? { id: me.nationId, meta: options.nationMeta ?? {} } : null
|
||||||
|
),
|
||||||
|
},
|
||||||
|
troop: {
|
||||||
|
findUnique: vi.fn(async ({ where }: { where: { troopLeaderId: number } }) =>
|
||||||
|
options.troop?.troopLeaderId === where.troopLeaderId ? options.troop : null
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const accessTokenStore = new RedisAccessTokenStore(
|
||||||
|
{
|
||||||
|
get: async () => null,
|
||||||
|
set: async () => null,
|
||||||
|
},
|
||||||
|
'che:default'
|
||||||
|
);
|
||||||
|
const context: GameApiContext = {
|
||||||
|
db: db as unknown as DatabaseClient,
|
||||||
|
redis: {} as RedisConnector['client'],
|
||||||
|
turnDaemon: { requestCommand } as unknown as TurnDaemonTransport,
|
||||||
|
battleSim: {} as GameApiContext['battleSim'],
|
||||||
|
profile: { id: 'che', scenario: 'default', name: 'che:default' },
|
||||||
|
auth: options.auth === undefined ? auth : options.auth,
|
||||||
|
uploadDir: 'uploads',
|
||||||
|
uploadPath: '/uploads',
|
||||||
|
uploadPublicUrl: null,
|
||||||
|
accessTokenStore,
|
||||||
|
flushStore: new InMemoryFlushStore(),
|
||||||
|
gameTokenSecret: 'test-secret',
|
||||||
|
};
|
||||||
|
return { context, requestCommand };
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('troop router permissions and mutations', () => {
|
||||||
|
it('creates a troop only for the general owned by the authenticated user', async () => {
|
||||||
|
const { context, requestCommand } = buildContext({
|
||||||
|
result: { type: 'troopCreate', ok: true, generalId: 1, troopId: 1, troopName: '백마대' },
|
||||||
|
});
|
||||||
|
const caller = appRouter.createCaller(context);
|
||||||
|
|
||||||
|
await expect(caller.troop.create({ troopName: '백마대' })).resolves.toEqual({
|
||||||
|
ok: true,
|
||||||
|
troopId: 1,
|
||||||
|
troopName: '백마대',
|
||||||
|
});
|
||||||
|
expect(requestCommand).toHaveBeenCalledWith({
|
||||||
|
type: 'troopCreate',
|
||||||
|
generalId: 1,
|
||||||
|
troopName: '백마대',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects troop creation before daemon dispatch when already assigned or the name is blank', async () => {
|
||||||
|
const assigned = buildContext({
|
||||||
|
me: buildGeneral({ troopId: 9 }),
|
||||||
|
result: null,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
appRouter.createCaller(assigned.context).troop.create({ troopName: '신규대' })
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: 'PRECONDITION_FAILED',
|
||||||
|
message: '이미 부대에 소속되어 있습니다.',
|
||||||
|
});
|
||||||
|
expect(assigned.requestCommand).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const blank = buildContext({ result: null });
|
||||||
|
await expect(appRouter.createCaller(blank.context).troop.create({ troopName: ' ' })).rejects.toMatchObject({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: '부대 이름이 없습니다.',
|
||||||
|
});
|
||||||
|
expect(blank.requestCommand).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an over-width legacy troop name', async () => {
|
||||||
|
const fixture = buildContext({ result: null });
|
||||||
|
await expect(
|
||||||
|
appRouter.createCaller(fixture.context).troop.create({ troopName: '가나다라마바사아자차' })
|
||||||
|
).rejects.toThrow('부대 이름은 전각 9자 또는 반각 18자 이하여야 합니다.');
|
||||||
|
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows only the current troop leader to kick a member', async () => {
|
||||||
|
const unauthorized = buildContext({
|
||||||
|
me: buildGeneral({ id: 2, troopId: 1 }),
|
||||||
|
target: buildGeneral({ id: 3, userId: null, troopId: 1 }),
|
||||||
|
result: null,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
appRouter.createCaller(unauthorized.context).troop.kick({ troopId: 1, targetGeneralId: 3 })
|
||||||
|
).rejects.toMatchObject({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
|
||||||
|
expect(unauthorized.requestCommand).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const authorized = buildContext({
|
||||||
|
me: buildGeneral({ id: 1, troopId: 1 }),
|
||||||
|
target: buildGeneral({ id: 3, userId: null, troopId: 1 }),
|
||||||
|
result: { type: 'troopKick', ok: true, generalId: 1, troopId: 1, targetGeneralId: 3 },
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
appRouter.createCaller(authorized.context).troop.kick({ troopId: 1, targetGeneralId: 3 })
|
||||||
|
).resolves.toEqual({ ok: true });
|
||||||
|
expect(authorized.requestCommand).toHaveBeenCalledWith({
|
||||||
|
type: 'troopKick',
|
||||||
|
generalId: 1,
|
||||||
|
troopId: 1,
|
||||||
|
targetGeneralId: 3,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks same-nation top-secret permission before renaming another troop', async () => {
|
||||||
|
const forbidden = buildContext({
|
||||||
|
me: buildGeneral({ id: 2, officerLevel: 1, meta: {} }),
|
||||||
|
troop: { troopLeaderId: 1, nationId: 1, name: '구대' },
|
||||||
|
result: null,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
appRouter.createCaller(forbidden.context).troop.rename({ troopId: 1, troopName: '신대' })
|
||||||
|
).rejects.toMatchObject({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
|
||||||
|
|
||||||
|
const ambassador = buildContext({
|
||||||
|
me: buildGeneral({ id: 2, officerLevel: 1, meta: { permission: 'ambassador' } }),
|
||||||
|
troop: { troopLeaderId: 1, nationId: 1, name: '구대' },
|
||||||
|
result: { type: 'troopRename', ok: true, generalId: 2, troopId: 1, troopName: '신대' },
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
appRouter.createCaller(ambassador.context).troop.rename({ troopId: 1, troopName: '신대' })
|
||||||
|
).resolves.toEqual({ ok: true, troopName: '신대' });
|
||||||
|
|
||||||
|
const crossNation = buildContext({
|
||||||
|
me: buildGeneral({ id: 2, officerLevel: 1, meta: { permission: 'ambassador' } }),
|
||||||
|
troop: { troopLeaderId: 1, nationId: 9, name: '타국대' },
|
||||||
|
result: null,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
appRouter.createCaller(crossNation.context).troop.rename({ troopId: 1, troopName: '신대' })
|
||||||
|
).rejects.toMatchObject({ code: 'PRECONDITION_FAILED', message: '부대가 없습니다.' });
|
||||||
|
expect(crossNation.requestCommand).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not accept troop mutations without authentication', async () => {
|
||||||
|
const fixture = buildContext({ auth: null, result: null });
|
||||||
|
await expect(
|
||||||
|
appRouter.createCaller(fixture.context).troop.create({ troopName: '백마대' })
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: 'UNAUTHORIZED',
|
||||||
|
});
|
||||||
|
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -50,11 +50,31 @@ const zTroopJoin = z.object({
|
|||||||
troopId: zFiniteNumber,
|
troopId: zFiniteNumber,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const zTroopCreate = z.object({
|
||||||
|
type: z.literal('troopCreate'),
|
||||||
|
generalId: zFiniteNumber,
|
||||||
|
troopName: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
const zTroopExit = z.object({
|
const zTroopExit = z.object({
|
||||||
type: z.literal('troopExit'),
|
type: z.literal('troopExit'),
|
||||||
generalId: zFiniteNumber,
|
generalId: zFiniteNumber,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const zTroopKick = z.object({
|
||||||
|
type: z.literal('troopKick'),
|
||||||
|
generalId: zFiniteNumber,
|
||||||
|
troopId: zFiniteNumber,
|
||||||
|
targetGeneralId: zFiniteNumber,
|
||||||
|
});
|
||||||
|
|
||||||
|
const zTroopRename = z.object({
|
||||||
|
type: z.literal('troopRename'),
|
||||||
|
generalId: zFiniteNumber,
|
||||||
|
troopId: zFiniteNumber,
|
||||||
|
troopName: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
const zDieOnPrestart = z.object({
|
const zDieOnPrestart = z.object({
|
||||||
type: z.literal('dieOnPrestart'),
|
type: z.literal('dieOnPrestart'),
|
||||||
generalId: zFiniteNumber,
|
generalId: zFiniteNumber,
|
||||||
@@ -262,6 +282,14 @@ const normalizeTroopJoin: CommandNormalizer<'troopJoin'> = (envelope) => {
|
|||||||
return { ...command, requestId: envelope.requestId };
|
return { ...command, requestId: envelope.requestId };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeTroopCreate: CommandNormalizer<'troopCreate'> = (envelope) => {
|
||||||
|
const command = parseWith(zTroopCreate, envelope.command);
|
||||||
|
if (!command) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { ...command, requestId: envelope.requestId };
|
||||||
|
};
|
||||||
|
|
||||||
const normalizeTroopExit: CommandNormalizer<'troopExit'> = (envelope) => {
|
const normalizeTroopExit: CommandNormalizer<'troopExit'> = (envelope) => {
|
||||||
const command = parseWith(zTroopExit, envelope.command);
|
const command = parseWith(zTroopExit, envelope.command);
|
||||||
if (!command) {
|
if (!command) {
|
||||||
@@ -270,6 +298,22 @@ const normalizeTroopExit: CommandNormalizer<'troopExit'> = (envelope) => {
|
|||||||
return { ...command, requestId: envelope.requestId };
|
return { ...command, requestId: envelope.requestId };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeTroopKick: CommandNormalizer<'troopKick'> = (envelope) => {
|
||||||
|
const command = parseWith(zTroopKick, envelope.command);
|
||||||
|
if (!command) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { ...command, requestId: envelope.requestId };
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeTroopRename: CommandNormalizer<'troopRename'> = (envelope) => {
|
||||||
|
const command = parseWith(zTroopRename, envelope.command);
|
||||||
|
if (!command) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { ...command, requestId: envelope.requestId };
|
||||||
|
};
|
||||||
|
|
||||||
const normalizeDieOnPrestart: CommandNormalizer<'dieOnPrestart'> = (envelope) => {
|
const normalizeDieOnPrestart: CommandNormalizer<'dieOnPrestart'> = (envelope) => {
|
||||||
const command = parseWith(zDieOnPrestart, envelope.command);
|
const command = parseWith(zDieOnPrestart, envelope.command);
|
||||||
if (!command) {
|
if (!command) {
|
||||||
@@ -445,8 +489,11 @@ const normalizeShutdown: CommandNormalizer<'shutdown'> = (envelope) => {
|
|||||||
const normalizers: CommandNormalizerMap = {
|
const normalizers: CommandNormalizerMap = {
|
||||||
auctionFinalize: normalizeAuctionFinalize,
|
auctionFinalize: normalizeAuctionFinalize,
|
||||||
auctionBid: normalizeAuctionBid,
|
auctionBid: normalizeAuctionBid,
|
||||||
|
troopCreate: normalizeTroopCreate,
|
||||||
troopJoin: normalizeTroopJoin,
|
troopJoin: normalizeTroopJoin,
|
||||||
troopExit: normalizeTroopExit,
|
troopExit: normalizeTroopExit,
|
||||||
|
troopKick: normalizeTroopKick,
|
||||||
|
troopRename: normalizeTroopRename,
|
||||||
dieOnPrestart: normalizeDieOnPrestart,
|
dieOnPrestart: normalizeDieOnPrestart,
|
||||||
buildNationCandidate: normalizeBuildNationCandidate,
|
buildNationCandidate: normalizeBuildNationCandidate,
|
||||||
instantRetreat: normalizeInstantRetreat,
|
instantRetreat: normalizeInstantRetreat,
|
||||||
|
|||||||
@@ -434,6 +434,18 @@ export class InMemoryTurnWorld {
|
|||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createTroop(troop: Troop): Troop | null {
|
||||||
|
if (this.troops.has(troop.id)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const next = { ...troop };
|
||||||
|
this.troops.set(troop.id, next);
|
||||||
|
this.dirtyTroopIds.add(troop.id);
|
||||||
|
this.createdTroopIds.add(troop.id);
|
||||||
|
this.deletedTroopIds.delete(troop.id);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
removeTroop(id: number): boolean {
|
removeTroop(id: number): boolean {
|
||||||
if (!this.troops.has(id)) {
|
if (!this.troops.has(id)) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export interface TurnWorldState {
|
|||||||
export interface TurnGeneral extends General {
|
export interface TurnGeneral extends General {
|
||||||
turnTime: Date;
|
turnTime: Date;
|
||||||
recentWarTime?: Date | null;
|
recentWarTime?: Date | null;
|
||||||
|
penalty?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TurnDiplomacy {
|
export interface TurnDiplomacy {
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ import {
|
|||||||
buildVoteUniqueSeed,
|
buildVoteUniqueSeed,
|
||||||
countOccupiedUniqueItems,
|
countOccupiedUniqueItems,
|
||||||
createItemModuleRegistry,
|
createItemModuleRegistry,
|
||||||
|
isValidTroopNameWidth,
|
||||||
loadItemModules,
|
loadItemModules,
|
||||||
|
normalizeTroopName,
|
||||||
|
resolveTroopSecretPermission,
|
||||||
resolveUniqueConfig,
|
resolveUniqueConfig,
|
||||||
rollUniqueLottery,
|
rollUniqueLottery,
|
||||||
type ItemModule,
|
type ItemModule,
|
||||||
@@ -441,6 +444,73 @@ async function handleTroopJoin(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleTroopCreate(
|
||||||
|
ctx: CommandHandlerContext,
|
||||||
|
command: Extract<TurnDaemonCommand, { type: 'troopCreate' }>
|
||||||
|
): Promise<TurnDaemonCommandResult> {
|
||||||
|
const { world } = ctx;
|
||||||
|
const general = world.getGeneralById(command.generalId);
|
||||||
|
if (!general) {
|
||||||
|
return {
|
||||||
|
type: 'troopCreate',
|
||||||
|
ok: false,
|
||||||
|
generalId: command.generalId,
|
||||||
|
reason: '장수 정보를 찾을 수 없습니다.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const troopName = normalizeTroopName(command.troopName);
|
||||||
|
if (!troopName) {
|
||||||
|
return { type: 'troopCreate', ok: false, generalId: command.generalId, reason: '부대 이름이 없습니다.' };
|
||||||
|
}
|
||||||
|
if (!isValidTroopNameWidth(troopName)) {
|
||||||
|
return {
|
||||||
|
type: 'troopCreate',
|
||||||
|
ok: false,
|
||||||
|
generalId: command.generalId,
|
||||||
|
reason: '부대 이름은 전각 9자 또는 반각 18자 이하여야 합니다.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (general.troopId !== 0 || world.getTroopById(general.id)) {
|
||||||
|
return {
|
||||||
|
type: 'troopCreate',
|
||||||
|
ok: false,
|
||||||
|
generalId: command.generalId,
|
||||||
|
reason: '이미 부대에 소속되어 있습니다.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (general.nationId <= 0 || !world.getNationById(general.nationId)) {
|
||||||
|
return {
|
||||||
|
type: 'troopCreate',
|
||||||
|
ok: false,
|
||||||
|
generalId: command.generalId,
|
||||||
|
reason: '국가에 소속되어 있지 않습니다.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const troop = world.createTroop({
|
||||||
|
id: general.id,
|
||||||
|
nationId: general.nationId,
|
||||||
|
name: troopName,
|
||||||
|
});
|
||||||
|
if (!troop) {
|
||||||
|
return {
|
||||||
|
type: 'troopCreate',
|
||||||
|
ok: false,
|
||||||
|
generalId: command.generalId,
|
||||||
|
reason: '부대가 생성되지 않았습니다. 버그일 수 있습니다.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
world.updateGeneral(general.id, { troopId: general.id });
|
||||||
|
return {
|
||||||
|
type: 'troopCreate',
|
||||||
|
ok: true,
|
||||||
|
generalId: general.id,
|
||||||
|
troopId: troop.id,
|
||||||
|
troopName: troop.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function handleTroopExit(
|
async function handleTroopExit(
|
||||||
ctx: CommandHandlerContext,
|
ctx: CommandHandlerContext,
|
||||||
command: Extract<TurnDaemonCommand, { type: 'troopExit' }>
|
command: Extract<TurnDaemonCommand, { type: 'troopExit' }>
|
||||||
@@ -490,6 +560,103 @@ async function handleTroopExit(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleTroopKick(
|
||||||
|
ctx: CommandHandlerContext,
|
||||||
|
command: Extract<TurnDaemonCommand, { type: 'troopKick' }>
|
||||||
|
): Promise<TurnDaemonCommandResult> {
|
||||||
|
const fail = (reason: string): TurnDaemonCommandResult => ({
|
||||||
|
type: 'troopKick',
|
||||||
|
ok: false,
|
||||||
|
generalId: command.generalId,
|
||||||
|
troopId: command.troopId,
|
||||||
|
targetGeneralId: command.targetGeneralId,
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
const { world } = ctx;
|
||||||
|
const actor = world.getGeneralById(command.generalId);
|
||||||
|
if (!actor) {
|
||||||
|
return fail('장수 정보를 찾을 수 없습니다.');
|
||||||
|
}
|
||||||
|
const troop = world.getTroopById(command.troopId);
|
||||||
|
if (
|
||||||
|
command.generalId !== command.troopId ||
|
||||||
|
actor.troopId !== actor.id ||
|
||||||
|
!troop ||
|
||||||
|
troop.id !== actor.id ||
|
||||||
|
troop.nationId !== actor.nationId
|
||||||
|
) {
|
||||||
|
return fail('권한이 부족합니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = world.getGeneralById(command.targetGeneralId);
|
||||||
|
if (!target) {
|
||||||
|
return fail('장수 정보를 찾을 수 없습니다.');
|
||||||
|
}
|
||||||
|
if (target.troopId === 0) {
|
||||||
|
return fail('부대에 소속되어 있지 않습니다.');
|
||||||
|
}
|
||||||
|
if (target.troopId !== command.troopId) {
|
||||||
|
return fail('다른 부대에 소속되어 있습니다.');
|
||||||
|
}
|
||||||
|
if (target.id === command.troopId) {
|
||||||
|
return fail('부대장을 추방할 수 없습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
world.updateGeneral(target.id, { troopId: 0 });
|
||||||
|
return {
|
||||||
|
type: 'troopKick',
|
||||||
|
ok: true,
|
||||||
|
generalId: actor.id,
|
||||||
|
troopId: troop.id,
|
||||||
|
targetGeneralId: target.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTroopRename(
|
||||||
|
ctx: CommandHandlerContext,
|
||||||
|
command: Extract<TurnDaemonCommand, { type: 'troopRename' }>
|
||||||
|
): Promise<TurnDaemonCommandResult> {
|
||||||
|
const fail = (reason: string): TurnDaemonCommandResult => ({
|
||||||
|
type: 'troopRename',
|
||||||
|
ok: false,
|
||||||
|
generalId: command.generalId,
|
||||||
|
troopId: command.troopId,
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
const { world } = ctx;
|
||||||
|
const actor = world.getGeneralById(command.generalId);
|
||||||
|
if (!actor) {
|
||||||
|
return fail('장수 정보를 찾을 수 없습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const nation = world.getNationById(actor.nationId);
|
||||||
|
const permission = resolveTroopSecretPermission(actor, nation?.meta ?? {}, false);
|
||||||
|
if (actor.id !== command.troopId && permission < 4) {
|
||||||
|
return fail('권한이 부족합니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const troopName = normalizeTroopName(command.troopName);
|
||||||
|
if (!troopName) {
|
||||||
|
return fail('부대 이름이 없습니다.');
|
||||||
|
}
|
||||||
|
if (!isValidTroopNameWidth(troopName)) {
|
||||||
|
return fail('부대 이름은 전각 9자 또는 반각 18자 이하여야 합니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const troop = world.getTroopById(command.troopId);
|
||||||
|
if (!troop || actor.nationId <= 0 || troop.nationId !== actor.nationId) {
|
||||||
|
return fail('부대가 없습니다.');
|
||||||
|
}
|
||||||
|
world.updateTroop(troop.id, { name: troopName });
|
||||||
|
return {
|
||||||
|
type: 'troopRename',
|
||||||
|
ok: true,
|
||||||
|
generalId: actor.id,
|
||||||
|
troopId: troop.id,
|
||||||
|
troopName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function handleDieOnPrestart(
|
async function handleDieOnPrestart(
|
||||||
ctx: CommandHandlerContext,
|
ctx: CommandHandlerContext,
|
||||||
command: Extract<TurnDaemonCommand, { type: 'dieOnPrestart' }>
|
command: Extract<TurnDaemonCommand, { type: 'dieOnPrestart' }>
|
||||||
@@ -1154,8 +1321,13 @@ export const createTurnDaemonCommandHandler = (options: {
|
|||||||
>;
|
>;
|
||||||
|
|
||||||
const handlers: HandlerMap = {
|
const handlers: HandlerMap = {
|
||||||
|
troopCreate: (command) =>
|
||||||
|
handleTroopCreate(ctx, command as Extract<TurnDaemonCommand, { type: 'troopCreate' }>),
|
||||||
troopJoin: (command) => handleTroopJoin(ctx, command as Extract<TurnDaemonCommand, { type: 'troopJoin' }>),
|
troopJoin: (command) => handleTroopJoin(ctx, command as Extract<TurnDaemonCommand, { type: 'troopJoin' }>),
|
||||||
troopExit: (command) => handleTroopExit(ctx, command as Extract<TurnDaemonCommand, { type: 'troopExit' }>),
|
troopExit: (command) => handleTroopExit(ctx, command as Extract<TurnDaemonCommand, { type: 'troopExit' }>),
|
||||||
|
troopKick: (command) => handleTroopKick(ctx, command as Extract<TurnDaemonCommand, { type: 'troopKick' }>),
|
||||||
|
troopRename: (command) =>
|
||||||
|
handleTroopRename(ctx, command as Extract<TurnDaemonCommand, { type: 'troopRename' }>),
|
||||||
dieOnPrestart: (command) =>
|
dieOnPrestart: (command) =>
|
||||||
handleDieOnPrestart(ctx, command as Extract<TurnDaemonCommand, { type: 'dieOnPrestart' }>),
|
handleDieOnPrestart(ctx, command as Extract<TurnDaemonCommand, { type: 'dieOnPrestart' }>),
|
||||||
buildNationCandidate: (command) =>
|
buildNationCandidate: (command) =>
|
||||||
|
|||||||
@@ -195,6 +195,7 @@ const mapGeneralRow = (row: TurnEngineGeneralRow): TurnGeneral => {
|
|||||||
meta: {},
|
meta: {},
|
||||||
},
|
},
|
||||||
itemInventory,
|
itemInventory,
|
||||||
|
penalty: row.penalty,
|
||||||
// meta는 상단에서 보장 처리됨.
|
// meta는 상단에서 보장 처리됨.
|
||||||
turnTime: row.turnTime,
|
turnTime: row.turnTime,
|
||||||
recentWarTime: row.recentWarTime ?? null,
|
recentWarTime: row.recentWarTime ?? null,
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import type { TriggerValue, TurnSchedule } from '@sammo-ts/logic';
|
||||||
|
|
||||||
|
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||||
|
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||||
|
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
|
||||||
|
|
||||||
|
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
|
||||||
|
|
||||||
|
const buildGeneral = (id: number, overrides: Partial<TurnGeneral> = {}): TurnGeneral => ({
|
||||||
|
id,
|
||||||
|
name: `장수${id}`,
|
||||||
|
nationId: 1,
|
||||||
|
cityId: 1,
|
||||||
|
troopId: 0,
|
||||||
|
stats: { leadership: 50, strength: 50, intelligence: 50 },
|
||||||
|
turnTime: new Date('0180-01-01T00:00:00Z'),
|
||||||
|
recentWarTime: null,
|
||||||
|
role: {
|
||||||
|
items: { horse: null, weapon: null, book: null, item: null },
|
||||||
|
personality: null,
|
||||||
|
specialDomestic: null,
|
||||||
|
specialWar: null,
|
||||||
|
},
|
||||||
|
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||||
|
meta: { killturn: 24 },
|
||||||
|
penalty: {},
|
||||||
|
officerLevel: 1,
|
||||||
|
experience: 0,
|
||||||
|
dedication: 0,
|
||||||
|
injury: 0,
|
||||||
|
gold: 1000,
|
||||||
|
rice: 1000,
|
||||||
|
crew: 100,
|
||||||
|
crewTypeId: 0,
|
||||||
|
train: 0,
|
||||||
|
atmos: 0,
|
||||||
|
age: 30,
|
||||||
|
npcState: 0,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildWorld = (options: {
|
||||||
|
generals?: TurnGeneral[];
|
||||||
|
troops?: Array<{ id: number; nationId: number; name: string }>;
|
||||||
|
nationMeta?: Record<string, TriggerValue>;
|
||||||
|
}) => {
|
||||||
|
const state: TurnWorldState = {
|
||||||
|
id: 1,
|
||||||
|
currentYear: 180,
|
||||||
|
currentMonth: 1,
|
||||||
|
tickSeconds: 600,
|
||||||
|
lastTurnTime: new Date('0180-01-01T00:00:00Z'),
|
||||||
|
meta: { killturn: 24 },
|
||||||
|
};
|
||||||
|
const snapshot: TurnWorldSnapshot = {
|
||||||
|
generals: options.generals ?? [buildGeneral(1)],
|
||||||
|
cities: [],
|
||||||
|
nations: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: '테스트국',
|
||||||
|
color: '#ff0000',
|
||||||
|
capitalCityId: null,
|
||||||
|
chiefGeneralId: 1,
|
||||||
|
gold: 1000,
|
||||||
|
rice: 1000,
|
||||||
|
power: 0,
|
||||||
|
level: 1,
|
||||||
|
typeCode: 'che_def',
|
||||||
|
meta: options.nationMeta ?? {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
troops: options.troops ?? [],
|
||||||
|
diplomacy: [],
|
||||||
|
events: [],
|
||||||
|
initialEvents: [],
|
||||||
|
scenarioConfig: {
|
||||||
|
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
|
||||||
|
iconPath: '',
|
||||||
|
map: {},
|
||||||
|
const: {},
|
||||||
|
environment: { mapName: 'test', unitSet: 'test' },
|
||||||
|
},
|
||||||
|
map: {
|
||||||
|
id: 'test',
|
||||||
|
name: 'test',
|
||||||
|
cities: [],
|
||||||
|
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return new InMemoryTurnWorld(state, snapshot, { schedule });
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('troop management world commands', () => {
|
||||||
|
it('creates a troop and assigns the authenticated general atomically in dirty state', async () => {
|
||||||
|
const world = buildWorld({});
|
||||||
|
const handler = createTurnDaemonCommandHandler({ world });
|
||||||
|
|
||||||
|
await expect(handler.handle({ type: 'troopCreate', generalId: 1, troopName: ' 백마대 ' })).resolves.toEqual({
|
||||||
|
type: 'troopCreate',
|
||||||
|
ok: true,
|
||||||
|
generalId: 1,
|
||||||
|
troopId: 1,
|
||||||
|
troopName: '백마대',
|
||||||
|
});
|
||||||
|
expect(world.getGeneralById(1)?.troopId).toBe(1);
|
||||||
|
expect(world.getTroopById(1)).toEqual({ id: 1, nationId: 1, name: '백마대' });
|
||||||
|
expect(world.peekDirtyState().createdTroops).toEqual([{ id: 1, nationId: 1, name: '백마대' }]);
|
||||||
|
|
||||||
|
const escapedWorld = buildWorld({ generals: [buildGeneral(2)] });
|
||||||
|
const escapedHandler = createTurnDaemonCommandHandler({ world: escapedWorld });
|
||||||
|
await expect(
|
||||||
|
escapedHandler.handle({ type: 'troopCreate', generalId: 2, troopName: '<백마대>' })
|
||||||
|
).resolves.toMatchObject({ ok: true, troopName: '<백마대>' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves legacy creation failures without mutating state', async () => {
|
||||||
|
const assigned = buildWorld({
|
||||||
|
generals: [buildGeneral(1, { troopId: 1 })],
|
||||||
|
troops: [{ id: 1, nationId: 1, name: '기존대' }],
|
||||||
|
});
|
||||||
|
const assignedHandler = createTurnDaemonCommandHandler({ world: assigned });
|
||||||
|
await expect(
|
||||||
|
assignedHandler.handle({ type: 'troopCreate', generalId: 1, troopName: '신규대' })
|
||||||
|
).resolves.toMatchObject({ ok: false, reason: '이미 부대에 소속되어 있습니다.' });
|
||||||
|
|
||||||
|
const blank = buildWorld({});
|
||||||
|
const blankHandler = createTurnDaemonCommandHandler({ world: blank });
|
||||||
|
await expect(
|
||||||
|
blankHandler.handle({ type: 'troopCreate', generalId: 1, troopName: ' ' })
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
reason: '부대 이름이 없습니다.',
|
||||||
|
});
|
||||||
|
expect(blank.getGeneralById(1)?.troopId).toBe(0);
|
||||||
|
expect(blank.peekDirtyState().createdTroops).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows only the troop leader to kick a current non-leader member', async () => {
|
||||||
|
const buildFixture = () =>
|
||||||
|
buildWorld({
|
||||||
|
generals: [
|
||||||
|
buildGeneral(1, { troopId: 1 }),
|
||||||
|
buildGeneral(2, { troopId: 1 }),
|
||||||
|
buildGeneral(3, { troopId: 1 }),
|
||||||
|
],
|
||||||
|
troops: [{ id: 1, nationId: 1, name: '백마대' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const forbidden = buildFixture();
|
||||||
|
const forbiddenHandler = createTurnDaemonCommandHandler({ world: forbidden });
|
||||||
|
await expect(
|
||||||
|
forbiddenHandler.handle({
|
||||||
|
type: 'troopKick',
|
||||||
|
generalId: 2,
|
||||||
|
troopId: 1,
|
||||||
|
targetGeneralId: 3,
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({ ok: false, reason: '권한이 부족합니다.' });
|
||||||
|
expect(forbidden.getGeneralById(3)?.troopId).toBe(1);
|
||||||
|
|
||||||
|
const allowed = buildFixture();
|
||||||
|
const allowedHandler = createTurnDaemonCommandHandler({ world: allowed });
|
||||||
|
await expect(
|
||||||
|
allowedHandler.handle({
|
||||||
|
type: 'troopKick',
|
||||||
|
generalId: 1,
|
||||||
|
troopId: 1,
|
||||||
|
targetGeneralId: 3,
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({ ok: true, targetGeneralId: 3 });
|
||||||
|
expect(allowed.getGeneralById(3)?.troopId).toBe(0);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
allowedHandler.handle({
|
||||||
|
type: 'troopKick',
|
||||||
|
generalId: 1,
|
||||||
|
troopId: 1,
|
||||||
|
targetGeneralId: 1,
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({ ok: false, reason: '부대장을 추방할 수 없습니다.' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renames for the leader or a same-nation top-secret actor and honors penalties', async () => {
|
||||||
|
const leaderWorld = buildWorld({
|
||||||
|
generals: [buildGeneral(1, { troopId: 1, penalty: { noTopSecret: true } })],
|
||||||
|
troops: [{ id: 1, nationId: 1, name: '구대' }],
|
||||||
|
});
|
||||||
|
const leaderHandler = createTurnDaemonCommandHandler({ world: leaderWorld });
|
||||||
|
await expect(
|
||||||
|
leaderHandler.handle({ type: 'troopRename', generalId: 1, troopId: 1, troopName: '신대' })
|
||||||
|
).resolves.toMatchObject({ ok: true, troopName: '신대' });
|
||||||
|
|
||||||
|
const managerWorld = buildWorld({
|
||||||
|
generals: [
|
||||||
|
buildGeneral(1, { troopId: 1 }),
|
||||||
|
buildGeneral(2, { meta: { killturn: 24, permission: 'ambassador' } }),
|
||||||
|
],
|
||||||
|
troops: [{ id: 1, nationId: 1, name: '구대' }],
|
||||||
|
});
|
||||||
|
const managerHandler = createTurnDaemonCommandHandler({ world: managerWorld });
|
||||||
|
await expect(
|
||||||
|
managerHandler.handle({ type: 'troopRename', generalId: 2, troopId: 1, troopName: '신대' })
|
||||||
|
).resolves.toMatchObject({ ok: true, troopName: '신대' });
|
||||||
|
|
||||||
|
const penalizedWorld = buildWorld({
|
||||||
|
generals: [
|
||||||
|
buildGeneral(1, { troopId: 1 }),
|
||||||
|
buildGeneral(2, {
|
||||||
|
meta: { killturn: 24, permission: 'ambassador' },
|
||||||
|
penalty: { noTopSecret: true },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
troops: [{ id: 1, nationId: 1, name: '구대' }],
|
||||||
|
});
|
||||||
|
const penalizedHandler = createTurnDaemonCommandHandler({ world: penalizedWorld });
|
||||||
|
await expect(
|
||||||
|
penalizedHandler.handle({ type: 'troopRename', generalId: 2, troopId: 1, troopName: '신대' })
|
||||||
|
).resolves.toMatchObject({ ok: false, reason: '권한이 부족합니다.' });
|
||||||
|
expect(penalizedWorld.getTroopById(1)?.name).toBe('구대');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
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)), '../../..');
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: '.',
|
||||||
|
testMatch: 'troop.spec.ts',
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
timeout: 30_000,
|
||||||
|
expect: {
|
||||||
|
timeout: 5_000,
|
||||||
|
},
|
||||||
|
reporter: [['list'], ['html', { open: 'never', outputFolder: resolve(repositoryRoot, 'playwright-report') }]],
|
||||||
|
outputDir: resolve(repositoryRoot, 'test-results/troop'),
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://127.0.0.1:15120/che/',
|
||||||
|
...devices['Desktop Chrome'],
|
||||||
|
deviceScaleFactor: 1,
|
||||||
|
colorScheme: 'dark',
|
||||||
|
trace: 'retain-on-failure',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
},
|
||||||
|
webServer: {
|
||||||
|
command:
|
||||||
|
'VITE_APP_BASE_PATH=/che VITE_GAME_API_URL=/che/api/trpc pnpm --filter @sammo-ts/game-frontend dev --host 127.0.0.1 --port 15120',
|
||||||
|
cwd: repositoryRoot,
|
||||||
|
url: 'http://127.0.0.1:15120/che/',
|
||||||
|
reuseExistingServer: false,
|
||||||
|
timeout: 120_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const imageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../../../../image/game');
|
||||||
|
|
||||||
|
type Member = { id: number; name: string; cityId: number; cityName: string };
|
||||||
|
type TroopFixture = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
nationId: number;
|
||||||
|
turnTime: string;
|
||||||
|
reservedCommands: string[];
|
||||||
|
leader: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
cityId: number;
|
||||||
|
cityName: string;
|
||||||
|
picture: string | null;
|
||||||
|
imageServer: number;
|
||||||
|
};
|
||||||
|
members: Member[];
|
||||||
|
};
|
||||||
|
type FixtureState = {
|
||||||
|
me: { id: number; troopId: number };
|
||||||
|
permission: number;
|
||||||
|
troops: TroopFixture[];
|
||||||
|
failCreate?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseTroops = (): TroopFixture[] => [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: '백마대',
|
||||||
|
nationId: 1,
|
||||||
|
turnTime: '2026-07-25T08:20:30.000Z',
|
||||||
|
reservedCommands: ['che_집합', 'che_이동'],
|
||||||
|
leader: {
|
||||||
|
id: 1,
|
||||||
|
name: '공손찬',
|
||||||
|
cityId: 1,
|
||||||
|
cityName: '북평',
|
||||||
|
picture: 'default.jpg',
|
||||||
|
imageServer: 0,
|
||||||
|
},
|
||||||
|
members: [
|
||||||
|
{ id: 1, name: '공손찬', cityId: 1, cityName: '북평' },
|
||||||
|
{ id: 3, name: '조운', cityId: 1, cityName: '북평' },
|
||||||
|
{ id: 4, name: '전예', cityId: 2, cityName: '계' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: '청룡대',
|
||||||
|
nationId: 1,
|
||||||
|
turnTime: '2026-07-25T08:30:30.000Z',
|
||||||
|
reservedCommands: ['che_징병'],
|
||||||
|
leader: {
|
||||||
|
id: 2,
|
||||||
|
name: '관우',
|
||||||
|
cityId: 2,
|
||||||
|
cityName: '계',
|
||||||
|
picture: 'default.jpg',
|
||||||
|
imageServer: 0,
|
||||||
|
},
|
||||||
|
members: [{ id: 2, name: '관우', cityId: 2, cityName: '계' }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const response = (data: unknown) => ({ result: { data } });
|
||||||
|
const errorResponse = (path: string, message: string) => ({
|
||||||
|
error: {
|
||||||
|
message,
|
||||||
|
code: -32000,
|
||||||
|
data: { code: 'BAD_REQUEST', httpStatus: 400, path },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const operationName = (route: Route): string => {
|
||||||
|
const url = new URL(route.request().url());
|
||||||
|
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6));
|
||||||
|
};
|
||||||
|
|
||||||
|
const fulfillJson = async (route: Route, body: unknown) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const gotoTroop = async (page: Page) => {
|
||||||
|
const lobbyResponse = page.waitForResponse((response) => response.url().includes('/trpc/lobby.info'));
|
||||||
|
await page.goto('troop');
|
||||||
|
await lobbyResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
const installApiFixture = async (page: Page, state: FixtureState) => {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
window.localStorage.setItem('sammo-game-token', 'ga_playwright');
|
||||||
|
window.localStorage.setItem('sammo-game-profile', 'che:default');
|
||||||
|
});
|
||||||
|
for (const filename of ['back_walnut.jpg', 'back_green.jpg']) {
|
||||||
|
await page.route(`**/image/game/${filename}`, async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
body: await readFile(resolve(imageRoot, filename)),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await page.route('**/image/icons/**', async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'image/png',
|
||||||
|
body: Buffer.from(
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||||
|
'base64'
|
||||||
|
),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route('**/che/api/trpc/**', async (route) => {
|
||||||
|
const operations = operationName(route).split(',');
|
||||||
|
const results = operations.map((operation) => {
|
||||||
|
if (operation === 'lobby.info') {
|
||||||
|
return response({ myGeneral: { id: state.me.id, name: '테스트 장수' } });
|
||||||
|
}
|
||||||
|
if (operation === 'join.getConfig') {
|
||||||
|
return response({});
|
||||||
|
}
|
||||||
|
if (operation === 'troop.getList') {
|
||||||
|
return response({
|
||||||
|
nation: { id: 1, name: '테스트국' },
|
||||||
|
me: state.me,
|
||||||
|
permission: state.permission,
|
||||||
|
troops: state.troops,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (operation === 'troop.create') {
|
||||||
|
if (state.failCreate) {
|
||||||
|
state.failCreate = false;
|
||||||
|
return errorResponse(operation, '부대 이름이 없습니다.');
|
||||||
|
}
|
||||||
|
const createdId = state.me.id;
|
||||||
|
state.me.troopId = createdId;
|
||||||
|
state.troops.push({
|
||||||
|
id: createdId,
|
||||||
|
name: '신규대',
|
||||||
|
nationId: 1,
|
||||||
|
turnTime: '2026-07-25T08:40:30.000Z',
|
||||||
|
reservedCommands: [],
|
||||||
|
leader: {
|
||||||
|
id: createdId,
|
||||||
|
name: '유비',
|
||||||
|
cityId: 1,
|
||||||
|
cityName: '북평',
|
||||||
|
picture: 'default.jpg',
|
||||||
|
imageServer: 0,
|
||||||
|
},
|
||||||
|
members: [{ id: createdId, name: '유비', cityId: 1, cityName: '북평' }],
|
||||||
|
});
|
||||||
|
return response({ ok: true, troopId: createdId, troopName: '신규대' });
|
||||||
|
}
|
||||||
|
if (operation === 'troop.rename') {
|
||||||
|
state.troops[0]!.name = '백마의종';
|
||||||
|
return response({ ok: true, troopName: '백마의종' });
|
||||||
|
}
|
||||||
|
if (operation === 'troop.kick') {
|
||||||
|
state.troops[0]!.members = state.troops[0]!.members.filter((member) => member.id !== 3);
|
||||||
|
return response({ ok: true });
|
||||||
|
}
|
||||||
|
return errorResponse(operation, `Unhandled fixture operation: ${operation}`);
|
||||||
|
});
|
||||||
|
await fulfillJson(route, results);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
test('renders the legacy desktop grid with matching computed geometry and states', async ({ page }) => {
|
||||||
|
await installApiFixture(page, {
|
||||||
|
me: { id: 1, troopId: 1 },
|
||||||
|
permission: 4,
|
||||||
|
troops: baseTroops(),
|
||||||
|
});
|
||||||
|
await page.setViewportSize({ width: 1000, height: 800 });
|
||||||
|
await gotoTroop(page);
|
||||||
|
await expect(page.locator('.troopInfo').filter({ hasText: '백마대' })).toBeVisible();
|
||||||
|
|
||||||
|
const geometry = await page
|
||||||
|
.locator('.troopItem')
|
||||||
|
.first()
|
||||||
|
.evaluate((item) => {
|
||||||
|
const origin = item.getBoundingClientRect();
|
||||||
|
const box = (selector: string) => {
|
||||||
|
const rect = item.querySelector<HTMLElement>(selector)!.getBoundingClientRect();
|
||||||
|
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
||||||
|
};
|
||||||
|
const members = item.querySelector<HTMLElement>('.troopMembers')!;
|
||||||
|
const style = getComputedStyle(members);
|
||||||
|
return {
|
||||||
|
item: { x: origin.x, y: origin.y, width: origin.width, height: origin.height },
|
||||||
|
info: box('.troopInfo'),
|
||||||
|
icon: box('.troopLeaderIcon'),
|
||||||
|
reserved: box('.troopReservedCommand'),
|
||||||
|
members: box('.troopMembers'),
|
||||||
|
action: box('.troopAction'),
|
||||||
|
membersStyle: {
|
||||||
|
paddingTop: style.paddingTop,
|
||||||
|
paddingLeft: style.paddingLeft,
|
||||||
|
textAlign: style.textAlign,
|
||||||
|
fontFamily: style.fontFamily,
|
||||||
|
fontSize: style.fontSize,
|
||||||
|
lineHeight: style.lineHeight,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(geometry.item).toEqual({ x: 0, y: 32, width: 1000, height: 127.5 });
|
||||||
|
expect(geometry.info.width).toBeCloseTo(130, 0);
|
||||||
|
expect(geometry.info.height).toBeCloseTo(65, 0);
|
||||||
|
expect(geometry.icon.x - geometry.info.x).toBeCloseTo(130, 0);
|
||||||
|
expect(geometry.reserved.x - geometry.info.x).toBeCloseTo(260, 0);
|
||||||
|
expect(geometry.members.x - geometry.info.x).toBeCloseTo(360, 0);
|
||||||
|
expect(geometry.members.width).toBeCloseTo(639, 0);
|
||||||
|
expect(geometry.members.height).toBeCloseTo(93, 0);
|
||||||
|
expect(geometry.action.x - geometry.info.x).toBeCloseTo(65, 0);
|
||||||
|
expect(geometry.action.y - geometry.info.y).toBeCloseTo(93, 0);
|
||||||
|
expect(geometry.action.width).toBeCloseTo(934, 0);
|
||||||
|
expect(geometry.membersStyle).toEqual({
|
||||||
|
paddingTop: '7px',
|
||||||
|
paddingLeft: '9.8px',
|
||||||
|
textAlign: 'left',
|
||||||
|
fontFamily: 'Pretendard, "Apple SD Gothic Neo", "Noto Sans KR", "Malgun Gothic"',
|
||||||
|
fontSize: '14px',
|
||||||
|
lineHeight: '21px',
|
||||||
|
});
|
||||||
|
|
||||||
|
const kickButton = page.getByRole('button', { name: '부대원 추방...' }).first();
|
||||||
|
await kickButton.hover();
|
||||||
|
const hoverStyle = await kickButton.evaluate((button) => ({
|
||||||
|
cursor: getComputedStyle(button).cursor,
|
||||||
|
filter: getComputedStyle(button).filter,
|
||||||
|
}));
|
||||||
|
expect(hoverStyle.cursor).toBe('pointer');
|
||||||
|
expect(hoverStyle.filter).not.toBe('none');
|
||||||
|
|
||||||
|
await page.locator('.troopMember').nth(1).hover();
|
||||||
|
await expect(page.getByRole('tooltip')).toContainText('조운');
|
||||||
|
expect(await page.getByRole('tooltip').evaluate((tooltip) => tooltip.getBoundingClientRect().width)).toBeCloseTo(
|
||||||
|
500,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
await page.screenshot({ path: 'test-results/troop/desktop-leader.png', fullPage: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('matches the legacy 500px responsive placement', async ({ page }) => {
|
||||||
|
await installApiFixture(page, {
|
||||||
|
me: { id: 1, troopId: 1 },
|
||||||
|
permission: 4,
|
||||||
|
troops: baseTroops(),
|
||||||
|
});
|
||||||
|
await page.setViewportSize({ width: 500, height: 800 });
|
||||||
|
await gotoTroop(page);
|
||||||
|
await expect(page.locator('.troopInfo').filter({ hasText: '백마대' })).toBeVisible();
|
||||||
|
|
||||||
|
const geometry = await page
|
||||||
|
.locator('.troopItem')
|
||||||
|
.first()
|
||||||
|
.evaluate((item) => {
|
||||||
|
const origin = item.getBoundingClientRect();
|
||||||
|
const relative = (selector: string) => {
|
||||||
|
const rect = item.querySelector<HTMLElement>(selector)!.getBoundingClientRect();
|
||||||
|
return { x: rect.x - origin.x, y: rect.y - origin.y, width: rect.width };
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
item: { width: origin.width, height: origin.height },
|
||||||
|
info: relative('.troopInfo'),
|
||||||
|
icon: relative('.troopLeaderIcon'),
|
||||||
|
reserved: relative('.troopReservedCommand'),
|
||||||
|
action: relative('.troopAction'),
|
||||||
|
members: relative('.troopMembers'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(geometry.item).toEqual({ width: 500, height: 129 });
|
||||||
|
expect(geometry.info).toMatchObject({ x: 0, y: 0, width: 130 });
|
||||||
|
expect(geometry.icon).toMatchObject({ x: 130, y: 0, width: 130 });
|
||||||
|
expect(geometry.reserved).toMatchObject({ x: 260, y: 0, width: 100 });
|
||||||
|
expect(geometry.action).toMatchObject({ x: 360, y: 0, width: 140 });
|
||||||
|
expect(geometry.members).toMatchObject({ x: 130, y: 93, width: 370 });
|
||||||
|
await page.screenshot({ path: 'test-results/troop/mobile-leader.png', fullPage: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows API failure then creates a troop successfully', async ({ page }) => {
|
||||||
|
const state: FixtureState = {
|
||||||
|
me: { id: 7, troopId: 0 },
|
||||||
|
permission: 0,
|
||||||
|
troops: baseTroops(),
|
||||||
|
failCreate: true,
|
||||||
|
};
|
||||||
|
await installApiFixture(page, state);
|
||||||
|
await gotoTroop(page);
|
||||||
|
|
||||||
|
const input = page.getByRole('textbox', { name: '부대명' });
|
||||||
|
await input.fill('실패대');
|
||||||
|
await page.getByRole('button', { name: '부대 창설', exact: true }).click();
|
||||||
|
await expect(page.getByRole('alert')).toContainText('부대 이름이 없습니다.');
|
||||||
|
expect(state.me.troopId).toBe(0);
|
||||||
|
|
||||||
|
await input.fill('신규대');
|
||||||
|
await page.getByRole('button', { name: '부대 창설', exact: true }).click();
|
||||||
|
await expect(page.getByRole('status')).toContainText('신규대 부대가 생성되었습니다.');
|
||||||
|
await expect(page.locator('.troopInfo').filter({ hasText: '신규대' })).toBeVisible();
|
||||||
|
await expect(input).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renames and kicks through confirm dialogs, then refreshes state', async ({ page }) => {
|
||||||
|
const state: FixtureState = {
|
||||||
|
me: { id: 1, troopId: 1 },
|
||||||
|
permission: 4,
|
||||||
|
troops: baseTroops(),
|
||||||
|
};
|
||||||
|
await installApiFixture(page, state);
|
||||||
|
page.on('dialog', (dialog) => dialog.accept());
|
||||||
|
await gotoTroop(page);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '부대명 변경...' }).first().click();
|
||||||
|
await page.getByRole('textbox', { name: '새 부대명' }).fill('백마의종');
|
||||||
|
await page.getByRole('button', { name: '변경', exact: true }).click();
|
||||||
|
await expect(page.getByRole('status')).toContainText('부대명을 변경했습니다.');
|
||||||
|
await expect(page.locator('.troopInfo').filter({ hasText: '백마의종' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '부대원 추방...' }).click();
|
||||||
|
await page.getByRole('combobox', { name: '추방할 부대원' }).selectOption('3');
|
||||||
|
await page.getByRole('button', { name: '추방', exact: true }).click();
|
||||||
|
await expect(page.getByRole('status')).toContainText('조운을 추방했습니다.');
|
||||||
|
await expect(page.locator('.troopMembers').first()).not.toContainText('조운');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not render management controls for an unauthorized member', async ({ page }) => {
|
||||||
|
await installApiFixture(page, {
|
||||||
|
me: { id: 3, troopId: 1 },
|
||||||
|
permission: 1,
|
||||||
|
troops: baseTroops(),
|
||||||
|
});
|
||||||
|
await gotoTroop(page);
|
||||||
|
await expect(page.getByRole('button', { name: '부대 탈퇴' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: '부대원 추방...' })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole('button', { name: '부대명 변경...' })).toHaveCount(0);
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vue-tsc && vite build",
|
"build": "vue-tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
|
"test:e2e:troop": "playwright test --config e2e/playwright.config.mjs",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"lint:fix": "eslint . --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"test": "node -e \"console.log('test not configured')\"",
|
"test": "node -e \"console.log('test not configured')\"",
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { createPinia } from 'pinia';
|
|||||||
import App from './App.vue';
|
import App from './App.vue';
|
||||||
import router from './router';
|
import router from './router';
|
||||||
import './assets/main.css';
|
import './assets/main.css';
|
||||||
import { useSessionStore } from './stores/session';
|
|
||||||
|
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
|
|
||||||
@@ -11,7 +10,4 @@ const pinia = createPinia();
|
|||||||
app.use(pinia);
|
app.use(pinia);
|
||||||
app.use(router);
|
app.use(router);
|
||||||
|
|
||||||
const session = useSessionStore(pinia);
|
|
||||||
void session.initialize();
|
|
||||||
|
|
||||||
app.mount('#app');
|
app.mount('#app');
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import HallOfFameView from '../views/HallOfFameView.vue';
|
|||||||
import DynastyListView from '../views/DynastyListView.vue';
|
import DynastyListView from '../views/DynastyListView.vue';
|
||||||
import DynastyDetailView from '../views/DynastyDetailView.vue';
|
import DynastyDetailView from '../views/DynastyDetailView.vue';
|
||||||
import SurveyView from '../views/SurveyView.vue';
|
import SurveyView from '../views/SurveyView.vue';
|
||||||
|
import TroopView from '../views/TroopView.vue';
|
||||||
import { useSessionStore } from '../stores/session';
|
import { useSessionStore } from '../stores/session';
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
@@ -60,6 +61,15 @@ const routes = [
|
|||||||
requiresGeneral: true,
|
requiresGeneral: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/troop',
|
||||||
|
name: 'troop',
|
||||||
|
component: TroopView,
|
||||||
|
meta: {
|
||||||
|
requiresAuth: true,
|
||||||
|
requiresGeneral: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/nation/cities',
|
path: '/nation/cities',
|
||||||
name: 'nation-cities',
|
name: 'nation-cities',
|
||||||
|
|||||||
@@ -162,6 +162,11 @@ export const useSessionStore = defineStore('session', {
|
|||||||
this.setSessionToken(storedToken);
|
this.setSessionToken(storedToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const storedGameToken = this.gameToken ?? readStorage(GAME_TOKEN_KEY);
|
||||||
|
if (storedGameToken && storedGameToken !== this.gameToken) {
|
||||||
|
this.setGameToken(storedGameToken);
|
||||||
|
}
|
||||||
|
|
||||||
const storedProfile = this.profile ?? readStorage(PROFILE_KEY) ?? import.meta.env.VITE_GAME_PROFILE;
|
const storedProfile = this.profile ?? readStorage(PROFILE_KEY) ?? import.meta.env.VITE_GAME_PROFILE;
|
||||||
if (storedProfile && storedProfile !== this.profile) {
|
if (storedProfile && storedProfile !== this.profile) {
|
||||||
this.setProfile(storedProfile);
|
this.setProfile(storedProfile);
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ watch(
|
|||||||
<RouterLink class="ghost" to="/nation/cities">세력 도시</RouterLink>
|
<RouterLink class="ghost" to="/nation/cities">세력 도시</RouterLink>
|
||||||
<RouterLink class="ghost" to="/nation/generals">세력 장수</RouterLink>
|
<RouterLink class="ghost" to="/nation/generals">세력 장수</RouterLink>
|
||||||
<RouterLink class="ghost" to="/nation/personnel">인사부</RouterLink>
|
<RouterLink class="ghost" to="/nation/personnel">인사부</RouterLink>
|
||||||
|
<RouterLink class="ghost" to="/troop">부대 편성</RouterLink>
|
||||||
<RouterLink class="ghost" to="/nation/affairs">내무부</RouterLink>
|
<RouterLink class="ghost" to="/nation/affairs">내무부</RouterLink>
|
||||||
<RouterLink class="ghost" to="/diplomacy">외교부</RouterLink>
|
<RouterLink class="ghost" to="/diplomacy">외교부</RouterLink>
|
||||||
<RouterLink class="ghost" to="/chief-center">사령부</RouterLink>
|
<RouterLink class="ghost" to="/chief-center">사령부</RouterLink>
|
||||||
|
|||||||
@@ -0,0 +1,678 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
|
||||||
|
import { trpc } from '../utils/trpc';
|
||||||
|
|
||||||
|
type TroopList = Awaited<ReturnType<typeof trpc.troop.getList.query>>;
|
||||||
|
type Troop = TroopList['troops'][number];
|
||||||
|
type Member = Troop['members'][number];
|
||||||
|
type DialogKind = 'rename' | 'kick' | null;
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const data = ref<TroopList | null>(null);
|
||||||
|
const errorMessage = ref('');
|
||||||
|
const noticeMessage = ref('');
|
||||||
|
const noticeKind = ref<'success' | 'error'>('success');
|
||||||
|
const createName = ref('');
|
||||||
|
const editName = ref('');
|
||||||
|
const kickTargetId = ref(0);
|
||||||
|
const dialogKind = ref<DialogKind>(null);
|
||||||
|
const dialogTroopId = ref(0);
|
||||||
|
const popupMember = ref<Member | null>(null);
|
||||||
|
const popupTop = ref(0);
|
||||||
|
|
||||||
|
const me = computed(() => data.value?.me ?? null);
|
||||||
|
|
||||||
|
const getErrorMessage = (error: unknown): string => {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
if (typeof error === 'string') {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
return '요청을 처리하지 못했습니다.';
|
||||||
|
};
|
||||||
|
|
||||||
|
const showNotice = (message: string, kind: 'success' | 'error') => {
|
||||||
|
noticeMessage.value = message;
|
||||||
|
noticeKind.value = kind;
|
||||||
|
};
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
if (loading.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
errorMessage.value = '';
|
||||||
|
try {
|
||||||
|
data.value = await trpc.troop.getList.query();
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = getErrorMessage(error);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runAction = async (action: () => Promise<void>) => {
|
||||||
|
errorMessage.value = '';
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
await refresh();
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
errorMessage.value = message;
|
||||||
|
showNotice(message, 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeTroop = async () => {
|
||||||
|
const troopName = createName.value;
|
||||||
|
await runAction(async () => {
|
||||||
|
await trpc.troop.create.mutate({ troopName });
|
||||||
|
createName.value = '';
|
||||||
|
showNotice(`${troopName} 부대가 생성되었습니다.`, 'success');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const joinTroop = async (troop: Troop) => {
|
||||||
|
await runAction(async () => {
|
||||||
|
await trpc.troop.join.mutate({ troopId: troop.id });
|
||||||
|
showNotice(` ${troop.name} 부대에 가입했습니다.`, 'success');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const exitTroop = async (troop: Troop) => {
|
||||||
|
const isLeader = me.value?.id === troop.id;
|
||||||
|
const prompt = isLeader ? `${troop.name} 부대를 해산하겠습니까?` : `${troop.name} 부대에서 탈퇴하겠습니까?`;
|
||||||
|
if (!window.confirm(prompt)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await runAction(async () => {
|
||||||
|
await trpc.troop.exit.mutate();
|
||||||
|
showNotice(isLeader ? '부대를 해산했습니다.' : '부대에서 탈퇴했습니다.', 'success');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const openRename = (troop: Troop) => {
|
||||||
|
dialogKind.value = 'rename';
|
||||||
|
dialogTroopId.value = troop.id;
|
||||||
|
editName.value = troop.name;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openKick = (troop: Troop) => {
|
||||||
|
dialogKind.value = 'kick';
|
||||||
|
dialogTroopId.value = troop.id;
|
||||||
|
kickTargetId.value = troop.members.find((member) => member.id !== troop.id)?.id ?? 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDialog = () => {
|
||||||
|
dialogKind.value = null;
|
||||||
|
dialogTroopId.value = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasFinalConsonant = (value: string): boolean => {
|
||||||
|
const last = Array.from(value.trim()).at(-1);
|
||||||
|
if (!last) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const code = last.codePointAt(0);
|
||||||
|
return code !== undefined && code >= 0xac00 && code <= 0xd7a3 && (code - 0xac00) % 28 !== 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renameTroop = async (troop: Troop) => {
|
||||||
|
const troopName = editName.value;
|
||||||
|
const particle = hasFinalConsonant(troopName) ? '으로' : '로';
|
||||||
|
if (!window.confirm(`${troop.name} 부대의 이름을 ${troopName}${particle} 바꾸시겠습니까?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await runAction(async () => {
|
||||||
|
await trpc.troop.rename.mutate({ troopId: troop.id, troopName });
|
||||||
|
closeDialog();
|
||||||
|
showNotice('부대명을 변경했습니다.', 'success');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const kickMember = async (troop: Troop) => {
|
||||||
|
const member = troop.members.find((candidate) => candidate.id === kickTargetId.value);
|
||||||
|
if (!member) {
|
||||||
|
showNotice('잘못된 접근입니다.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const particle = hasFinalConsonant(member.name) ? '을' : '를';
|
||||||
|
if (!window.confirm(`${troop.name} 부대에서 ${member.name}${particle} 추방하시겠습니까?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await runAction(async () => {
|
||||||
|
await trpc.troop.kick.mutate({ troopId: troop.id, targetGeneralId: member.id });
|
||||||
|
closeDialog();
|
||||||
|
showNotice(`${member.name}${particle} 추방했습니다.`, 'success');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const showMemberPopup = (event: MouseEvent, member: Member) => {
|
||||||
|
const row = (event.currentTarget as HTMLElement).closest('.troopMembers') as HTMLElement | null;
|
||||||
|
popupMember.value = member;
|
||||||
|
popupTop.value = row ? row.offsetTop + row.offsetHeight : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hideMemberPopup = () => {
|
||||||
|
popupMember.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconPath = (troop: Troop): string => {
|
||||||
|
const picture = troop.leader?.picture || 'default.jpg';
|
||||||
|
return troop.leader?.imageServer ? `${import.meta.env.BASE_URL}d_pic/${picture}` : `/image/icons/${picture}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTurn = (turnTime: string | null): string => {
|
||||||
|
if (!turnTime) {
|
||||||
|
return '--:--';
|
||||||
|
}
|
||||||
|
return turnTime.slice(14, 19);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void refresh();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main id="container" class="legacy-troop-page">
|
||||||
|
<header class="topBackBar bg0">
|
||||||
|
<RouterLink class="btn legacyNavButton backLink" to="/">돌아가기</RouterLink>
|
||||||
|
<button class="btn legacyNavButton reloadButton" type="button" :disabled="loading" @click="refresh">
|
||||||
|
갱신
|
||||||
|
</button>
|
||||||
|
<h2>부대 편성</h2>
|
||||||
|
<div></div>
|
||||||
|
<div></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="noticeMessage"
|
||||||
|
class="notice"
|
||||||
|
:class="noticeKind"
|
||||||
|
:role="noticeKind === 'error' ? 'alert' : 'status'"
|
||||||
|
>
|
||||||
|
{{ noticeMessage }}
|
||||||
|
</div>
|
||||||
|
<div v-if="errorMessage && !noticeMessage" class="notice error" role="alert">{{ errorMessage }}</div>
|
||||||
|
<div v-if="loading && !data" class="loading">불러오는 중...</div>
|
||||||
|
|
||||||
|
<div v-if="data" id="troopList" class="bg0">
|
||||||
|
<div v-for="troop in data.troops" :key="troop.id" class="troopItem" :data-troop-id="troop.id">
|
||||||
|
<div class="troopInfo">
|
||||||
|
{{ troop.name }}<br />
|
||||||
|
【 {{ troop.leader?.cityName ?? '알 수 없음' }} 】
|
||||||
|
</div>
|
||||||
|
<div class="troopTurn">【턴】 {{ formatTurn(troop.turnTime) }}</div>
|
||||||
|
<div class="troopLeaderIcon">
|
||||||
|
<img
|
||||||
|
height="64"
|
||||||
|
width="64"
|
||||||
|
:src="iconPath(troop)"
|
||||||
|
:alt="`${troop.leader?.name ?? '부대장'} 아이콘`"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="troopLeaderName">{{ troop.leader?.name ?? '알 수 없음' }}</div>
|
||||||
|
<div class="troopReservedCommand">
|
||||||
|
<div v-for="(brief, index) in troop.reservedCommands" :key="`${troop.id}-${index}`">
|
||||||
|
{{ `${index + 1}: ${brief}` }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="troopMembers">
|
||||||
|
<template v-for="(member, index) in troop.members" :key="member.id">
|
||||||
|
<template v-if="index !== 0">, </template>
|
||||||
|
<span
|
||||||
|
class="troopMember"
|
||||||
|
:class="{
|
||||||
|
troopLeader: member.id === troop.id,
|
||||||
|
troopDiffCityMemeber: member.cityId !== troop.leader?.cityId,
|
||||||
|
}"
|
||||||
|
@mouseenter="showMemberPopup($event, member)"
|
||||||
|
@mouseleave="hideMemberPopup"
|
||||||
|
>
|
||||||
|
{{ member.name
|
||||||
|
}}<template v-if="member.cityId !== troop.leader?.cityId">
|
||||||
|
({{ member.cityName }})</template
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
({{ troop.members.length }}명)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="troopAction">
|
||||||
|
<div v-if="dialogKind === null || dialogTroopId !== troop.id" class="actionButtons">
|
||||||
|
<button v-if="data.me.troopId === 0" class="btn btn-primary" @click="joinTroop(troop)">
|
||||||
|
부대 탑승
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="data.me.troopId === troop.id"
|
||||||
|
class="btn"
|
||||||
|
:class="data.me.id === data.me.troopId ? 'btn-danger' : 'btn-primary'"
|
||||||
|
@click="exitTroop(troop)"
|
||||||
|
>
|
||||||
|
{{ data.me.id === data.me.troopId ? '부대 해산' : '부대 탈퇴' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="data.me.troopId === troop.id && data.me.id === data.me.troopId"
|
||||||
|
class="btn btn-secondary"
|
||||||
|
@click="openKick(troop)"
|
||||||
|
>
|
||||||
|
부대원 추방...
|
||||||
|
</button>
|
||||||
|
<button v-if="data.permission >= 4" class="btn btn-info" @click="openRename(troop)">
|
||||||
|
부대명 변경...
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="dialogKind === 'rename'" class="subDialog renameDialog">
|
||||||
|
<div class="subTitle bg1 center"><span>부대명 변경</span></div>
|
||||||
|
<div class="subForm">
|
||||||
|
<input v-model.trim="editName" class="formControl" type="text" aria-label="새 부대명" />
|
||||||
|
</div>
|
||||||
|
<div class="subBtnCancel">
|
||||||
|
<button class="btn btn-secondary" @click="closeDialog">취소</button>
|
||||||
|
</div>
|
||||||
|
<div class="subBtnOK">
|
||||||
|
<button class="btn btn-primary" @click="renameTroop(troop)">변경</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="subDialog kickDialog">
|
||||||
|
<div class="subTitle bg1 center"><span>부대원 추방</span></div>
|
||||||
|
<div class="subForm">
|
||||||
|
<select v-model.number="kickTargetId" class="formControl" aria-label="추방할 부대원">
|
||||||
|
<option
|
||||||
|
v-for="member in troop.members.filter((candidate) => candidate.id !== troop.id)"
|
||||||
|
:key="member.id"
|
||||||
|
:value="member.id"
|
||||||
|
>
|
||||||
|
{{ member.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="subBtnCancel">
|
||||||
|
<button class="btn btn-secondary" @click="closeDialog">취소</button>
|
||||||
|
</div>
|
||||||
|
<div class="subBtnOK">
|
||||||
|
<button class="btn btn-primary" @click="kickMember(troop)">추방</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="filler"><span class="dummy"></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="data" class="additionalTroopOptions">
|
||||||
|
<div v-if="data.me.troopId === 0" class="makeNewTroop">
|
||||||
|
<div class="makeTitle bg1 center">부대 창설</div>
|
||||||
|
<input v-model.trim="createName" class="formControl troopNameField" type="text" aria-label="부대명" />
|
||||||
|
<button class="btn btn-secondary createButton" @click="makeTroop">부대 창설</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="bottomBar bg0">
|
||||||
|
<RouterLink class="btn legacyNavButton backLink" to="/">돌아가기</RouterLink>
|
||||||
|
<div></div>
|
||||||
|
</footer>
|
||||||
|
<div v-if="popupMember" id="generalPopup" :style="{ top: `${popupTop}px` }" role="tooltip">
|
||||||
|
<strong>{{ popupMember.name }}</strong>
|
||||||
|
<span>{{ popupMember.cityName }}</span>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.legacy-troop-page {
|
||||||
|
position: relative;
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0 auto;
|
||||||
|
color: #fff;
|
||||||
|
background: #000;
|
||||||
|
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic';
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg0 {
|
||||||
|
background-image: url('/image/game/back_walnut.jpg');
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg1 {
|
||||||
|
background-image: url('/image/game/back_green.jpg');
|
||||||
|
}
|
||||||
|
|
||||||
|
.center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topBackBar {
|
||||||
|
width: 100%;
|
||||||
|
height: 32px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 90px 90px 1fr 90px 90px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topBackBar h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 32px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.legacyNavButton {
|
||||||
|
height: 32px;
|
||||||
|
margin-right: 2px;
|
||||||
|
border-color: #004f28;
|
||||||
|
color: #fff;
|
||||||
|
background: #00582c;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid #477a47;
|
||||||
|
color: #d8f5d8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice.error {
|
||||||
|
border-color: #9b4848;
|
||||||
|
color: #ffd0d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#generalPopup {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 10;
|
||||||
|
width: 500px;
|
||||||
|
min-height: 58px;
|
||||||
|
padding: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid #999;
|
||||||
|
background: #202020;
|
||||||
|
}
|
||||||
|
|
||||||
|
.additionalTroopOptions {
|
||||||
|
margin-top: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.makeNewTroop {
|
||||||
|
width: 250px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1fr;
|
||||||
|
border: 1px solid gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
.makeTitle {
|
||||||
|
grid-column: 1/3;
|
||||||
|
padding: 0.15em;
|
||||||
|
font-size: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.troopDiffCityMemeber {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
|
||||||
|
.troopLeader {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.troopMember {
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
.troopItem {
|
||||||
|
display: grid;
|
||||||
|
border-right: 1px solid gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
.troopItem > div {
|
||||||
|
border-top: 1px solid gray;
|
||||||
|
border-left: 1px solid gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
.troopInfo {
|
||||||
|
grid-column: 1/3;
|
||||||
|
grid-row: 1/2;
|
||||||
|
display: grid;
|
||||||
|
align-content: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.troopTurn {
|
||||||
|
grid-column: 1/3;
|
||||||
|
grid-row: 2/3;
|
||||||
|
display: grid;
|
||||||
|
align-content: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.troopLeaderIcon {
|
||||||
|
grid-column: 3/4;
|
||||||
|
grid-row: 1/2;
|
||||||
|
display: grid;
|
||||||
|
align-content: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.troopLeaderIcon img {
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.troopLeaderName {
|
||||||
|
grid-column: 3/4;
|
||||||
|
grid-row: 2/3;
|
||||||
|
display: grid;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.troopReservedCommand {
|
||||||
|
grid-column: 4/5;
|
||||||
|
grid-row: 1/3;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 85%;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.troopMembers {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.5em 0.7em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.troopAction {
|
||||||
|
grid-column: 6/7;
|
||||||
|
grid-row: 1/3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
min-height: 31px;
|
||||||
|
padding: 0.2em 0.75em;
|
||||||
|
border: 1px solid #777;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #eee;
|
||||||
|
background: #555;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
filter: brightness(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:focus-visible {
|
||||||
|
outline: 2px solid #8ab4f8;
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
border-color: #0d6efd;
|
||||||
|
background: #0d6efd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
border-color: #dc3545;
|
||||||
|
background: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-info {
|
||||||
|
border-color: #0dcaf0;
|
||||||
|
color: #111;
|
||||||
|
background: #0dcaf0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
border-color: #6c757d;
|
||||||
|
background: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formControl {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 31px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid #777;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
color: #eee;
|
||||||
|
background: #1b1b1b;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subForm,
|
||||||
|
.subBtnCancel,
|
||||||
|
.subBtnOK {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottomBar {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottomBar .legacyNavButton {
|
||||||
|
width: 70px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 501px) {
|
||||||
|
.legacy-troop-page {
|
||||||
|
width: 1000px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#generalPopup {
|
||||||
|
left: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.troopItem {
|
||||||
|
grid-template-rows: 65px 28px 34.5px;
|
||||||
|
grid-template-columns: 65px 65px 130px 100px 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.troopMembers {
|
||||||
|
grid-column: 5/6;
|
||||||
|
grid-row: 1/3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.troopItem:last-of-type {
|
||||||
|
border-bottom: 1px solid gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filler {
|
||||||
|
grid-column: 1/2;
|
||||||
|
grid-row: 3/4;
|
||||||
|
display: grid;
|
||||||
|
align-content: center;
|
||||||
|
justify-content: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dummy::after {
|
||||||
|
content: '└';
|
||||||
|
padding-right: 1.5ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.troopAction {
|
||||||
|
grid-column: 2/7;
|
||||||
|
grid-row: 3/4;
|
||||||
|
border-left-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionButtons {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 110px 110px 110px;
|
||||||
|
grid-template-rows: 33.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subDialog {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 90px 140px 50px 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subTitle {
|
||||||
|
display: grid;
|
||||||
|
align-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 500px) {
|
||||||
|
.legacy-troop-page {
|
||||||
|
width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#generalPopup {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.troopItem {
|
||||||
|
grid-template-rows: 65px 28px auto;
|
||||||
|
grid-template-columns: 65px 65px 130px 100px 0 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.troopMembers {
|
||||||
|
grid-column: 3/7;
|
||||||
|
grid-row: 3/4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.troopItem:last-of-type .troopMembers {
|
||||||
|
border-bottom: 1px solid gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filler {
|
||||||
|
grid-column: 1/3;
|
||||||
|
grid-row: 3/4;
|
||||||
|
border-top: 1px solid gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionButtons {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subDialog {
|
||||||
|
height: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
grid-template-rows: 1fr 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subTitle,
|
||||||
|
.subForm {
|
||||||
|
grid-column: 1/3;
|
||||||
|
display: grid;
|
||||||
|
align-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.makeNewTroop {
|
||||||
|
width: 250px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -38,7 +38,8 @@
|
|||||||
"src/**/*.d.ts",
|
"src/**/*.d.ts",
|
||||||
"src/**/*.tsx",
|
"src/**/*.tsx",
|
||||||
"src/**/*.vue",
|
"src/**/*.vue",
|
||||||
"test/**/*.ts"
|
"test/**/*.ts",
|
||||||
|
"e2e/**/*.ts"
|
||||||
],
|
],
|
||||||
"references": [
|
"references": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@playwright/test": "1.62.0",
|
||||||
"@types/node": "^26.1.1",
|
"@types/node": "^26.1.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.65.0",
|
"@typescript-eslint/eslint-plugin": "^8.65.0",
|
||||||
"@typescript-eslint/parser": "^8.65.0",
|
"@typescript-eslint/parser": "^8.65.0",
|
||||||
|
|||||||
@@ -52,8 +52,17 @@ export type TurnDaemonCommand =
|
|||||||
| { type: 'resume'; requestId?: string; reason?: string }
|
| { type: 'resume'; requestId?: string; reason?: string }
|
||||||
| { type: 'shutdown'; requestId?: string; reason?: string }
|
| { type: 'shutdown'; requestId?: string; reason?: string }
|
||||||
| { type: 'getStatus'; requestId?: string }
|
| { type: 'getStatus'; requestId?: string }
|
||||||
|
| { type: 'troopCreate'; requestId?: string; generalId: number; troopName: string }
|
||||||
| { type: 'troopJoin'; requestId?: string; generalId: number; troopId: number }
|
| { type: 'troopJoin'; requestId?: string; generalId: number; troopId: number }
|
||||||
| { type: 'troopExit'; requestId?: string; generalId: number }
|
| { type: 'troopExit'; requestId?: string; generalId: number }
|
||||||
|
| {
|
||||||
|
type: 'troopKick';
|
||||||
|
requestId?: string;
|
||||||
|
generalId: number;
|
||||||
|
troopId: number;
|
||||||
|
targetGeneralId: number;
|
||||||
|
}
|
||||||
|
| { type: 'troopRename'; requestId?: string; generalId: number; troopId: number; troopName: string }
|
||||||
| { type: 'dieOnPrestart'; requestId?: string; generalId: number }
|
| { type: 'dieOnPrestart'; requestId?: string; generalId: number }
|
||||||
| { type: 'buildNationCandidate'; requestId?: string; generalId: number }
|
| { type: 'buildNationCandidate'; requestId?: string; generalId: number }
|
||||||
| { type: 'instantRetreat'; requestId?: string; generalId: number }
|
| { type: 'instantRetreat'; requestId?: string; generalId: number }
|
||||||
@@ -204,6 +213,19 @@ export type TurnDaemonCommandResult =
|
|||||||
auctionId: number;
|
auctionId: number;
|
||||||
reason: string;
|
reason: string;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: 'troopCreate';
|
||||||
|
ok: true;
|
||||||
|
generalId: number;
|
||||||
|
troopId: number;
|
||||||
|
troopName: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'troopCreate';
|
||||||
|
ok: false;
|
||||||
|
generalId: number;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: 'troopJoin';
|
type: 'troopJoin';
|
||||||
ok: true;
|
ok: true;
|
||||||
@@ -229,6 +251,35 @@ export type TurnDaemonCommandResult =
|
|||||||
generalId: number;
|
generalId: number;
|
||||||
reason: string;
|
reason: string;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: 'troopKick';
|
||||||
|
ok: true;
|
||||||
|
generalId: number;
|
||||||
|
troopId: number;
|
||||||
|
targetGeneralId: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'troopKick';
|
||||||
|
ok: false;
|
||||||
|
generalId: number;
|
||||||
|
troopId: number;
|
||||||
|
targetGeneralId: number;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'troopRename';
|
||||||
|
ok: true;
|
||||||
|
generalId: number;
|
||||||
|
troopId: number;
|
||||||
|
troopName: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'troopRename';
|
||||||
|
ok: false;
|
||||||
|
generalId: number;
|
||||||
|
troopId: number;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
| { type: 'dieOnPrestart'; ok: boolean; generalId: number; reason?: string }
|
| { type: 'dieOnPrestart'; ok: boolean; generalId: number; reason?: string }
|
||||||
| { type: 'buildNationCandidate'; ok: boolean; generalId: number; reason?: string }
|
| { type: 'buildNationCandidate'; ok: boolean; generalId: number; reason?: string }
|
||||||
| { type: 'instantRetreat'; ok: boolean; generalId: number; reason?: string }
|
| { type: 'instantRetreat'; ok: boolean; generalId: number; reason?: string }
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export interface TurnEngineGeneralRow {
|
|||||||
age: number;
|
age: number;
|
||||||
npcState: number;
|
npcState: number;
|
||||||
meta: JsonValue;
|
meta: JsonValue;
|
||||||
|
penalty: JsonValue;
|
||||||
turnTime: Date;
|
turnTime: Date;
|
||||||
recentWarTime: Date | null;
|
recentWarTime: Date | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,5 +18,6 @@ export * from './scenario/index.js';
|
|||||||
export * from './triggers/index.js';
|
export * from './triggers/index.js';
|
||||||
export * from './turn/index.js';
|
export * from './turn/index.js';
|
||||||
export * from './tournament/index.js';
|
export * from './tournament/index.js';
|
||||||
|
export * from './troop/management.js';
|
||||||
export * from './world/index.js';
|
export * from './world/index.js';
|
||||||
export * from './war/index.js';
|
export * from './war/index.js';
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { asRecord } from '@sammo-ts/common';
|
||||||
|
|
||||||
|
export interface TroopPermissionGeneral {
|
||||||
|
nationId: number;
|
||||||
|
officerLevel: number;
|
||||||
|
meta: unknown;
|
||||||
|
penalty?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const readNumber = (value: unknown, fallback = 0): number => {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (Number.isFinite(parsed)) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isFullWidthCodePoint = (codePoint: number): boolean =>
|
||||||
|
codePoint >= 0x1100 &&
|
||||||
|
(codePoint <= 0x115f ||
|
||||||
|
codePoint === 0x2329 ||
|
||||||
|
codePoint === 0x232a ||
|
||||||
|
(codePoint >= 0x2e80 && codePoint <= 0x3247 && codePoint !== 0x303f) ||
|
||||||
|
(codePoint >= 0x3250 && codePoint <= 0x4dbf) ||
|
||||||
|
(codePoint >= 0x4e00 && codePoint <= 0xa4c6) ||
|
||||||
|
(codePoint >= 0xa960 && codePoint <= 0xa97c) ||
|
||||||
|
(codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
|
||||||
|
(codePoint >= 0xf900 && codePoint <= 0xfaff) ||
|
||||||
|
(codePoint >= 0xfe10 && codePoint <= 0xfe19) ||
|
||||||
|
(codePoint >= 0xfe30 && codePoint <= 0xfe6b) ||
|
||||||
|
(codePoint >= 0xff01 && codePoint <= 0xff60) ||
|
||||||
|
(codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
|
||||||
|
(codePoint >= 0x1b000 && codePoint <= 0x1b001) ||
|
||||||
|
(codePoint >= 0x1f200 && codePoint <= 0x1f251) ||
|
||||||
|
(codePoint >= 0x20000 && codePoint <= 0x3fffd));
|
||||||
|
|
||||||
|
// PHP mb_strwidth와 같은 전각 2/반각 1 기준으로 부대명 길이를 센다.
|
||||||
|
export const getLegacyStringWidth = (value: string): number => {
|
||||||
|
let width = 0;
|
||||||
|
for (const character of value) {
|
||||||
|
const codePoint = character.codePointAt(0);
|
||||||
|
if (codePoint === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (codePoint === 0 || codePoint < 0x20 || (codePoint >= 0x7f && codePoint < 0xa0)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
width += isFullWidthCodePoint(codePoint) ? 2 : 1;
|
||||||
|
}
|
||||||
|
return width;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레거시 StringUtil::neutralize처럼 HTML 특수문자를 저장용 문자열로 바꾼 뒤
|
||||||
|
// 양 끝의 separator/control만 제거한다.
|
||||||
|
export const normalizeTroopName = (value: string): string =>
|
||||||
|
value
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replace(/^[\p{Z}\p{C}]+|[\p{Z}\p{C}]+$/gu, '');
|
||||||
|
|
||||||
|
export const isValidTroopNameWidth = (value: string): boolean => {
|
||||||
|
const width = getLegacyStringWidth(value);
|
||||||
|
return width >= 1 && width <= 18;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveTroopSecretPermission = (
|
||||||
|
general: TroopPermissionGeneral,
|
||||||
|
nationMeta: unknown,
|
||||||
|
checkSecretLimit = false
|
||||||
|
): number => {
|
||||||
|
if (general.nationId <= 0 || general.officerLevel === 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const penalty = asRecord(general.penalty);
|
||||||
|
if (penalty.noChief) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = asRecord(general.meta);
|
||||||
|
const permission = meta.permission;
|
||||||
|
const belong = readNumber(meta.belong, 0);
|
||||||
|
const nation = asRecord(nationMeta);
|
||||||
|
const secretLimit = readNumber(nation.secretlimit ?? nation.secretLimit, 3);
|
||||||
|
|
||||||
|
let secretMax = 4;
|
||||||
|
if (penalty.noTopSecret || penalty.noChief) {
|
||||||
|
secretMax = 1;
|
||||||
|
} else if (penalty.noAmbassador) {
|
||||||
|
secretMax = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
let secretMin = 0;
|
||||||
|
if (general.officerLevel === 12 || permission === 'ambassador') {
|
||||||
|
secretMin = 4;
|
||||||
|
} else if (permission === 'auditor') {
|
||||||
|
secretMin = 3;
|
||||||
|
} else if (general.officerLevel >= 5) {
|
||||||
|
secretMin = 2;
|
||||||
|
} else if (general.officerLevel > 1) {
|
||||||
|
secretMin = 1;
|
||||||
|
} else if (checkSecretLimit && belong >= secretLimit) {
|
||||||
|
secretMin = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(secretMin, secretMax);
|
||||||
|
};
|
||||||
Generated
+38
@@ -14,6 +14,9 @@ importers:
|
|||||||
'@eslint/js':
|
'@eslint/js':
|
||||||
specifier: ^10.0.1
|
specifier: ^10.0.1
|
||||||
version: 10.0.1(eslint@10.8.0(jiti@2.6.1)(supports-color@7.2.0))
|
version: 10.0.1(eslint@10.8.0(jiti@2.6.1)(supports-color@7.2.0))
|
||||||
|
'@playwright/test':
|
||||||
|
specifier: 1.62.0
|
||||||
|
version: 1.62.0
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^26.1.1
|
specifier: ^26.1.1
|
||||||
version: 26.1.1
|
version: 26.1.1
|
||||||
@@ -1017,6 +1020,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==}
|
resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==}
|
||||||
engines: {node: ^14.18.0 || >=16.0.0}
|
engines: {node: ^14.18.0 || >=16.0.0}
|
||||||
|
|
||||||
|
'@playwright/test@1.62.0':
|
||||||
|
resolution: {integrity: sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
'@pm2/agent@2.0.4':
|
'@pm2/agent@2.0.4':
|
||||||
resolution: {integrity: sha512-n7WYvvTJhHLS2oBb1PjOtgLpMhgImOq8sXkPBw6smeg9LJBWZjiEgPKOpR8mn9UJZsB5P3W4V/MyvNnp31LKeA==}
|
resolution: {integrity: sha512-n7WYvvTJhHLS2oBb1PjOtgLpMhgImOq8sXkPBw6smeg9LJBWZjiEgPKOpR8mn9UJZsB5P3W4V/MyvNnp31LKeA==}
|
||||||
|
|
||||||
@@ -2919,6 +2927,11 @@ packages:
|
|||||||
fraction.js@5.3.4:
|
fraction.js@5.3.4:
|
||||||
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
|
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
|
||||||
|
|
||||||
|
fsevents@2.3.2:
|
||||||
|
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||||
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
@@ -3490,6 +3503,16 @@ packages:
|
|||||||
pkg-types@2.3.0:
|
pkg-types@2.3.0:
|
||||||
resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==}
|
resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==}
|
||||||
|
|
||||||
|
playwright-core@1.62.0:
|
||||||
|
resolution: {integrity: sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
playwright@1.62.0:
|
||||||
|
resolution: {integrity: sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
pm2-axon-rpc@0.7.1:
|
pm2-axon-rpc@0.7.1:
|
||||||
resolution: {integrity: sha512-FbLvW60w+vEyvMjP/xom2UPhUN/2bVpdtLfKJeYM3gwzYhoTEEChCOICfFzxkxuoEleOlnpjie+n1nue91bDQw==}
|
resolution: {integrity: sha512-FbLvW60w+vEyvMjP/xom2UPhUN/2bVpdtLfKJeYM3gwzYhoTEEChCOICfFzxkxuoEleOlnpjie+n1nue91bDQw==}
|
||||||
engines: {node: '>=5'}
|
engines: {node: '>=5'}
|
||||||
@@ -4816,6 +4839,10 @@ snapshots:
|
|||||||
|
|
||||||
'@pkgr/core@0.3.6': {}
|
'@pkgr/core@0.3.6': {}
|
||||||
|
|
||||||
|
'@playwright/test@1.62.0':
|
||||||
|
dependencies:
|
||||||
|
playwright: 1.62.0
|
||||||
|
|
||||||
'@pm2/agent@2.0.4(supports-color@7.2.0)':
|
'@pm2/agent@2.0.4(supports-color@7.2.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
async: 3.2.6
|
async: 3.2.6
|
||||||
@@ -6532,6 +6559,9 @@ snapshots:
|
|||||||
|
|
||||||
fraction.js@5.3.4: {}
|
fraction.js@5.3.4: {}
|
||||||
|
|
||||||
|
fsevents@2.3.2:
|
||||||
|
optional: true
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -7047,6 +7077,14 @@ snapshots:
|
|||||||
exsolve: 1.0.8
|
exsolve: 1.0.8
|
||||||
pathe: 2.0.3
|
pathe: 2.0.3
|
||||||
|
|
||||||
|
playwright-core@1.62.0: {}
|
||||||
|
|
||||||
|
playwright@1.62.0:
|
||||||
|
dependencies:
|
||||||
|
playwright-core: 1.62.0
|
||||||
|
optionalDependencies:
|
||||||
|
fsevents: 2.3.2
|
||||||
|
|
||||||
pm2-axon-rpc@0.7.1(supports-color@7.2.0):
|
pm2-axon-rpc@0.7.1(supports-color@7.2.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.3(supports-color@7.2.0)
|
debug: 4.4.3(supports-color@7.2.0)
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ allowBuilds:
|
|||||||
|
|
||||||
minimumReleaseAgeExclude:
|
minimumReleaseAgeExclude:
|
||||||
- eslint@10.8.0
|
- eslint@10.8.0
|
||||||
|
- '@playwright/test@1.62.0'
|
||||||
|
- playwright-core@1.62.0
|
||||||
|
- playwright@1.62.0
|
||||||
|
|
||||||
onlyBuiltDependencies:
|
onlyBuiltDependencies:
|
||||||
- '@prisma/client'
|
- '@prisma/client'
|
||||||
|
|||||||
Reference in New Issue
Block a user