feat: complete nation personnel and finance parity
This commit is contained in:
@@ -8,7 +8,10 @@ export const changePermission = authedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
isAmbassador: z.boolean(),
|
||||
targetGeneralIds: z.array(z.number().int().positive()),
|
||||
targetGeneralIds: z
|
||||
.array(z.number().int().positive())
|
||||
.max(2)
|
||||
.refine((ids) => new Set(ids).size === ids.length, '중복된 장수를 지정할 수 없습니다.'),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
|
||||
@@ -10,7 +10,7 @@ export const getPersonnelInfo = authedProcedure.query(async ({ ctx }) => {
|
||||
const me = await getMyGeneral(ctx);
|
||||
assertNationAccess(me);
|
||||
|
||||
const [nation, cityRows, troopRows, generalRows, worldState] = await Promise.all([
|
||||
const [nation, cityRows, troopRows, generalRows, worldState, rankRows] = await Promise.all([
|
||||
ctx.db.nation.findUnique({
|
||||
where: { id: me.nationId },
|
||||
select: {
|
||||
@@ -25,7 +25,7 @@ export const getPersonnelInfo = authedProcedure.query(async ({ ctx }) => {
|
||||
}),
|
||||
ctx.db.city.findMany({
|
||||
where: { nationId: me.nationId },
|
||||
select: { id: true, name: true, level: true, region: true },
|
||||
select: { id: true, name: true, level: true, region: true, meta: true },
|
||||
orderBy: { id: 'asc' },
|
||||
}),
|
||||
ctx.db.troop.findMany({ select: { troopLeaderId: true, name: true } }),
|
||||
@@ -38,6 +38,8 @@ export const getPersonnelInfo = authedProcedure.query(async ({ ctx }) => {
|
||||
nationId: true,
|
||||
cityId: true,
|
||||
troopId: true,
|
||||
picture: true,
|
||||
imageServer: true,
|
||||
officerLevel: true,
|
||||
leadership: true,
|
||||
strength: true,
|
||||
@@ -57,6 +59,15 @@ export const getPersonnelInfo = authedProcedure.query(async ({ ctx }) => {
|
||||
orderBy: { id: 'asc' },
|
||||
}),
|
||||
ctx.db.worldState.findFirst(),
|
||||
ctx.db.rankData.findMany({
|
||||
where: {
|
||||
nationId: me.nationId,
|
||||
type: { in: ['killnum', 'firenum'] },
|
||||
value: { gt: 0 },
|
||||
},
|
||||
select: { generalId: true, type: true, value: true },
|
||||
orderBy: [{ value: 'desc' }, { generalId: 'asc' }],
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!nation) {
|
||||
@@ -66,20 +77,42 @@ export const getPersonnelInfo = authedProcedure.query(async ({ ctx }) => {
|
||||
const cityNameMap = new Map(cityRows.map((city) => [city.id, city.name]));
|
||||
const troopNameMap = new Map(troopRows.map((troop) => [troop.troopLeaderId, troop.name]));
|
||||
const mappedGenerals = await mapGeneralList(generalRows, cityNameMap, troopNameMap);
|
||||
const canManage = me.officerLevel >= 5;
|
||||
const responseGenerals = canManage
|
||||
? mappedGenerals
|
||||
: mappedGenerals.map((general) => ({
|
||||
...general,
|
||||
stats: { leadership: 0, strength: 0, intelligence: 0 },
|
||||
experience: 0,
|
||||
dedication: 0,
|
||||
injury: 0,
|
||||
gold: 0,
|
||||
rice: 0,
|
||||
crew: 0,
|
||||
troopId: 0,
|
||||
troopName: null,
|
||||
personality: null,
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
}));
|
||||
const responseGeneralMap = new Map(responseGenerals.map((general) => [general.id, general]));
|
||||
const visibleGenerals = responseGenerals.filter((general) => canManage || general.officerLevel >= 2);
|
||||
|
||||
const chiefAssignments = mappedGenerals
|
||||
const chiefAssignments = responseGenerals
|
||||
.filter((general) => general.officerLevel >= 5)
|
||||
.reduce<Record<number, (typeof mappedGenerals)[number]>>((acc, general) => {
|
||||
.reduce<Record<number, (typeof responseGenerals)[number]>>((acc, general) => {
|
||||
acc[general.officerLevel] = general;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const cityAssignments = cityRows.map((city) => {
|
||||
const officers = mappedGenerals.filter(
|
||||
(general) => general.officerLevel >= 2 && general.officerLevel <= 4 && general.officerCity === city.id
|
||||
);
|
||||
const officers = mappedGenerals
|
||||
.filter(
|
||||
(general) => general.officerLevel >= 2 && general.officerLevel <= 4 && general.officerCity === city.id
|
||||
)
|
||||
.map((general) => responseGeneralMap.get(general.id)!);
|
||||
|
||||
const officerMap: Record<number, (typeof mappedGenerals)[number] | null> = {
|
||||
const officerMap: Record<number, (typeof responseGenerals)[number] | null> = {
|
||||
4: null,
|
||||
3: null,
|
||||
2: null,
|
||||
@@ -94,6 +127,7 @@ export const getPersonnelInfo = authedProcedure.query(async ({ ctx }) => {
|
||||
name: city.name,
|
||||
level: city.level,
|
||||
region: city.region,
|
||||
officerSet: Number(asRecord(city.meta).officer_set ?? 0),
|
||||
officers: officerMap,
|
||||
};
|
||||
});
|
||||
@@ -116,17 +150,37 @@ export const getPersonnelInfo = authedProcedure.query(async ({ ctx }) => {
|
||||
};
|
||||
});
|
||||
|
||||
const ambassadors = permissionCandidates.filter(
|
||||
(candidate) => candidate.permission === 'ambassador' || candidate.maxPermission === 4
|
||||
);
|
||||
const auditors = permissionCandidates.filter(
|
||||
(candidate) => candidate.permission === 'auditor' || candidate.maxPermission >= 3
|
||||
);
|
||||
const canChangePermissions = me.officerLevel === 12;
|
||||
const ambassadors = canChangePermissions
|
||||
? permissionCandidates.filter(
|
||||
(candidate) => candidate.permission === 'ambassador' || candidate.maxPermission === 4
|
||||
)
|
||||
: [];
|
||||
const auditors = canChangePermissions
|
||||
? permissionCandidates.filter((candidate) => candidate.permission === 'auditor' || candidate.maxPermission >= 3)
|
||||
: [];
|
||||
const generalNameMap = new Map(mappedGenerals.map((general) => [general.id, general.name]));
|
||||
const awards = {
|
||||
tigers: rankRows
|
||||
.filter((row) => row.type === 'killnum')
|
||||
.slice(0, 5)
|
||||
.map((row) => ({ id: row.generalId, name: generalNameMap.get(row.generalId) ?? '-', value: row.value })),
|
||||
eagles: rankRows
|
||||
.filter((row) => row.type === 'firenum')
|
||||
.slice(0, 7)
|
||||
.map((row) => ({ id: row.generalId, name: generalNameMap.get(row.generalId) ?? '-', value: row.value })),
|
||||
};
|
||||
const nationMeta = asRecord(nation.meta);
|
||||
const mePenalty = penaltyMap.get(me.id) ?? {};
|
||||
const chiefSet = Number(nationMeta.chief_set ?? 0);
|
||||
|
||||
return {
|
||||
me: {
|
||||
id: me.id,
|
||||
officerLevel: me.officerLevel,
|
||||
canManage,
|
||||
canChangePermissions,
|
||||
canKick: canManage && mePenalty.noBanGeneral !== true && (chiefSet & (1 << me.officerLevel)) === 0,
|
||||
},
|
||||
nation: {
|
||||
id: nation.id,
|
||||
@@ -135,11 +189,13 @@ export const getPersonnelInfo = authedProcedure.query(async ({ ctx }) => {
|
||||
level: nation.level,
|
||||
typeCode: nation.typeCode,
|
||||
capitalCityId: nation.capitalCityId ?? 0,
|
||||
chiefSet,
|
||||
},
|
||||
chiefStatMin: resolveChiefStatMin(worldState),
|
||||
generals: mappedGenerals,
|
||||
generals: visibleGenerals,
|
||||
chiefAssignments,
|
||||
cityAssignments,
|
||||
awards,
|
||||
permissionCandidates: {
|
||||
ambassadors,
|
||||
auditors,
|
||||
|
||||
@@ -83,6 +83,8 @@ export type GeneralListRow = {
|
||||
nationId: number;
|
||||
cityId: number;
|
||||
troopId: number;
|
||||
picture?: string | null;
|
||||
imageServer?: number;
|
||||
officerLevel: number;
|
||||
leadership: number;
|
||||
strength: number;
|
||||
@@ -270,7 +272,8 @@ export const resolveNationScoutMessage = (meta: Record<string, unknown>): string
|
||||
|
||||
export const resolveWarSettingRemain = (meta: Record<string, unknown>): number => {
|
||||
const legacy = readMetaNumber(meta, 'available_war_setting_cnt', -1);
|
||||
const fallback = legacy >= 0 ? legacy : readMetaNumber(meta, 'availableWarSettingCnt', MAX_AVAILABLE_WAR_SETTING_CNT);
|
||||
const fallback =
|
||||
legacy >= 0 ? legacy : readMetaNumber(meta, 'availableWarSettingCnt', MAX_AVAILABLE_WAR_SETTING_CNT);
|
||||
return Math.max(0, Math.min(MAX_AVAILABLE_WAR_SETTING_CNT, fallback));
|
||||
};
|
||||
|
||||
@@ -287,10 +290,7 @@ export const checkSecretMaxPermission = (penalty: Record<string, unknown>): numb
|
||||
return 4;
|
||||
};
|
||||
|
||||
export const loadTraitNames = async (
|
||||
keys: Array<string | null>,
|
||||
kind: keyof TraitCache
|
||||
): Promise<TraitNameMap> => {
|
||||
export const loadTraitNames = async (keys: Array<string | null>, kind: keyof TraitCache): Promise<TraitNameMap> => {
|
||||
const cache = traitCache[kind];
|
||||
const unique = Array.from(new Set(keys.filter((key): key is string => Boolean(key))));
|
||||
const missing = unique.filter((key) => !cache.has(key));
|
||||
@@ -481,8 +481,10 @@ export const mapGeneralList = async (
|
||||
cityName: cityNameMap.get(general.cityId) ?? null,
|
||||
troopId: general.troopId,
|
||||
troopName: troopNameMap.get(general.troopId) ?? null,
|
||||
picture: general.picture ?? null,
|
||||
imageServer: general.imageServer ?? 0,
|
||||
officerCity,
|
||||
officerCityName: officerCity > 0 ? cityNameMap.get(officerCity) ?? null : null,
|
||||
officerCityName: officerCity > 0 ? (cityNameMap.get(officerCity) ?? null) : null,
|
||||
stats: {
|
||||
leadership: general.leadership,
|
||||
strength: general.strength,
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import type { RedisConnector } from '@sammo-ts/infra';
|
||||
|
||||
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
|
||||
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
|
||||
import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js';
|
||||
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
|
||||
import { appRouter } from '../src/router.js';
|
||||
|
||||
const baseGeneral: GeneralRow = {
|
||||
id: 22,
|
||||
userId: 'user-22',
|
||||
name: '인사담당',
|
||||
nationId: 1,
|
||||
cityId: 1,
|
||||
troopId: 0,
|
||||
npcState: 0,
|
||||
affinity: null,
|
||||
bornYear: 180,
|
||||
deadYear: 300,
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
leadership: 70,
|
||||
strength: 70,
|
||||
intel: 70,
|
||||
injury: 0,
|
||||
experience: 0,
|
||||
dedication: 0,
|
||||
officerLevel: 5,
|
||||
gold: 1_000,
|
||||
rice: 1_000,
|
||||
crew: 0,
|
||||
crewTypeId: 0,
|
||||
train: 0,
|
||||
atmos: 0,
|
||||
weaponCode: 'None',
|
||||
bookCode: 'None',
|
||||
horseCode: 'None',
|
||||
itemCode: 'None',
|
||||
turnTime: new Date('2026-01-01T00:00:00.000Z'),
|
||||
recentWarTime: null,
|
||||
age: 20,
|
||||
startAge: 20,
|
||||
personalCode: 'None',
|
||||
specialCode: 'None',
|
||||
special2Code: 'None',
|
||||
lastTurn: {},
|
||||
meta: { killturn: 24, belong: 5, permission: 'normal' },
|
||||
penalty: {},
|
||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
};
|
||||
|
||||
const auth: GameSessionTokenPayload = {
|
||||
version: 1,
|
||||
profile: 'che:default',
|
||||
issuedAt: '2026-01-01T00:00:00.000Z',
|
||||
expiresAt: '2026-01-02T00:00:00.000Z',
|
||||
sessionId: 'session-22',
|
||||
user: { id: 'user-22', username: 'tester', displayName: 'Tester', roles: [] },
|
||||
sanctions: {},
|
||||
};
|
||||
|
||||
const createContext = (
|
||||
options: {
|
||||
me?: GeneralRow;
|
||||
db?: Record<string, unknown>;
|
||||
requestCommand?: ReturnType<typeof vi.fn>;
|
||||
} = {}
|
||||
): GameApiContext => {
|
||||
const requestCommand = options.requestCommand ?? vi.fn();
|
||||
const redisClient = { get: async () => null, set: async () => null };
|
||||
const db = {
|
||||
general: { findFirst: vi.fn(async () => options.me ?? baseGeneral) },
|
||||
...options.db,
|
||||
};
|
||||
return {
|
||||
db: db as unknown as DatabaseClient,
|
||||
redis: {} as RedisConnector['client'],
|
||||
turnDaemon: { requestCommand } as unknown as TurnDaemonTransport,
|
||||
battleSim: {} as GameApiContext['battleSim'],
|
||||
profile: { id: 'che', scenario: 'default', name: 'che:default' },
|
||||
auth,
|
||||
uploadDir: 'uploads',
|
||||
uploadPath: '/uploads',
|
||||
uploadPublicUrl: null,
|
||||
accessTokenStore: new RedisAccessTokenStore(redisClient, 'che:default'),
|
||||
flushStore: new InMemoryFlushStore(),
|
||||
gameTokenSecret: 'test-secret',
|
||||
};
|
||||
};
|
||||
|
||||
const listRow = (overrides: Record<string, unknown>) => ({
|
||||
id: 1,
|
||||
name: '장수1',
|
||||
npcState: 0,
|
||||
nationId: 1,
|
||||
cityId: 1,
|
||||
troopId: 0,
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
officerLevel: 1,
|
||||
leadership: 70,
|
||||
strength: 70,
|
||||
intel: 70,
|
||||
experience: 0,
|
||||
dedication: 0,
|
||||
injury: 0,
|
||||
gold: 1_000,
|
||||
rice: 1_000,
|
||||
crew: 0,
|
||||
personalCode: 'None',
|
||||
specialCode: 'None',
|
||||
special2Code: 'None',
|
||||
meta: { belong: 5, permission: 'normal' },
|
||||
penalty: {},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('nation personnel router', () => {
|
||||
it('always dispatches the authenticated general and never accepts a client actor id', async () => {
|
||||
const requestCommand = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ type: 'appoint', ok: true, generalId: 22 })
|
||||
.mockResolvedValueOnce({ type: 'kick', ok: true, generalId: 22 })
|
||||
.mockResolvedValueOnce({ type: 'changePermission', ok: true, generalId: 22 });
|
||||
const caller = appRouter.createCaller(createContext({ requestCommand }));
|
||||
|
||||
await caller.nation.appoint({ destGeneralId: 7, destCityId: 1, officerLevel: 4 });
|
||||
await caller.nation.kick({ destGeneralId: 8 });
|
||||
await caller.nation.changePermission({ isAmbassador: true, targetGeneralIds: [9] });
|
||||
|
||||
expect(requestCommand.mock.calls).toEqual([
|
||||
[{ type: 'appoint', generalId: 22, destGeneralId: 7, destCityId: 1, officerLevel: 4 }],
|
||||
[{ type: 'kick', generalId: 22, destGeneralId: 8 }],
|
||||
[{ type: 'changePermission', generalId: 22, isAmbassador: true, targetGeneralIds: [9] }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects oversized and duplicate permission selections before daemon dispatch', async () => {
|
||||
const requestCommand = vi.fn();
|
||||
const caller = appRouter.createCaller(createContext({ requestCommand }));
|
||||
await expect(
|
||||
caller.nation.changePermission({ isAmbassador: true, targetGeneralIds: [1, 2, 3] })
|
||||
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
||||
await expect(
|
||||
caller.nation.changePermission({ isAmbassador: false, targetGeneralIds: [1, 1] })
|
||||
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
||||
expect(requestCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('redacts management candidates for an ordinary member but keeps appointed officers and awards visible', async () => {
|
||||
const me = { ...baseGeneral, officerLevel: 1 };
|
||||
const rows = [
|
||||
listRow({ id: 22, name: '일반인', officerLevel: 1 }),
|
||||
listRow({ id: 30, name: '군사', officerLevel: 3, meta: { belong: 8, officerCity: 1 } }),
|
||||
listRow({ id: 31, name: '후보', officerLevel: 1, gold: 99_999, rice: 99_999 }),
|
||||
];
|
||||
const context = createContext({
|
||||
me,
|
||||
db: {
|
||||
nation: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
id: 1,
|
||||
name: '위',
|
||||
color: '#777777',
|
||||
level: 3,
|
||||
typeCode: 'che_법가',
|
||||
capitalCityId: 1,
|
||||
meta: { chief_set: 0 },
|
||||
})),
|
||||
},
|
||||
city: {
|
||||
findMany: vi.fn(async () => [
|
||||
{ id: 1, name: '허창', level: 7, region: 2, meta: { officer_set: 0 } },
|
||||
]),
|
||||
},
|
||||
troop: { findMany: vi.fn(async () => []) },
|
||||
general: {
|
||||
findFirst: vi.fn(async () => me),
|
||||
findMany: vi.fn(async () => rows),
|
||||
},
|
||||
worldState: { findFirst: vi.fn(async () => ({ config: { stat: { chiefMin: 65 } } })) },
|
||||
rankData: {
|
||||
findMany: vi.fn(async () => [{ generalId: 30, type: 'firenum', value: 7 }]),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await appRouter.createCaller(context).nation.getPersonnelInfo();
|
||||
expect(result.me).toMatchObject({ id: 22, canManage: false, canChangePermissions: false, canKick: false });
|
||||
expect(result.generals.map((general) => general.id)).toEqual([30]);
|
||||
expect(result.permissionCandidates).toEqual({ ambassadors: [], auditors: [] });
|
||||
expect(result.cityAssignments[0]?.officers[3]?.name).toBe('군사');
|
||||
expect(result.cityAssignments[0]?.officers[3]).toMatchObject({
|
||||
stats: { leadership: 0, strength: 0, intelligence: 0 },
|
||||
gold: 0,
|
||||
rice: 0,
|
||||
crew: 0,
|
||||
troopId: 0,
|
||||
});
|
||||
expect(result.awards.eagles).toEqual([{ id: 30, name: '군사', value: 7 }]);
|
||||
});
|
||||
|
||||
it('allows finance mutations only for a head officer or an eligible ambassador', async () => {
|
||||
const nationDb = {
|
||||
nation: {
|
||||
findUnique: vi.fn(async () => ({ meta: { _updatedAt: '2026-01-01T00:00:00.000Z' } })),
|
||||
},
|
||||
};
|
||||
const makeCommand = () =>
|
||||
vi.fn(async () => ({
|
||||
type: 'setNationMeta',
|
||||
ok: true,
|
||||
nationId: 1,
|
||||
updatedAt: '2026-01-01T00:01:00.000Z',
|
||||
}));
|
||||
|
||||
const headCommand = makeCommand();
|
||||
await expect(
|
||||
appRouter.createCaller(createContext({ db: nationDb, requestCommand: headCommand })).nation.setRate({
|
||||
amount: 20,
|
||||
})
|
||||
).resolves.toEqual({ ok: true });
|
||||
expect(headCommand).toHaveBeenCalledWith({
|
||||
type: 'setNationMeta',
|
||||
nationId: 1,
|
||||
updates: { rate: 20 },
|
||||
expectedUpdatedAt: '2026-01-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
const ambassadorCommand = makeCommand();
|
||||
const ambassador = {
|
||||
...baseGeneral,
|
||||
officerLevel: 1,
|
||||
meta: { killturn: 24, belong: 5, permission: 'ambassador' },
|
||||
};
|
||||
await expect(
|
||||
appRouter
|
||||
.createCaller(createContext({ me: ambassador, db: nationDb, requestCommand: ambassadorCommand }))
|
||||
.nation.setRate({ amount: 25 })
|
||||
).resolves.toEqual({ ok: true });
|
||||
|
||||
const memberCommand = makeCommand();
|
||||
const member = { ...baseGeneral, officerLevel: 1 };
|
||||
await expect(
|
||||
appRouter
|
||||
.createCaller(createContext({ me: member, db: nationDb, requestCommand: memberCommand }))
|
||||
.nation.setRate({ amount: 25 })
|
||||
).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||
expect(memberCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user