merge: complete map-to-city page parity
This commit is contained in:
@@ -7,6 +7,7 @@ import { authedProcedure } from '../../trpc.js';
|
||||
import { asRecord, isRecord } from '@sammo-ts/common';
|
||||
import { loadWorldMap } from '../../maps/worldMap.js';
|
||||
import { loadMapLayout } from '../../maps/mapLayout.js';
|
||||
import { loadUnitSetDefinitionByName } from '../../battleSim/unitSetLoader.js';
|
||||
import { getMyGeneral, getOwnedGeneral } from '../shared/general.js';
|
||||
|
||||
const isWorldAdmin = (roles: readonly string[]): boolean =>
|
||||
@@ -33,6 +34,29 @@ const defenceTrain = (meta: unknown): number => {
|
||||
return typeof raw === 'number' && Number.isFinite(raw) ? raw : 0;
|
||||
};
|
||||
|
||||
const unitSetName = (world: WorldStateRow | null, fallback: string): string => {
|
||||
const config = asRecord(world?.config);
|
||||
const environment = asRecord(config.environment ?? config.map);
|
||||
return typeof environment.unitSet === 'string' && environment.unitSet.trim() ? environment.unitSet : fallback;
|
||||
};
|
||||
|
||||
const crewTypeNameCache = new Map<string, Promise<Map<number, string>>>();
|
||||
const loadCrewTypeNames = (name: string): Promise<Map<number, string>> => {
|
||||
const cached = crewTypeNameCache.get(name);
|
||||
if (cached) return cached;
|
||||
const pending = loadUnitSetDefinitionByName(name)
|
||||
.then((definition) => new Map((definition.crewTypes ?? []).map((crewType) => [crewType.id, crewType.name])))
|
||||
.catch(() => new Map<number, string>());
|
||||
crewTypeNameCache.set(name, pending);
|
||||
return pending;
|
||||
};
|
||||
|
||||
const leadershipBonus = (officerLevel: number, nationLevel: number): number => {
|
||||
if (officerLevel === 12) return nationLevel * 2;
|
||||
if (officerLevel >= 5) return nationLevel;
|
||||
return 0;
|
||||
};
|
||||
|
||||
const toWorldStateSnapshot = (row: WorldStateRow) => ({
|
||||
scenarioCode: row.scenarioCode,
|
||||
currentYear: row.currentYear,
|
||||
@@ -122,6 +146,8 @@ export const worldRouter = router({
|
||||
if (me.officerLevel > 0 && me.nationId > 0) {
|
||||
cities.filter((city) => city.nationId === me.nationId).forEach((city) => selectable.add(city.id));
|
||||
nationGenerals.forEach((general) => selectable.add(general.cityId));
|
||||
}
|
||||
if ((nation?.level ?? 0) > 0) {
|
||||
Object.keys(spy).forEach((id) => selectable.add(Number(id)));
|
||||
}
|
||||
if (admin) cities.forEach((city) => selectable.add(city.id));
|
||||
@@ -150,6 +176,8 @@ export const worldRouter = router({
|
||||
turnMap.set(turn.generalId, list);
|
||||
}
|
||||
const nationMap = new Map(nations.map((item) => [item.id, item]));
|
||||
const selectedNation = nationMap.get(selected.nationId);
|
||||
const crewTypeNames = await loadCrewTypeNames(unitSetName(world, ctx.profile.id));
|
||||
const officers = await ctx.db.general.findMany({
|
||||
where: { officerLevel: { in: [2, 3, 4] } },
|
||||
select: { name: true, officerLevel: true, meta: true },
|
||||
@@ -175,14 +203,59 @@ export const worldRouter = router({
|
||||
intelligence: general.intel,
|
||||
injury: general.injury,
|
||||
officerLevel: general.officerLevel,
|
||||
leadershipBonus: leadershipBonus(general.officerLevel, nationMap.get(general.nationId)?.level ?? 0),
|
||||
defenceTrain: ours ? defenceTrain(general.meta) : null,
|
||||
crewTypeId: ours ? general.crewTypeId : null,
|
||||
crewTypeName: ours ? (crewTypeNames.get(general.crewTypeId) ?? null) : null,
|
||||
crew: ours || full ? general.crew : null,
|
||||
train: ours ? general.train : null,
|
||||
atmos: ours ? general.atmos : null,
|
||||
turns: ours && general.npcState <= 1 ? (turnMap.get(general.id) ?? []) : [],
|
||||
};
|
||||
});
|
||||
const forceSummary = mappedGenerals.reduce(
|
||||
(summary, general) => {
|
||||
if (general.nationId > 0 && me.nationId > 0 && general.nationId !== me.nationId) {
|
||||
summary.enemyGenerals += 1;
|
||||
if (general.crew !== null && general.crew >= 0) summary.enemyCrew += general.crew;
|
||||
if (general.crew !== null && general.crew > 0) summary.enemyArmedGenerals += 1;
|
||||
return summary;
|
||||
}
|
||||
if (me.nationId <= 0 || general.nationId !== me.nationId) return summary;
|
||||
summary.ownGenerals += 1;
|
||||
summary.ownCrew += general.crew ?? 0;
|
||||
if ((general.crew ?? 0) <= 0) return summary;
|
||||
summary.ownArmedGenerals += 1;
|
||||
const readiness = Math.min(general.train ?? -1, general.atmos ?? -1);
|
||||
if (readiness >= 90) {
|
||||
summary.ready90Crew += general.crew ?? 0;
|
||||
summary.ready90Generals += 1;
|
||||
}
|
||||
if (readiness >= 60) {
|
||||
summary.ready60Crew += general.crew ?? 0;
|
||||
summary.ready60Generals += 1;
|
||||
}
|
||||
if (general.defenceTrain !== null && readiness >= general.defenceTrain) {
|
||||
summary.defenceReadyCrew += general.crew ?? 0;
|
||||
summary.defenceReadyGenerals += 1;
|
||||
}
|
||||
return summary;
|
||||
},
|
||||
{
|
||||
enemyCrew: 0,
|
||||
enemyArmedGenerals: 0,
|
||||
enemyGenerals: 0,
|
||||
ownCrew: 0,
|
||||
ownArmedGenerals: 0,
|
||||
ownGenerals: 0,
|
||||
ready90Crew: 0,
|
||||
ready90Generals: 0,
|
||||
ready60Crew: 0,
|
||||
ready60Generals: 0,
|
||||
defenceReadyCrew: 0,
|
||||
defenceReadyGenerals: 0,
|
||||
}
|
||||
);
|
||||
return {
|
||||
me: { id: me.id, nationId: me.nationId, officerLevel: me.officerLevel, admin },
|
||||
options: [...selectable]
|
||||
@@ -194,6 +267,7 @@ export const worldRouter = router({
|
||||
id: selected.id,
|
||||
name: selected.name,
|
||||
nationId: selected.nationId,
|
||||
nationColor: selectedNation?.color ?? '#000000',
|
||||
level: selected.level,
|
||||
region: selected.region,
|
||||
population: redact(selected.population),
|
||||
@@ -217,8 +291,11 @@ export const worldRouter = router({
|
||||
},
|
||||
},
|
||||
generals: mappedGenerals,
|
||||
forceSummary,
|
||||
lastExecute:
|
||||
typeof asRecord(world?.meta).turntime === 'string' ? String(asRecord(world?.meta).turntime) : '',
|
||||
typeof asRecord(world?.meta).turntime === 'string'
|
||||
? String(asRecord(world?.meta).turntime).slice(5, 19)
|
||||
: '',
|
||||
};
|
||||
}),
|
||||
getState: procedure.query(async ({ ctx }) => {
|
||||
|
||||
@@ -53,13 +53,13 @@ const general = (overrides: Partial<GeneralRow> = {}): GeneralRow => ({
|
||||
updatedAt: now,
|
||||
...overrides,
|
||||
});
|
||||
const auth = (roles: string[] = []): GameSessionTokenPayload => ({
|
||||
const auth = (roles: string[] = [], userId = 'user-1'): GameSessionTokenPayload => ({
|
||||
version: 1,
|
||||
profile: 'che:default',
|
||||
issuedAt: now.toISOString(),
|
||||
expiresAt: new Date(now.getTime() + 86400000).toISOString(),
|
||||
sessionId: 'session',
|
||||
user: { id: 'user-1', username: 'tester', displayName: 'Tester', roles },
|
||||
user: { id: userId, username: 'tester', displayName: 'Tester', roles },
|
||||
sanctions: {},
|
||||
});
|
||||
const city = (id: number, nationId: number) => ({
|
||||
@@ -88,15 +88,30 @@ const city = (id: number, nationId: number) => ({
|
||||
meta: {},
|
||||
});
|
||||
|
||||
const context = (options: { me?: GeneralRow; roles?: string[]; nationMeta?: Record<string, unknown> } = {}) => {
|
||||
const context = (
|
||||
options: {
|
||||
me?: GeneralRow;
|
||||
roles?: string[];
|
||||
userId?: string;
|
||||
nationMeta?: Record<string, unknown>;
|
||||
nationLevel?: number;
|
||||
stationCityId?: number;
|
||||
} = {}
|
||||
) => {
|
||||
const me = options.me ?? general();
|
||||
const cities = [city(1, 1), city(2, 2), city(3, 2), city(80, 1)];
|
||||
const foreign = general({ id: 2, userId: 'user-2', name: '적군', nationId: 2, cityId: 2, crew: 777 });
|
||||
const db = {
|
||||
general: {
|
||||
findFirst: vi.fn(async () => me),
|
||||
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) =>
|
||||
where.userId === me.userId ? me : null
|
||||
),
|
||||
findMany: vi.fn(async (args: { where?: Record<string, unknown>; select?: Record<string, boolean> }) => {
|
||||
if (args.where?.nationId === 1 && args.select?.cityId) return [{ cityId: me.cityId }];
|
||||
if (args.where?.nationId === 1 && args.select?.cityId)
|
||||
return [
|
||||
{ cityId: me.cityId },
|
||||
...(options.stationCityId ? [{ cityId: options.stationCityId }] : []),
|
||||
];
|
||||
if (args.where?.cityId === 2) return [foreign];
|
||||
if (args.where?.cityId === 3) return [foreign];
|
||||
if (args.where?.officerLevel) return [];
|
||||
@@ -108,7 +123,7 @@ const context = (options: { me?: GeneralRow; roles?: string[]; nationMeta?: Reco
|
||||
id: 1,
|
||||
name: '아국',
|
||||
color: '#008000',
|
||||
level: 1,
|
||||
level: options.nationLevel ?? 1,
|
||||
capitalCityId: 1,
|
||||
meta: options.nationMeta ?? {},
|
||||
})),
|
||||
@@ -130,7 +145,7 @@ const context = (options: { me?: GeneralRow; roles?: string[]; nationMeta?: Reco
|
||||
turnDaemon: {} as GameApiContext['turnDaemon'],
|
||||
battleSim: {} as GameApiContext['battleSim'],
|
||||
profile: { id: 'che', scenario: 'default', name: 'che:default' },
|
||||
auth: auth(options.roles),
|
||||
auth: auth(options.roles, options.userId),
|
||||
uploadDir: 'uploads',
|
||||
uploadPath: '/uploads',
|
||||
uploadPublicUrl: null,
|
||||
@@ -157,6 +172,25 @@ describe('in-game information permissions', () => {
|
||||
expect(result.generals).toEqual([]);
|
||||
});
|
||||
|
||||
it('derives the actor from the session user instead of accepting another user general', async () => {
|
||||
const caller = appRouter.createCaller(context({ userId: 'user-2' }));
|
||||
await expect(caller.world.getCurrentCity({ cityId: 1 })).rejects.toMatchObject({ code: 'NOT_FOUND' });
|
||||
});
|
||||
|
||||
it('allows a nation member to select a city occupied by another general of the same nation', async () => {
|
||||
const result = await appRouter.createCaller(context({ stationCityId: 2 })).world.getCurrentCity({ cityId: 2 });
|
||||
expect(result.options.map((entry) => entry.id)).toContain(2);
|
||||
expect(result.visibility.full).toBe(true);
|
||||
});
|
||||
|
||||
it('does not grant a spy city while the nation has no active level', async () => {
|
||||
const result = await appRouter
|
||||
.createCaller(context({ nationMeta: { spy: { 2: 2 } }, nationLevel: 0 }))
|
||||
.world.getCurrentCity({ cityId: 2 });
|
||||
expect(result.options.map((entry) => entry.id)).not.toContain(2);
|
||||
expect(result.visibility.full).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps adjacent foreign detail redacted and never reveals military fields', async () => {
|
||||
const result = await appRouter
|
||||
.createCaller(context({ me: general({ cityId: 80 }) }))
|
||||
@@ -174,12 +208,20 @@ describe('in-game information permissions', () => {
|
||||
expect(result.visibility.full).toBe(true);
|
||||
expect(result.city.population).toBe(1000);
|
||||
expect(result.generals[0]).toMatchObject({ crew: 777, train: null, atmos: null, crewTypeId: null });
|
||||
expect(result.forceSummary).toMatchObject({
|
||||
enemyCrew: 777,
|
||||
enemyArmedGenerals: 1,
|
||||
enemyGenerals: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('allows administrative roles to inspect all city and general fields', async () => {
|
||||
const result = await appRouter.createCaller(context({ roles: ['admin'] })).world.getCurrentCity({ cityId: 3 });
|
||||
expect(result.options).toHaveLength(4);
|
||||
expect(result.visibility.full).toBe(true);
|
||||
expect(result.generals[0]).toMatchObject({ crew: 777, train: 90, atmos: 90, crewTypeId: 1 });
|
||||
});
|
||||
it.each(['admin', 'superuser', 'admin.superuser'])(
|
||||
'allows the %s role to inspect all city and general fields',
|
||||
async (role) => {
|
||||
const result = await appRouter.createCaller(context({ roles: [role] })).world.getCurrentCity({ cityId: 3 });
|
||||
expect(result.options).toHaveLength(4);
|
||||
expect(result.visibility.full).toBe(true);
|
||||
expect(result.generals[0]).toMatchObject({ crew: 777, train: 90, atmos: 90, crewTypeId: 1 });
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user