Merge branch 'main' into feature/p0-actor-ownership

# Conflicts:
#	app/game-api/src/router/troop/index.ts
This commit is contained in:
2026-07-25 08:52:01 +00:00
35 changed files with 3570 additions and 128 deletions
+71 -17
View File
@@ -517,6 +517,18 @@ export const joinRouter = router({
},
},
});
await db.generalAccessLog.upsert({
where: { generalId: general.id },
update: {
userId,
lastRefresh: new Date(),
},
create: {
generalId: general.id,
userId,
lastRefresh: new Date(),
},
});
if (inheritRequiredPoint > 0) {
await setInheritancePoint(db, userId, 'previous', currentPoint - inheritRequiredPoint);
@@ -618,25 +630,67 @@ export const joinRouter = router({
});
}
const updated = await ctx.db.general.updateMany({
where: {
id: input.generalId,
userId: null,
npcState: { gte: 2 },
},
data: {
userId,
npcState: 1,
updatedAt: new Date(),
},
});
await ctx.db.$transaction!(async (db) => {
const [candidate, worldState] = await Promise.all([
db.general.findUnique({
where: { id: input.generalId },
select: { npcState: true, meta: true },
}),
db.worldState.findFirst({
select: { currentYear: true, currentMonth: true },
}),
]);
if (!candidate || candidate.npcState < 2 || !worldState) {
throw new TRPCError({
code: 'NOT_FOUND',
message: '빙의 가능한 장수를 찾지 못했습니다.',
});
}
if (updated.count === 0) {
throw new TRPCError({
code: 'NOT_FOUND',
message: '빙의 가능한 장수를 찾지 못했습니다.',
const now = new Date();
const updated = await db.general.updateMany({
where: {
id: input.generalId,
userId: null,
npcState: candidate.npcState,
},
data: {
userId,
npcState: 1,
meta: {
...asRecord(candidate.meta),
npc_org: candidate.npcState,
owner_name: ctx.auth?.user.displayName ?? '',
pickYearMonth: worldState.currentYear * 12 + worldState.currentMonth - 1,
killturn: 6,
defence_train: 80,
},
updatedAt: now,
},
});
}
if (updated.count === 0) {
throw new TRPCError({
code: 'NOT_FOUND',
message: '빙의 가능한 장수를 찾지 못했습니다.',
});
}
await db.generalAccessLog.upsert({
where: { generalId: input.generalId },
update: {
userId,
lastRefresh: now,
refresh: 0,
refreshTotal: 0,
refreshScore: 0,
refreshScoreTotal: 0,
},
create: {
generalId: input.generalId,
userId,
lastRefresh: now,
},
});
});
return { ok: true };
}),
+242 -46
View File
@@ -1,76 +1,272 @@
import { TRPCError } from '@trpc/server';
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 { getOwnedGeneral } from '../shared/general.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({
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(
z.object({
generalId: z.number().int().positive(),
troopId: z.number().int().positive(),
targetGeneralId: z.number().int().positive(),
})
)
.mutation(async ({ ctx, input }) => {
const general = await getOwnedGeneral(ctx, input.generalId);
const result = await ctx.turnDaemon.requestCommand({
type: 'troopJoin',
generalId: general.id,
troopId: input.troopId,
const me = await getMyGeneral(ctx);
if (me.id !== input.troopId || me.troopId !== me.id) {
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
}
const target = await ctx.db.general.findUnique({
where: { id: input.targetGeneralId },
select: { id: true, troopId: true },
});
if (!result) {
throw new TRPCError({
code: 'TIMEOUT',
message: 'Turn daemon did not respond.',
});
if (!target) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '장수 정보를 찾을 수 없습니다.' });
}
if (result.type !== 'troopJoin') {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Unexpected turn daemon response.',
});
if (target.troopId === 0) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '부대에 소속되어 있지 않습니다.' });
}
if (!result.ok) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: result.reason,
});
if (target.troopId !== input.troopId) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '다른 부대에 소속되어 있습니다.' });
}
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 };
}),
exit: authedProcedure
rename: authedProcedure
.input(
z.object({
generalId: z.number().int().positive(),
troopId: z.number().int().positive(),
troopName: troopNameSchema,
})
)
.mutation(async ({ ctx, input }) => {
const general = await getOwnedGeneral(ctx, input.generalId);
const result = await ctx.turnDaemon.requestCommand({
type: 'troopExit',
generalId: general.id,
const me = await getMyGeneral(ctx);
const troopName = normalizeRequiredTroopName(input.troopName);
const nation = await ctx.db.nation.findUnique({
where: { id: me.nationId },
select: { meta: true },
});
if (!result) {
throw new TRPCError({
code: 'TIMEOUT',
message: 'Turn daemon did not respond.',
});
const permission = resolveTroopSecretPermission(me, nation?.meta ?? {}, false);
if (me.id !== input.troopId && permission < 4) {
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
}
if (result.type !== 'troopExit') {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Unexpected turn daemon response.',
});
}
if (!result.ok) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: result.reason,
});
const troop = await ctx.db.troop.findUnique({
where: { troopLeaderId: input.troopId },
select: { nationId: true },
});
if (!troop || me.nationId <= 0 || troop.nationId !== me.nationId) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '부대가 없습니다.' });
}
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 };
}),
});
+1 -9
View File
@@ -283,8 +283,7 @@ describe('appRouter', () => {
it('rejects another user general across actor-owned routers', async () => {
const general = buildGeneralRow({ id: 15, userId: 'user-2' });
const transport = new InMemoryTurnDaemonTransport();
const caller = appRouter.createCaller(buildContext({ general, transport }));
const caller = appRouter.createCaller(buildContext({ general }));
await expect(caller.turns.getCommandTable({ generalId: general.id })).rejects.toMatchObject({
code: 'FORBIDDEN',
@@ -350,12 +349,6 @@ describe('appRouter', () => {
).rejects.toMatchObject({
code: 'FORBIDDEN',
});
await expect(caller.troop.join({ generalId: general.id, troopId: 3 })).rejects.toMatchObject({
code: 'FORBIDDEN',
});
await expect(caller.troop.exit({ generalId: general.id })).rejects.toMatchObject({
code: 'FORBIDDEN',
});
await expect(
caller.world.getMap({
generalId: general.id,
@@ -364,7 +357,6 @@ describe('appRouter', () => {
).rejects.toMatchObject({
code: 'FORBIDDEN',
});
expect(transport.commands).toHaveLength(0);
});
it('rejects unauthenticated general-scoped map views', async () => {
+243
View File
@@ -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();
});
});