feat: 메인 도시 정보를 Ref와 일치

도시 소유 국가색과 태수·군사·종사를 general.me에 추가한다. 주민 영역, 반응형 grid와 국가색 대비를 Ref 카드와 같은 geometry로 맞추고 API 및 production Chromium 회귀 검증을 추가한다.
This commit is contained in:
2026-08-20 16:43:54 +00:00
parent 50e2dc1ddb
commit 481197a84c
7 changed files with 372 additions and 85 deletions
+70 -32
View File
@@ -34,6 +34,7 @@ import {
resolveNationBlockWar,
resolveNationNotice,
resolveNationRate,
resolveOfficerCity,
type TraitNameMap,
} from '../nation/shared.js';
import {
@@ -348,38 +349,73 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
]);
const nation = queriedNation ?? NEUTRAL_NATION_CONTEXT;
const [capitalCity, cityNation, troopLeaderCity, nationPopulation, nationCrew, topChiefRows] = await Promise.all([
nation.capitalCityId
? ctx.db.city.findUnique({ where: { id: nation.capitalCityId }, select: { name: true } })
: Promise.resolve(null),
city && city.nationId > 0
? ctx.db.nation.findUnique({ where: { id: city.nationId }, select: { name: true } })
: Promise.resolve(null),
troopLeader && troopLeader.cityId > 0
? ctx.db.city.findUnique({ where: { id: troopLeader.cityId }, select: { name: true } })
: Promise.resolve(null),
nation.id > 0
? ctx.db.city.aggregate({
where: { nationId: nation.id },
_count: true,
_sum: { population: true, populationMax: true },
})
: Promise.resolve({ _count: 0, _sum: { population: 0, populationMax: 0 } }),
nation.id > 0
? ctx.db.general.aggregate({
where: { nationId: nation.id, npcState: { not: 5 } },
_count: true,
_sum: { crew: true, leadership: true },
})
: Promise.resolve({ _count: 0, _sum: { crew: 0, leadership: 0 } }),
nation.id > 0
? ctx.db.general.findMany({
where: { nationId: nation.id, officerLevel: { gte: 11 } },
select: { id: true, name: true, npcState: true, officerLevel: true },
orderBy: { id: 'asc' },
})
: Promise.resolve([]),
]);
const [capitalCity, cityNation, troopLeaderCity, nationPopulation, nationCrew, chiefAndCityOfficerRows] =
await Promise.all([
nation.capitalCityId
? ctx.db.city.findUnique({ where: { id: nation.capitalCityId }, select: { name: true } })
: Promise.resolve(null),
city && city.nationId > 0
? ctx.db.nation.findUnique({ where: { id: city.nationId }, select: { name: true, color: true } })
: Promise.resolve(null),
troopLeader && troopLeader.cityId > 0
? ctx.db.city.findUnique({ where: { id: troopLeader.cityId }, select: { name: true } })
: Promise.resolve(null),
nation.id > 0
? ctx.db.city.aggregate({
where: { nationId: nation.id },
_count: true,
_sum: { population: true, populationMax: true },
})
: Promise.resolve({ _count: 0, _sum: { population: 0, populationMax: 0 } }),
nation.id > 0
? ctx.db.general.aggregate({
where: { nationId: nation.id, npcState: { not: 5 } },
_count: true,
_sum: { crew: true, leadership: true },
})
: Promise.resolve({ _count: 0, _sum: { crew: 0, leadership: 0 } }),
nation.id > 0 || city
? ctx.db.general.findMany({
where: {
OR: [
{ nationId: nation.id > 0 ? nation.id : -1, officerLevel: { gte: 11 } },
{
officerLevel: { in: [2, 3, 4] },
meta: { path: ['officerCity'], equals: city?.id ?? -1 },
},
{
officerLevel: { in: [2, 3, 4] },
meta: { path: ['officer_city'], equals: city?.id ?? -1 },
},
],
},
select: { id: true, name: true, npcState: true, nationId: true, officerLevel: true, meta: true },
orderBy: { id: 'asc' },
})
: Promise.resolve([]),
]);
const topChiefRows = chiefAndCityOfficerRows.filter(
(chief) => nation.id > 0 && chief.nationId === nation.id && chief.officerLevel >= 11
);
const cityOfficers: Record<2 | 3 | 4, { id: number; name: string; npcState: number } | null> = {
2: null,
3: null,
4: null,
};
if (city) {
for (const officer of chiefAndCityOfficerRows) {
if (
(officer.officerLevel === 2 || officer.officerLevel === 3 || officer.officerLevel === 4) &&
resolveOfficerCity(asRecord(officer.meta)) === city.id
) {
cityOfficers[officer.officerLevel] = {
id: officer.id,
name: officer.name,
npcState: officer.npcState,
};
}
}
}
const [personalityNames, domesticNames, warNames, nationTypeNames, crewTypeNames, itemNames] = await Promise.all([
loadTraitNames([general.personalCode], 'personality'),
loadTraitNames([general.specialCode], 'domestic'),
@@ -528,6 +564,8 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
levelName: resolveCityLevelName(city.level),
regionName: resolveRegionName(city.region),
nationName: city.nationId > 0 ? (cityNation?.name ?? '-') : '공백지',
nationColor: city.nationId > 0 ? (cityNation?.color ?? '#000000') : '#000000',
officers: cityOfficers,
}
: null,
nation: {
@@ -290,6 +290,64 @@ describe('in-game my information ownership', () => {
);
});
it('returns the current city nation color and its Ref city officers', async () => {
const me = buildGeneral();
const fixture = createContext({
me,
targets: [
me,
buildGeneral({ id: 8, name: '태수장', officerLevel: 4, npcState: 0, meta: { officerCity: 1 } }),
buildGeneral({ id: 9, name: '군사장', officerLevel: 3, npcState: 2, meta: { officer_city: 1 } }),
buildGeneral({ id: 10, name: '타도시종사', officerLevel: 2, npcState: 6, meta: { officerCity: 2 } }),
],
city: {
id: 1,
name: '업',
level: 8,
nationId: 1,
population: 1_000,
populationMax: 2_000,
agriculture: 100,
agricultureMax: 200,
commerce: 100,
commerceMax: 200,
security: 100,
securityMax: 200,
trust: 70,
trade: 100,
defence: 100,
defenceMax: 200,
wall: 100,
wallMax: 200,
region: 2,
supplyState: 1,
frontState: 0,
},
});
await expect(appRouter.createCaller(fixture.context).general.me()).resolves.toMatchObject({
city: {
nationName: '위',
nationColor: '#777777',
officers: {
4: { id: 8, name: '태수장', npcState: 0 },
3: { id: 9, name: '군사장', npcState: 2 },
2: null,
},
},
});
expect(fixture.db.general.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
OR: expect.arrayContaining([
expect.objectContaining({ meta: { path: ['officerCity'], equals: 1 } }),
expect.objectContaining({ meta: { path: ['officer_city'], equals: 1 } }),
]),
},
})
);
});
it('returns Ref display names instead of numeric levels and internal codes for the main GUI', async () => {
const fixture = createContext({
me: buildGeneral({