fix(game-ui): 세력일람 병력을 추정 정보로 교체
This commit is contained in:
@@ -10,6 +10,9 @@ import { resolveSecretPermission } from '../shared/secretPermission.js';
|
|||||||
|
|
||||||
const zDirectorySort = z.number().int().min(1).max(15).default(9);
|
const zDirectorySort = z.number().int().min(1).max(15).default(9);
|
||||||
|
|
||||||
|
const directoryGeneralTypes = ['만능', '통', '무', '지', '평범', '무지', '무능'] as const;
|
||||||
|
type DirectoryGeneralType = (typeof directoryGeneralTypes)[number];
|
||||||
|
|
||||||
const readNumber = (value: unknown, fallback = 0): number =>
|
const readNumber = (value: unknown, fallback = 0): number =>
|
||||||
typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||||
|
|
||||||
@@ -34,6 +37,28 @@ const compareString = (left: string, right: string): number => {
|
|||||||
return left < right ? -1 : 1;
|
return left < right ? -1 : 1;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resolveDirectoryGeneralType = (leadership: number, strength: number, intel: number): DirectoryGeneralType => {
|
||||||
|
if (leadership < 40) {
|
||||||
|
return strength + intel < 40 ? '무능' : '무지';
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxStat = Math.max(leadership, strength, intel);
|
||||||
|
const lowestPairSum = Math.min(leadership + strength, strength + intel, intel + leadership);
|
||||||
|
if (maxStat >= 70 && lowestPairSum >= maxStat * 1.7) {
|
||||||
|
return '만능';
|
||||||
|
}
|
||||||
|
if (strength >= 60 && intel < strength * 0.8) {
|
||||||
|
return '무';
|
||||||
|
}
|
||||||
|
if (intel >= 60 && strength < intel * 0.8) {
|
||||||
|
return '지';
|
||||||
|
}
|
||||||
|
if (leadership >= 60 && strength + intel < leadership) {
|
||||||
|
return '통';
|
||||||
|
}
|
||||||
|
return '평범';
|
||||||
|
};
|
||||||
|
|
||||||
const resolveExperienceLevel = (experience: number, maxLevel: number): number => {
|
const resolveExperienceLevel = (experience: number, maxLevel: number): number => {
|
||||||
const level = experience < 1_000 ? Math.trunc(experience / 100) : Math.trunc(Math.sqrt(experience / 10));
|
const level = experience < 1_000 ? Math.trunc(experience / 100) : Math.trunc(Math.sqrt(experience / 10));
|
||||||
return Math.max(0, Math.min(level, maxLevel));
|
return Math.max(0, Math.min(level, maxLevel));
|
||||||
@@ -77,7 +102,7 @@ const resolveRefreshText = (score: number): string => {
|
|||||||
export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
|
export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
|
||||||
await getMyGeneral(ctx);
|
await getMyGeneral(ctx);
|
||||||
|
|
||||||
const [nations, generals, cities] = await Promise.all([
|
const [nations, generals, cities, accessLogs, worldState] = await Promise.all([
|
||||||
ctx.db.nation.findMany({
|
ctx.db.nation.findMany({
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -96,7 +121,9 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
|
|||||||
npcState: true,
|
npcState: true,
|
||||||
nationId: true,
|
nationId: true,
|
||||||
cityId: true,
|
cityId: true,
|
||||||
crew: true,
|
leadership: true,
|
||||||
|
strength: true,
|
||||||
|
intel: true,
|
||||||
dedication: true,
|
dedication: true,
|
||||||
officerLevel: true,
|
officerLevel: true,
|
||||||
meta: true,
|
meta: true,
|
||||||
@@ -108,6 +135,12 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
|
|||||||
select: { id: true, name: true, nationId: true },
|
select: { id: true, name: true, nationId: true },
|
||||||
orderBy: { id: 'asc' },
|
orderBy: { id: 'asc' },
|
||||||
}),
|
}),
|
||||||
|
ctx.db.generalAccessLog.findMany({
|
||||||
|
select: { generalId: true, refreshScoreTotal: true },
|
||||||
|
}),
|
||||||
|
ctx.db.worldState.findFirst({
|
||||||
|
select: { tickSeconds: true, meta: true },
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const directoryNations = nations.some((nation) => nation.id === 0)
|
const directoryNations = nations.some((nation) => nation.id === 0)
|
||||||
@@ -136,6 +169,15 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
|
|||||||
}
|
}
|
||||||
const citiesByNation = new Map<number, typeof cities>();
|
const citiesByNation = new Map<number, typeof cities>();
|
||||||
const cityNameById = new Map(cities.map((city) => [city.id, city.name] as const));
|
const cityNameById = new Map(cities.map((city) => [city.id, city.name] as const));
|
||||||
|
const accessScoreByGeneralId = new Map(
|
||||||
|
accessLogs.map((accessLog) => [accessLog.generalId, accessLog.refreshScoreTotal] as const)
|
||||||
|
);
|
||||||
|
const worldMeta = asRecord(worldState?.meta);
|
||||||
|
const autorunUser = asRecord(worldMeta.autorun_user);
|
||||||
|
const worldKillturn = readNumber(worldMeta.killturn);
|
||||||
|
const turnMinutes = readNumber(worldState?.tickSeconds) / 60;
|
||||||
|
const autorunLimitTurns = turnMinutes > 0 ? readNumber(autorunUser.limit_minutes) / turnMinutes : 0;
|
||||||
|
const activeKillturnThreshold = worldKillturn - autorunLimitTurns;
|
||||||
for (const city of cities) {
|
for (const city of cities) {
|
||||||
const list = citiesByNation.get(city.nationId) ?? [];
|
const list = citiesByNation.get(city.nationId) ?? [];
|
||||||
list.push(city);
|
list.push(city);
|
||||||
@@ -169,6 +211,40 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
|
|||||||
false
|
false
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
const analyzedGenerals = nationGenerals.map((general) => {
|
||||||
|
const killturn = readMetaNumber(general.meta, 'killturn');
|
||||||
|
const inactive = general.npcState < 2 && killturn < activeKillturnThreshold;
|
||||||
|
const accessScore = accessScoreByGeneralId.get(general.id) ?? 0;
|
||||||
|
return {
|
||||||
|
id: general.id,
|
||||||
|
name: general.name,
|
||||||
|
npcState: general.npcState,
|
||||||
|
leadership: general.leadership,
|
||||||
|
type: resolveDirectoryGeneralType(general.leadership, general.strength, general.intel),
|
||||||
|
inactive,
|
||||||
|
accessGrade: accessScore >= 1_500 ? 'high' : accessScore >= 200 ? 'medium' : 'normal',
|
||||||
|
killturn,
|
||||||
|
} as const;
|
||||||
|
});
|
||||||
|
const combatGenerals = analyzedGenerals.filter(
|
||||||
|
(general) => general.type !== '무능' && general.type !== '무지'
|
||||||
|
);
|
||||||
|
const userCombatGenerals = combatGenerals.filter((general) => general.npcState < 2 && !general.inactive);
|
||||||
|
const npcCombatGenerals = combatGenerals.filter((general) => general.npcState >= 2 && general.killturn > 5);
|
||||||
|
const generalGroups = directoryGeneralTypes
|
||||||
|
.map((type) => ({
|
||||||
|
type,
|
||||||
|
label: `${type}장`,
|
||||||
|
generals: analyzedGenerals
|
||||||
|
.filter((general) => general.type === type)
|
||||||
|
.sort((left, right) => {
|
||||||
|
const leftScore = accessScoreByGeneralId.get(left.id) ?? 0;
|
||||||
|
const rightScore = accessScoreByGeneralId.get(right.id) ?? 0;
|
||||||
|
return rightScore - leftScore || compareString(left.name, right.name) || left.id - right.id;
|
||||||
|
})
|
||||||
|
.map(({ leadership: _leadership, killturn: _killturn, type: _type, ...general }) => general),
|
||||||
|
}))
|
||||||
|
.filter((group) => group.generals.length > 0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: nation.id,
|
id: nation.id,
|
||||||
@@ -183,8 +259,16 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
|
|||||||
capitalCityId: nation.capitalCityId ?? 0,
|
capitalCityId: nation.capitalCityId ?? 0,
|
||||||
rulerCityName: ruler ? (cityNameById.get(ruler.cityId) ?? null) : null,
|
rulerCityName: ruler ? (cityNameById.get(ruler.cityId) ?? null) : null,
|
||||||
generalCount: readMetaNumber(nation.meta, 'gennum', nationGenerals.length),
|
generalCount: readMetaNumber(nation.meta, 'gennum', nationGenerals.length),
|
||||||
totalCrew: nationGenerals.reduce((sum, general) => sum + general.crew, 0),
|
|
||||||
cityCount: nationCities.length,
|
cityCount: nationCities.length,
|
||||||
|
forceEstimate: {
|
||||||
|
totalGeneralCount: analyzedGenerals.length,
|
||||||
|
userCombatGeneralCount: userCombatGenerals.length,
|
||||||
|
userTroops: userCombatGenerals.reduce((sum, general) => sum + general.leadership * 100, 0),
|
||||||
|
npcCombatGeneralCount: npcCombatGenerals.length,
|
||||||
|
npcTroops: npcCombatGenerals.reduce((sum, general) => sum + general.leadership * 100, 0),
|
||||||
|
inactiveGeneralCount: analyzedGenerals.filter((general) => general.inactive).length,
|
||||||
|
},
|
||||||
|
generalGroups,
|
||||||
officers,
|
officers,
|
||||||
ambassadorNames: secretPermissions
|
ambassadorNames: secretPermissions
|
||||||
.filter(({ permission }) => permission === 4)
|
.filter(({ permission }) => permission === 4)
|
||||||
|
|||||||
@@ -125,6 +125,9 @@ const globalGenerals = [
|
|||||||
name: '군주',
|
name: '군주',
|
||||||
dedication: 900,
|
dedication: 900,
|
||||||
officerLevel: 12,
|
officerLevel: 12,
|
||||||
|
leadership: 80,
|
||||||
|
strength: 80,
|
||||||
|
intel: 80,
|
||||||
meta: { killturn: 4, owner_name: '통일유저' },
|
meta: { killturn: 4, owner_name: '통일유저' },
|
||||||
experience: 10_000,
|
experience: 10_000,
|
||||||
}),
|
}),
|
||||||
@@ -133,6 +136,9 @@ const globalGenerals = [
|
|||||||
name: '외교관',
|
name: '외교관',
|
||||||
dedication: 800,
|
dedication: 800,
|
||||||
officerLevel: 1,
|
officerLevel: 1,
|
||||||
|
leadership: 70,
|
||||||
|
strength: 80,
|
||||||
|
intel: 40,
|
||||||
meta: { killturn: 2, permission: 'ambassador' },
|
meta: { killturn: 2, permission: 'ambassador' },
|
||||||
experience: 5_000,
|
experience: 5_000,
|
||||||
}),
|
}),
|
||||||
@@ -141,6 +147,9 @@ const globalGenerals = [
|
|||||||
name: '제재외교관',
|
name: '제재외교관',
|
||||||
dedication: 700,
|
dedication: 700,
|
||||||
officerLevel: 1,
|
officerLevel: 1,
|
||||||
|
leadership: 70,
|
||||||
|
strength: 40,
|
||||||
|
intel: 80,
|
||||||
meta: { killturn: 1, permission: 'ambassador' },
|
meta: { killturn: 1, permission: 'ambassador' },
|
||||||
penalty: { noTopSecret: true },
|
penalty: { noTopSecret: true },
|
||||||
experience: 2_000,
|
experience: 2_000,
|
||||||
@@ -150,6 +159,9 @@ const globalGenerals = [
|
|||||||
name: '조언자',
|
name: '조언자',
|
||||||
dedication: 600,
|
dedication: 600,
|
||||||
officerLevel: 1,
|
officerLevel: 1,
|
||||||
|
leadership: 30,
|
||||||
|
strength: 70,
|
||||||
|
intel: 70,
|
||||||
meta: { killturn: 5, permission: 'auditor' },
|
meta: { killturn: 5, permission: 'auditor' },
|
||||||
experience: 1_000,
|
experience: 1_000,
|
||||||
}),
|
}),
|
||||||
@@ -162,6 +174,8 @@ const globalGenerals = [
|
|||||||
dedication: 500,
|
dedication: 500,
|
||||||
officerLevel: 12,
|
officerLevel: 12,
|
||||||
leadership: 80,
|
leadership: 80,
|
||||||
|
strength: 70,
|
||||||
|
intel: 65,
|
||||||
meta: { killturn: 0 },
|
meta: { killturn: 0 },
|
||||||
experience: 20_000,
|
experience: 20_000,
|
||||||
}),
|
}),
|
||||||
@@ -222,7 +236,12 @@ const createContext = (
|
|||||||
worldState: {
|
worldState: {
|
||||||
findFirst: vi.fn(async () => ({
|
findFirst: vi.fn(async () => ({
|
||||||
config: { const: { maxLevel: 100, maxDedLevel: 30 } },
|
config: { const: { maxLevel: 100, maxDedLevel: 30 } },
|
||||||
meta: { isUnited: options.isUnited ?? 0 },
|
meta: {
|
||||||
|
isUnited: options.isUnited ?? 0,
|
||||||
|
killturn: 4,
|
||||||
|
autorun_user: { limit_minutes: 60 },
|
||||||
|
},
|
||||||
|
tickSeconds: 3_600,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -280,6 +299,20 @@ describe('legacy global nation/general directories', () => {
|
|||||||
);
|
);
|
||||||
expect(results.slice(1)).toEqual([results[0], results[0], results[0]]);
|
expect(results.slice(1)).toEqual([results[0], results[0], results[0]]);
|
||||||
for (const result of results) {
|
for (const result of results) {
|
||||||
|
const serializedNationRows = JSON.stringify(result.nations);
|
||||||
|
for (const privateField of [
|
||||||
|
'totalCrew',
|
||||||
|
'crew',
|
||||||
|
'leadership',
|
||||||
|
'strength',
|
||||||
|
'intel',
|
||||||
|
'killturn',
|
||||||
|
'refreshScoreTotal',
|
||||||
|
'meta',
|
||||||
|
'penalty',
|
||||||
|
]) {
|
||||||
|
expect(serializedNationRows).not.toContain(`"${privateField}"`);
|
||||||
|
}
|
||||||
const serializedGeneralRows = JSON.stringify(result.generals);
|
const serializedGeneralRows = JSON.stringify(result.generals);
|
||||||
for (const privateField of [
|
for (const privateField of [
|
||||||
'userId',
|
'userId',
|
||||||
@@ -304,17 +337,75 @@ describe('legacy global nation/general directories', () => {
|
|||||||
expect(result[1]).toMatchObject({
|
expect(result[1]).toMatchObject({
|
||||||
id: 1,
|
id: 1,
|
||||||
generalCount: 4,
|
generalCount: 4,
|
||||||
totalCrew: 400,
|
|
||||||
cityCount: 1,
|
cityCount: 1,
|
||||||
|
forceEstimate: {
|
||||||
|
totalGeneralCount: 4,
|
||||||
|
userCombatGeneralCount: 1,
|
||||||
|
userTroops: 8_000,
|
||||||
|
npcCombatGeneralCount: 0,
|
||||||
|
npcTroops: 0,
|
||||||
|
inactiveGeneralCount: 2,
|
||||||
|
},
|
||||||
ambassadorNames: ['군주', '외교관'],
|
ambassadorNames: ['군주', '외교관'],
|
||||||
auditorCount: 1,
|
auditorCount: 1,
|
||||||
});
|
});
|
||||||
|
expect(result[1]?.generalGroups.map((group) => [group.label, group.generals.map(({ name }) => name)])).toEqual([
|
||||||
|
['만능장', ['군주']],
|
||||||
|
['무장', ['외교관']],
|
||||||
|
['지장', ['제재외교관']],
|
||||||
|
['무지장', ['조언자']],
|
||||||
|
]);
|
||||||
expect(result[1]?.generals.map((general) => general.name)).toEqual(['군주', '외교관', '제재외교관', '조언자']);
|
expect(result[1]?.generals.map((general) => general.name)).toEqual(['군주', '외교관', '제재외교관', '조언자']);
|
||||||
expect(result[1]?.officers[0]).toMatchObject({
|
expect(result[1]?.officers[0]).toMatchObject({
|
||||||
officerLevel: 12,
|
officerLevel: 12,
|
||||||
general: { id: 10, name: '군주' },
|
general: { id: 10, name: '군주' },
|
||||||
});
|
});
|
||||||
expect(result[2]).toMatchObject({ name: '재 야', generalCount: 1, totalCrew: 100, cityCount: 1 });
|
expect(result[2]).toMatchObject({ name: '재 야', generalCount: 1, cityCount: 1 });
|
||||||
|
expect(JSON.stringify(result)).not.toContain('totalCrew');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches every Ref general-category boundary and estimates only combat-capable groups', async () => {
|
||||||
|
const categoryStats = [
|
||||||
|
['만능장', 80, 80, 80],
|
||||||
|
['통장', 70, 20, 20],
|
||||||
|
['무장', 70, 80, 40],
|
||||||
|
['지장', 70, 40, 80],
|
||||||
|
['평범장', 60, 50, 50],
|
||||||
|
['무지장', 30, 50, 50],
|
||||||
|
['무능장', 30, 10, 10],
|
||||||
|
] as const;
|
||||||
|
const categoryGenerals = categoryStats.map(([name, leadership, strength, intel], index) =>
|
||||||
|
directoryGeneral({
|
||||||
|
id: 101 + index,
|
||||||
|
name,
|
||||||
|
nationId: 1,
|
||||||
|
leadership,
|
||||||
|
strength,
|
||||||
|
intel,
|
||||||
|
meta: { killturn: 4 },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const [nation] = await appRouter
|
||||||
|
.createCaller(
|
||||||
|
createContext({
|
||||||
|
directory: {
|
||||||
|
nations: [nationRows[0]!],
|
||||||
|
generals: categoryGenerals,
|
||||||
|
cities: [{ id: 1, name: '허창', nationId: 1 }],
|
||||||
|
},
|
||||||
|
}).context
|
||||||
|
)
|
||||||
|
.world.getNationDirectory();
|
||||||
|
|
||||||
|
expect(nation?.generalGroups.map((group) => group.label)).toEqual(categoryStats.map(([name]) => name));
|
||||||
|
expect(nation?.forceEstimate).toEqual({
|
||||||
|
totalGeneralCount: 7,
|
||||||
|
userCombatGeneralCount: 5,
|
||||||
|
userTroops: 35_000,
|
||||||
|
npcCombatGeneralCount: 0,
|
||||||
|
npcTroops: 0,
|
||||||
|
inactiveGeneralCount: 0,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('projects a level-zero nation location from its ruler city even when that city belongs to another nation', async () => {
|
it('projects a level-zero nation location from its ruler city even when that city belongs to another nation', async () => {
|
||||||
|
|||||||
@@ -19,7 +19,21 @@ const nationDirectory = [
|
|||||||
power: 2000,
|
power: 2000,
|
||||||
capitalCityId: 2,
|
capitalCityId: 2,
|
||||||
generalCount: 1,
|
generalCount: 1,
|
||||||
totalCrew: 1_200,
|
forceEstimate: {
|
||||||
|
totalGeneralCount: 1,
|
||||||
|
userCombatGeneralCount: 1,
|
||||||
|
userTroops: 8_000,
|
||||||
|
npcCombatGeneralCount: 0,
|
||||||
|
npcTroops: 0,
|
||||||
|
inactiveGeneralCount: 0,
|
||||||
|
},
|
||||||
|
generalGroups: [
|
||||||
|
{
|
||||||
|
type: '만능',
|
||||||
|
label: '만능장',
|
||||||
|
generals: [{ id: 20, name: '유비', npcState: 0, inactive: false, accessGrade: 'normal' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
cityCount: 1,
|
cityCount: 1,
|
||||||
officers: Array.from({ length: 8 }, (_, index) => ({
|
officers: Array.from({ length: 8 }, (_, index) => ({
|
||||||
officerLevel: 12 - index,
|
officerLevel: 12 - index,
|
||||||
@@ -39,7 +53,26 @@ const nationDirectory = [
|
|||||||
power: 1000,
|
power: 1000,
|
||||||
capitalCityId: 1,
|
capitalCityId: 1,
|
||||||
generalCount: 2,
|
generalCount: 2,
|
||||||
totalCrew: 2_500,
|
forceEstimate: {
|
||||||
|
totalGeneralCount: 2,
|
||||||
|
userCombatGeneralCount: 1,
|
||||||
|
userTroops: 9_000,
|
||||||
|
npcCombatGeneralCount: 0,
|
||||||
|
npcTroops: 0,
|
||||||
|
inactiveGeneralCount: 1,
|
||||||
|
},
|
||||||
|
generalGroups: [
|
||||||
|
{
|
||||||
|
type: '무',
|
||||||
|
label: '무장',
|
||||||
|
generals: [{ id: 10, name: '조조', npcState: 0, inactive: false, accessGrade: 'high' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: '지',
|
||||||
|
label: '지장',
|
||||||
|
generals: [{ id: 11, name: '순욱', npcState: 1, inactive: true, accessGrade: 'medium' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
cityCount: 1,
|
cityCount: 1,
|
||||||
officers: Array.from({ length: 8 }, (_, index) => ({
|
officers: Array.from({ length: 8 }, (_, index) => ({
|
||||||
officerLevel: 12 - index,
|
officerLevel: 12 - index,
|
||||||
@@ -62,7 +95,15 @@ const nationDirectory = [
|
|||||||
power: 0,
|
power: 0,
|
||||||
capitalCityId: 0,
|
capitalCityId: 0,
|
||||||
generalCount: 1,
|
generalCount: 1,
|
||||||
totalCrew: 0,
|
forceEstimate: {
|
||||||
|
totalGeneralCount: 1,
|
||||||
|
userCombatGeneralCount: 0,
|
||||||
|
userTroops: 0,
|
||||||
|
npcCombatGeneralCount: 1,
|
||||||
|
npcTroops: 7_000,
|
||||||
|
inactiveGeneralCount: 0,
|
||||||
|
},
|
||||||
|
generalGroups: [],
|
||||||
cityCount: 1,
|
cityCount: 1,
|
||||||
officers: Array.from({ length: 8 }, (_, index) => ({ officerLevel: 12 - index, general: null })),
|
officers: Array.from({ length: 8 }, (_, index) => ({ officerLevel: 12 - index, general: null })),
|
||||||
ambassadorNames: [],
|
ambassadorNames: [],
|
||||||
@@ -351,10 +392,9 @@ test('a level-zero nation shows the ruler current city even without territory th
|
|||||||
await expect(table.locator('.roaming-city')).toHaveCSS('color', 'rgb(255, 255, 0)');
|
await expect(table.locator('.roaming-city')).toHaveCSS('color', 'rgb(255, 255, 0)');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('PYA-scale general directory mounts only the active responsive layout and defers portraits', async (
|
test('PYA-scale general directory mounts only the active responsive layout and defers portraits', async ({
|
||||||
{ page },
|
page,
|
||||||
testInfo
|
}, testInfo) => {
|
||||||
) => {
|
|
||||||
const requestedPortraits = new Set<string>();
|
const requestedPortraits = new Set<string>();
|
||||||
page.on('request', (request) => {
|
page.on('request', (request) => {
|
||||||
if (request.url().includes('/performance/general-')) {
|
if (request.url().includes('/performance/general-')) {
|
||||||
@@ -700,8 +740,16 @@ test('nation directory reuses only the public general-directory row on hover and
|
|||||||
await page.waitForLoadState('networkidle');
|
await page.waitForLoadState('networkidle');
|
||||||
await expect(page.getByRole('button', { name: '장수 일람 연동' })).toHaveCount(0);
|
await expect(page.getByRole('button', { name: '장수 일람 연동' })).toHaveCount(0);
|
||||||
await expect(page.locator('[data-general-preview-trigger]')).toHaveCount(4);
|
await expect(page.locator('[data-general-preview-trigger]')).toHaveCount(4);
|
||||||
await expect(page.locator('[data-nation-id="1"]')).toContainText('총 병사');
|
const nationAnalysis = page.locator('[data-nation-id="1"] .force-analysis');
|
||||||
await expect(page.locator('[data-nation-id="1"]')).toContainText('2,500');
|
await expect(nationAnalysis).not.toContainText('총 병사');
|
||||||
|
await expect(nationAnalysis).toContainText('무장(1): 조조');
|
||||||
|
await expect(nationAnalysis).toContainText('지장(1): ⓝ순욱');
|
||||||
|
await expect(nationAnalysis).toContainText('예상 병력 약 9,000명');
|
||||||
|
await expect(nationAnalysis).toContainText('삭턴장(1)');
|
||||||
|
await expect(page.locator('[data-general-preview-trigger="11"]')).toHaveCSS(
|
||||||
|
'text-decoration-line',
|
||||||
|
'line-through'
|
||||||
|
);
|
||||||
expect(requestedOperations.filter((operation) => operation === 'world.getGeneralDirectory')).toHaveLength(
|
expect(requestedOperations.filter((operation) => operation === 'world.getGeneralDirectory')).toHaveLength(
|
||||||
viewport.name === 'desktop' ? 0 : 1
|
viewport.name === 'desktop' ? 0 : 1
|
||||||
);
|
);
|
||||||
@@ -865,9 +913,7 @@ test('nation and general directories rearrange for mobile and keep the tapped pr
|
|||||||
await expect(page.locator('.general-card-list [data-general-card-id]')).toHaveCount(2);
|
await expect(page.locator('.general-card-list [data-general-card-id]')).toHaveCount(2);
|
||||||
await expect(page.locator('[data-directory-tooltip="card-special-domestic-10"]')).toContainText('상재');
|
await expect(page.locator('[data-directory-tooltip="card-special-domestic-10"]')).toContainText('상재');
|
||||||
await expect(page.locator('[data-directory-tooltip="card-special-war-10"]')).toContainText('귀모');
|
await expect(page.locator('[data-directory-tooltip="card-special-war-10"]')).toContainText('귀모');
|
||||||
await expect(page.locator('[data-directory-tooltip="card-injury-leadership-10"] > .wounded')).toHaveText(
|
await expect(page.locator('[data-directory-tooltip="card-injury-leadership-10"] > .wounded')).toHaveText('81');
|
||||||
'81'
|
|
||||||
);
|
|
||||||
await expect(page.locator('[data-general-card-id="10"] .leadership-bonus')).toHaveText('+6');
|
await expect(page.locator('[data-general-card-id="10"] .leadership-bonus')).toHaveText('+6');
|
||||||
const mobileInjury = page.locator('[data-directory-tooltip="card-injury-leadership-10"]');
|
const mobileInjury = page.locator('[data-directory-tooltip="card-injury-leadership-10"]');
|
||||||
await mobileInjury.focus();
|
await mobileInjury.focus();
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ const officerName = (nation: Nation, officerLevel: number) =>
|
|||||||
nation.officers.find((officer) => officer.officerLevel === officerLevel)?.general;
|
nation.officers.find((officer) => officer.officerLevel === officerLevel)?.general;
|
||||||
const displayGeneralName = (general: { name: string; npcState: number }) =>
|
const displayGeneralName = (general: { name: string; npcState: number }) =>
|
||||||
general.npcState > 0 && !/^[ⓜⓝ㉥]/u.test(general.name) ? `ⓝ${general.name}` : general.name;
|
general.npcState > 0 && !/^[ⓜⓝ㉥]/u.test(general.name) ? `ⓝ${general.name}` : general.name;
|
||||||
|
const displayAnalyzedGeneralColor = (general: { npcState: number; accessGrade: 'normal' | 'medium' | 'high' }) => {
|
||||||
|
if (general.accessGrade === 'high') return 'yellow';
|
||||||
|
if (general.accessGrade === 'medium') return 'lightgreen';
|
||||||
|
return getNpcColor(general.npcState);
|
||||||
|
};
|
||||||
const displayAmbassadorName = (nation: Nation, name: string) => {
|
const displayAmbassadorName = (nation: Nation, name: string) => {
|
||||||
const general = nation.generals.find((candidate) => candidate.name === name);
|
const general = nation.generals.find((candidate) => candidate.name === name);
|
||||||
return general ? displayGeneralName(general) : name;
|
return general ? displayGeneralName(general) : name;
|
||||||
@@ -203,16 +208,6 @@ onBeforeUnmount(() => {
|
|||||||
<td class="label-cell">장수 / 속령</td>
|
<td class="label-cell">장수 / 속령</td>
|
||||||
<td class="value-wide">{{ nation.generalCount }} / {{ nation.cityCount }}</td>
|
<td class="value-wide">{{ nation.generalCount }} / {{ nation.cityCount }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr class="desktop-only">
|
|
||||||
<td class="label-cell">총 병사</td>
|
|
||||||
<td class="value-wide">{{ nation.totalCrew.toLocaleString('ko-KR') }}</td>
|
|
||||||
<td colspan="6"></td>
|
|
||||||
</tr>
|
|
||||||
<tr class="mobile-only">
|
|
||||||
<td class="label-cell">총 병사</td>
|
|
||||||
<td class="value-wide">{{ nation.totalCrew.toLocaleString('ko-KR') }}</td>
|
|
||||||
<td colspan="2"></td>
|
|
||||||
</tr>
|
|
||||||
<tr v-for="row in 2" :key="`desktop-officers-${row}`" class="desktop-only">
|
<tr v-for="row in 2" :key="`desktop-officers-${row}`" class="desktop-only">
|
||||||
<template v-for="column in 4" :key="column">
|
<template v-for="column in 4" :key="column">
|
||||||
<td class="label-cell">
|
<td class="label-cell">
|
||||||
@@ -287,27 +282,41 @@ onBeforeUnmount(() => {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="8">
|
<td colspan="8" class="force-analysis">
|
||||||
장수 일람 :
|
<p class="force-summary">
|
||||||
<template v-for="general in nation.generals" :key="general.id">
|
* 총({{ nation.forceEstimate.totalGeneralCount }}), 전투장({{
|
||||||
<button
|
nation.forceEstimate.userCombatGeneralCount
|
||||||
type="button"
|
}}, 예상 병력 약 {{ nation.forceEstimate.userTroops.toLocaleString('ko-KR') }}명),
|
||||||
class="general-preview-trigger"
|
전투N장({{ nation.forceEstimate.npcCombatGeneralCount }}, 예상 병력 약
|
||||||
:data-general-preview-trigger="general.id"
|
{{ nation.forceEstimate.npcTroops.toLocaleString('ko-KR') }}명), 삭턴장({{
|
||||||
:style="{ color: getNpcColor(general.npcState) }"
|
nation.forceEstimate.inactiveGeneralCount
|
||||||
:aria-expanded="activeGeneralId === general.id"
|
}}) *
|
||||||
:aria-describedby="
|
</p>
|
||||||
activeGeneralId === general.id ? 'nation-general-preview' : undefined
|
<p v-for="group in nation.generalGroups" :key="group.type" class="general-group">
|
||||||
"
|
<strong class="general-group-label"
|
||||||
@pointerdown="showGeneralDetailsFromTouch($event, general.id)"
|
>{{ group.label }}({{ group.generals.length }})</strong
|
||||||
@pointerenter="showGeneralDetails($event, general.id)"
|
>:
|
||||||
@pointerleave="hideGeneralDetailsFromPointer($event, general.id)"
|
<template v-for="(general, index) in group.generals" :key="general.id">
|
||||||
@focus="showGeneralDetails($event, general.id)"
|
<button
|
||||||
@blur="hideGeneralDetails(general.id)"
|
type="button"
|
||||||
>
|
class="general-preview-trigger"
|
||||||
{{ displayGeneralName(general) }}</button
|
:class="{ 'inactive-general': general.inactive }"
|
||||||
>,
|
:data-general-preview-trigger="general.id"
|
||||||
</template>
|
:style="{ color: displayAnalyzedGeneralColor(general) }"
|
||||||
|
:aria-expanded="activeGeneralId === general.id"
|
||||||
|
:aria-describedby="
|
||||||
|
activeGeneralId === general.id ? 'nation-general-preview' : undefined
|
||||||
|
"
|
||||||
|
@pointerdown="showGeneralDetailsFromTouch($event, general.id)"
|
||||||
|
@pointerenter="showGeneralDetails($event, general.id)"
|
||||||
|
@pointerleave="hideGeneralDetailsFromPointer($event, general.id)"
|
||||||
|
@focus="showGeneralDetails($event, general.id)"
|
||||||
|
@blur="hideGeneralDetails(general.id)"
|
||||||
|
>
|
||||||
|
{{ displayGeneralName(general) }}</button
|
||||||
|
><template v-if="index < group.generals.length - 1">, </template>
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -326,12 +335,6 @@ onBeforeUnmount(() => {
|
|||||||
<td class="neutral-label">속 령</td>
|
<td class="neutral-label">속 령</td>
|
||||||
<td class="neutral-value">{{ nation.cityCount }}</td>
|
<td class="neutral-value">{{ nation.cityCount }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
|
||||||
<td class="neutral-spacer"> </td>
|
|
||||||
<td class="neutral-label">총 병사</td>
|
|
||||||
<td class="neutral-value">{{ nation.totalCrew.toLocaleString('ko-KR') }}</td>
|
|
||||||
<td colspan="2"></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="5">
|
<td colspan="5">
|
||||||
속령 일람 :
|
속령 일람 :
|
||||||
@@ -505,6 +508,28 @@ onBeforeUnmount(() => {
|
|||||||
outline: 1px dashed cyan;
|
outline: 1px dashed cyan;
|
||||||
outline-offset: 1px;
|
outline-offset: 1px;
|
||||||
}
|
}
|
||||||
|
.force-analysis {
|
||||||
|
padding: 0 0 0 5.8em !important;
|
||||||
|
text-indent: -5.8em;
|
||||||
|
}
|
||||||
|
.force-summary,
|
||||||
|
.general-group {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.force-summary {
|
||||||
|
color: yellow;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
text-indent: 0;
|
||||||
|
}
|
||||||
|
.general-group-label {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 5.3em;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.inactive-general {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
.general-hover-preview {
|
.general-hover-preview {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 30;
|
z-index: 30;
|
||||||
|
|||||||
Reference in New Issue
Block a user