feat(game): match Ref nation basic card
This commit is contained in:
@@ -28,7 +28,20 @@ import {
|
||||
sanitizeInternalDisplayCode,
|
||||
} from '../../services/gameDisplayNames.js';
|
||||
import { getMyGeneral } from '../shared/general.js';
|
||||
import { loadTraitNames, resolveNationNotice, type TraitNameMap } from '../nation/shared.js';
|
||||
import {
|
||||
loadTraitNames,
|
||||
resolveNationBill,
|
||||
resolveNationBlockScout,
|
||||
resolveNationBlockWar,
|
||||
resolveNationNotice,
|
||||
resolveNationRate,
|
||||
type TraitNameMap,
|
||||
} from '../nation/shared.js';
|
||||
import {
|
||||
resolveImpossibleStrategicCommands,
|
||||
resolveMainNationTech,
|
||||
splitNationTraitInfo,
|
||||
} from '../../services/mainNationProjection.js';
|
||||
|
||||
const zGeneralSettings = z.object({
|
||||
tnmt: z.number().int().optional(),
|
||||
@@ -54,6 +67,7 @@ const NEUTRAL_NATION_CONTEXT = {
|
||||
tech: 0,
|
||||
typeCode: 'None',
|
||||
capitalCityId: null,
|
||||
meta: {},
|
||||
} as const;
|
||||
|
||||
const resolveImmediateActionRequestId = (
|
||||
@@ -296,20 +310,42 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
||||
tech: true,
|
||||
typeCode: true,
|
||||
capitalCityId: true,
|
||||
meta: true,
|
||||
},
|
||||
})
|
||||
: Promise.resolve(NEUTRAL_NATION_CONTEXT),
|
||||
ctx.db.worldState.findFirst({ select: { config: true } }),
|
||||
ctx.db.worldState.findFirst({ select: { currentYear: true, currentMonth: true, config: true, meta: true } }),
|
||||
]);
|
||||
const nation = queriedNation ?? NEUTRAL_NATION_CONTEXT;
|
||||
|
||||
const [capitalCity, cityNation] = await Promise.all([
|
||||
const [capitalCity, cityNation, 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),
|
||||
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 [personalityNames, domesticNames, warNames, nationTypeNames, crewTypeNames, itemNames] = await Promise.all([
|
||||
loadTraitNames([general.personalCode], 'personality'),
|
||||
@@ -327,6 +363,18 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
||||
const settings = resolveUserSettings(metaRecord);
|
||||
const penalties = resolvePenalty(general.penalty);
|
||||
const dedicationLevel = readNumber(metaRecord.dedlevel, 0);
|
||||
const nationMeta = asRecord(nation.meta);
|
||||
const nationType = nationTypeNames.get(nation.typeCode);
|
||||
const nationTypeEffects = splitNationTraitInfo(nationType?.info ?? '');
|
||||
const nationTech = resolveMainNationTech({
|
||||
tech: nation.tech,
|
||||
currentYear: worldState?.currentYear ?? 0,
|
||||
worldConfig: worldState?.config,
|
||||
worldMeta: worldState?.meta,
|
||||
});
|
||||
const topChiefs = Object.fromEntries(
|
||||
topChiefRows.map((chief) => [chief.officerLevel, { id: chief.id, name: chief.name, npcState: chief.npcState }])
|
||||
);
|
||||
const itemName = (code: string | null): string | null => {
|
||||
const normalized = normalizeItemCode(code);
|
||||
return normalized ? (itemNames.get(normalized) ?? sanitizeInternalDisplayCode(normalized)) : null;
|
||||
@@ -406,13 +454,48 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
||||
}
|
||||
: null,
|
||||
nation: {
|
||||
...nation,
|
||||
id: nation.id,
|
||||
name: nation.name,
|
||||
color: nation.color,
|
||||
level: nation.level,
|
||||
gold: nation.gold,
|
||||
rice: nation.rice,
|
||||
tech: nation.tech,
|
||||
typeCode: nation.typeCode,
|
||||
capitalCityId: nation.capitalCityId,
|
||||
levelName: resolveNationLevelName(nation.level),
|
||||
typeName:
|
||||
nation.id === 0
|
||||
? '해당 없음'
|
||||
: (nationTypeNames.get(nation.typeCode)?.name ?? sanitizeInternalDisplayCode(nation.typeCode)),
|
||||
typeName: nation.id === 0 ? '-' : (nationType?.name ?? sanitizeInternalDisplayCode(nation.typeCode)),
|
||||
typePros: nationTypeEffects.pros,
|
||||
typeCons: nationTypeEffects.cons,
|
||||
capitalCityName: nation.id === 0 ? null : (capitalCity?.name ?? null),
|
||||
population: {
|
||||
cityCount: nationPopulation._count,
|
||||
current: nationPopulation._sum.population ?? 0,
|
||||
max: nationPopulation._sum.populationMax ?? 0,
|
||||
},
|
||||
crew: {
|
||||
generalCount: nationCrew._count,
|
||||
current: nationCrew._sum.crew ?? 0,
|
||||
max: (nationCrew._sum.leadership ?? 0) * 100,
|
||||
},
|
||||
power: readNumber(nationMeta.power, 0),
|
||||
bill: resolveNationBill(nationMeta),
|
||||
taxRate: resolveNationRate(nation),
|
||||
strategicCommandLimit: readNumber(nationMeta.strategic_cmd_limit, 0),
|
||||
diplomaticLimit: readNumber(nationMeta.surlimit, 0),
|
||||
prohibitScout: resolveNationBlockScout(nationMeta),
|
||||
prohibitWar: resolveNationBlockWar(nationMeta),
|
||||
techLevel: nationTech.level,
|
||||
techLimited: nationTech.limited,
|
||||
topChiefs,
|
||||
impossibleStrategicCommands:
|
||||
nation.id === 0
|
||||
? []
|
||||
: resolveImpossibleStrategicCommands(
|
||||
nationMeta,
|
||||
worldState?.currentYear ?? 0,
|
||||
worldState?.currentMonth ?? 1
|
||||
),
|
||||
},
|
||||
settings,
|
||||
penalties,
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
|
||||
const STRATEGIC_COMMAND_NAMES = [
|
||||
'필사즉생',
|
||||
'백성동원',
|
||||
'수몰',
|
||||
'허보',
|
||||
'의병모집',
|
||||
'이호경식',
|
||||
'급습',
|
||||
'피장파장',
|
||||
] as const;
|
||||
|
||||
const readFiniteNumber = (value: unknown, fallback = 0): number => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const clamp = (value: number, min: number, max: number): number => Math.min(max, Math.max(min, value));
|
||||
|
||||
export const splitNationTraitInfo = (info: string): { pros: string; cons: string } => {
|
||||
const tokens = info.trim().split(/\s+/u).filter(Boolean);
|
||||
return {
|
||||
pros: tokens.filter((token) => token.endsWith('↑')).join(' '),
|
||||
cons: tokens.filter((token) => token.endsWith('↓')).join(' '),
|
||||
};
|
||||
};
|
||||
|
||||
export const resolveMainNationTech = (options: {
|
||||
tech: number;
|
||||
currentYear: number;
|
||||
worldConfig: unknown;
|
||||
worldMeta: unknown;
|
||||
}): { level: number; limited: boolean } => {
|
||||
const config = asRecord(options.worldConfig);
|
||||
const constValues = asRecord(config.const ?? config.consts);
|
||||
const scenarioMeta = asRecord(asRecord(options.worldMeta).scenarioMeta);
|
||||
const maxLevel = Math.max(1, Math.floor(readFiniteNumber(constValues.maxTechLevel, 12)));
|
||||
const initialLevel = Math.max(1, Math.floor(readFiniteNumber(constValues.initialAllowedTechLevel, 1)));
|
||||
const increaseYears = Math.max(1, Math.floor(readFiniteNumber(constValues.techLevelIncYear, 5)));
|
||||
const startYear = readFiniteNumber(scenarioMeta.startYear, options.currentYear);
|
||||
const relativeMaximum = clamp(
|
||||
Math.floor((options.currentYear - startYear) / increaseYears) + initialLevel,
|
||||
1,
|
||||
maxLevel
|
||||
);
|
||||
const level = clamp(Math.floor(options.tech / 1000), 0, maxLevel);
|
||||
return { level, limited: level >= relativeMaximum };
|
||||
};
|
||||
|
||||
export const resolveImpossibleStrategicCommands = (
|
||||
nationMeta: unknown,
|
||||
currentYear: number,
|
||||
currentMonth: number
|
||||
): Array<{ name: string; remainingTurns: number; availableYear: number; availableMonth: number }> => {
|
||||
const meta = asRecord(nationMeta);
|
||||
const currentYearMonth = Math.floor(currentYear) * 12 + Math.floor(currentMonth) - 1;
|
||||
const result: Array<{ name: string; remainingTurns: number; availableYear: number; availableMonth: number }> = [];
|
||||
|
||||
for (const name of STRATEGIC_COMMAND_NAMES) {
|
||||
const nextAvailable = Math.floor(readFiniteNumber(meta[`next_execute_${name}`], 0));
|
||||
if (nextAvailable <= currentYearMonth) continue;
|
||||
result.push({
|
||||
name,
|
||||
remainingTurns: nextAvailable - currentYearMonth,
|
||||
availableYear: Math.floor(nextAvailable / 12),
|
||||
availableMonth: (nextAvailable % 12) + 1,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
resolveImpossibleStrategicCommands,
|
||||
resolveMainNationTech,
|
||||
splitNationTraitInfo,
|
||||
} from '../src/services/mainNationProjection.js';
|
||||
|
||||
describe('main nation projection', () => {
|
||||
it('splits the Ref nation-type advantages and disadvantages without changing their order', () => {
|
||||
expect(splitNationTraitInfo('농상↑ 민심↑ 쌀수입↓')).toEqual({
|
||||
pros: '농상↑ 민심↑',
|
||||
cons: '쌀수입↓',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the scenario-relative Ref technology grade and limit', () => {
|
||||
expect(
|
||||
resolveMainNationTech({
|
||||
tech: 3_999,
|
||||
currentYear: 190,
|
||||
worldConfig: {
|
||||
const: { maxTechLevel: 12, initialAllowedTechLevel: 1, techLevelIncYear: 5 },
|
||||
},
|
||||
worldMeta: { scenarioMeta: { startYear: 180 } },
|
||||
})
|
||||
).toEqual({ level: 3, limited: true });
|
||||
});
|
||||
|
||||
it('returns only strategic commands whose Ref-compatible cooldown is still active', () => {
|
||||
expect(
|
||||
resolveImpossibleStrategicCommands(
|
||||
{
|
||||
next_execute_수몰: 190 * 12 + 4,
|
||||
next_execute_허보: 190 * 12 + 2,
|
||||
},
|
||||
190,
|
||||
4
|
||||
)
|
||||
).toEqual([{ name: '수몰', remainingTurns: 1, availableYear: 190, availableMonth: 5 }]);
|
||||
});
|
||||
});
|
||||
@@ -116,7 +116,31 @@ const generalContext = {
|
||||
items: { horse: 'None', weapon: 'None', book: 'None', item: 'None' },
|
||||
},
|
||||
city,
|
||||
nation: { id: 1, name: '아국', color: '#008000', level: 1 },
|
||||
nation: {
|
||||
id: 1,
|
||||
name: '아국',
|
||||
color: '#008000',
|
||||
level: 1,
|
||||
gold: 10_000,
|
||||
rice: 9_000,
|
||||
tech: 100,
|
||||
typeName: '유가',
|
||||
typePros: '농상↑ 민심↑',
|
||||
typeCons: '쌀수입↓',
|
||||
population: { cityCount: 1, current: 150_000, max: 620_500 },
|
||||
crew: { generalCount: 2, current: 500, max: 7_000 },
|
||||
power: 1_234,
|
||||
bill: 100,
|
||||
taxRate: 20,
|
||||
strategicCommandLimit: 0,
|
||||
diplomaticLimit: 0,
|
||||
prohibitScout: false,
|
||||
prohibitWar: false,
|
||||
techLevel: 0,
|
||||
techLimited: false,
|
||||
topChiefs: {},
|
||||
impossibleStrategicCommands: [],
|
||||
},
|
||||
settings: {},
|
||||
penalties: {},
|
||||
};
|
||||
|
||||
@@ -145,9 +145,24 @@ const myGeneral = (state: FixtureState) => ({
|
||||
rice: 0,
|
||||
tech: 0,
|
||||
typeCode: 'None',
|
||||
typeName: '해당 없음',
|
||||
typeName: '-',
|
||||
typePros: '',
|
||||
typeCons: '',
|
||||
capitalCityId: null,
|
||||
capitalCityName: null,
|
||||
population: { cityCount: 0, current: 0, max: 0 },
|
||||
crew: { generalCount: 0, current: 0, max: 0 },
|
||||
power: 0,
|
||||
bill: 100,
|
||||
taxRate: 20,
|
||||
strategicCommandLimit: 0,
|
||||
diplomaticLimit: 0,
|
||||
prohibitScout: false,
|
||||
prohibitWar: false,
|
||||
techLevel: 0,
|
||||
techLimited: false,
|
||||
topChiefs: {},
|
||||
impossibleStrategicCommands: [],
|
||||
}
|
||||
: {
|
||||
id: 1,
|
||||
@@ -162,6 +177,21 @@ const myGeneral = (state: FixtureState) => ({
|
||||
typeName: '법가',
|
||||
capitalCityId: 1,
|
||||
capitalCityName: '업',
|
||||
typePros: '금수입↑ 치안↑',
|
||||
typeCons: '인구↓ 민심↓',
|
||||
population: { cityCount: 1, current: 1_000, max: 2_000 },
|
||||
crew: { generalCount: 2, current: 500, max: 7_000 },
|
||||
power: 1_234,
|
||||
bill: 100,
|
||||
taxRate: 20,
|
||||
strategicCommandLimit: 0,
|
||||
diplomaticLimit: 0,
|
||||
prohibitScout: false,
|
||||
prohibitWar: false,
|
||||
techLevel: 0,
|
||||
techLimited: false,
|
||||
topChiefs: {},
|
||||
impossibleStrategicCommands: [],
|
||||
},
|
||||
settings: {
|
||||
tnmt: 0,
|
||||
|
||||
@@ -260,6 +260,24 @@ const generalContext = (state: NavigationFixture) => ({
|
||||
bill: 100,
|
||||
capitalCityId: 1,
|
||||
typeCode: 'che_유가',
|
||||
typeName: '유가',
|
||||
typePros: '농상↑ 민심↑',
|
||||
typeCons: '쌀수입↓',
|
||||
population: { cityCount: 2, current: 150_000, max: 620_500 },
|
||||
crew: { generalCount: 2, current: 500, max: 7_000 },
|
||||
power: 1_234,
|
||||
taxRate: state.nationRate ?? 20,
|
||||
strategicCommandLimit: 2,
|
||||
diplomaticLimit: 0,
|
||||
prohibitScout: false,
|
||||
prohibitWar: true,
|
||||
techLevel: 0,
|
||||
techLimited: false,
|
||||
topChiefs: {
|
||||
12: { id: 1, name: '군주', npcState: 0 },
|
||||
11: { id: 2, name: '참모', npcState: 1 },
|
||||
},
|
||||
impossibleStrategicCommands: [{ name: '수몰', remainingTurns: 2, availableYear: 190, availableMonth: 5 }],
|
||||
},
|
||||
settings: {},
|
||||
penalties: {},
|
||||
@@ -568,17 +586,17 @@ const persistArtifact = async (page: Page, name: string) => {
|
||||
nationPopup: describe('#mobile-nation-menu'),
|
||||
quickPopup: describe('#mobile-quick-menu'),
|
||||
commandMenu: describe('.reserved-command-editor details[open] .menu-items'),
|
||||
commandDividers: [...document.querySelectorAll<HTMLElement>('.reserved-command-editor details[open] .menu-divider')].map(
|
||||
(element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
||||
borderTop: style.borderTop,
|
||||
margin: style.margin,
|
||||
};
|
||||
}
|
||||
),
|
||||
commandDividers: [
|
||||
...document.querySelectorAll<HTMLElement>('.reserved-command-editor details[open] .menu-divider'),
|
||||
].map((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
||||
borderTop: style.borderTop,
|
||||
margin: style.margin,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
const commandMenu = page.locator('.reserved-command-editor details[open] .menu-items').first();
|
||||
@@ -867,6 +885,31 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn
|
||||
await expect(page.locator('[data-main-target="general"] [data-dex-progress]')).toHaveCount(0);
|
||||
await expect(page.locator('[data-main-target="general"] [role="progressbar"]')).toHaveCount(4);
|
||||
|
||||
const nationCard = page.locator('[data-main-target="nation"] [data-nation-basic-card]');
|
||||
await expect(nationCard.locator('.head')).toHaveCount(17);
|
||||
await expect(nationCard).toContainText('유가 (농상↑ 민심↑쌀수입↓)');
|
||||
await expect(nationCard).toContainText('영주군주참모ⓝ참모');
|
||||
await expect(nationCard).toContainText('총 주민150,000 / 620,500');
|
||||
await expect(nationCard).toContainText('총 병사500 / 7,000');
|
||||
await expect(nationCard).toContainText('지급률100%');
|
||||
await expect(nationCard).toContainText('전략2턴');
|
||||
await expect(nationCard).toContainText('임관허가');
|
||||
await expect(nationCard).toContainText('전쟁금지');
|
||||
expect(await nationCard.evaluate((element) => element.getBoundingClientRect().height)).toBe(193);
|
||||
const nationRowHeights = await nationCard
|
||||
.locator('.nation-grid')
|
||||
.evaluate((element) => [...element.children].map((child) => child.getBoundingClientRect().height));
|
||||
expect(Math.max(...nationRowHeights) - Math.min(...nationRowHeights)).toBeLessThanOrEqual(0.01);
|
||||
const strategicCell = nationCard.locator('.strategic');
|
||||
const strategicTooltip = strategicCell.getByRole('tooltip');
|
||||
await expect(strategicTooltip).toBeHidden();
|
||||
await strategicCell.hover();
|
||||
await expect(strategicTooltip).toBeVisible();
|
||||
await expect(strategicTooltip).toContainText('수몰: 2턴 뒤(190년 5월부터)');
|
||||
await strategicCell.focus();
|
||||
await expect(strategicCell).toBeFocused();
|
||||
await expect(strategicTooltip).toBeVisible();
|
||||
|
||||
expect(await cityBars.first().evaluate((element) => element.getBoundingClientRect().height)).toBe(9);
|
||||
expect(await statBars.first().evaluate((element) => element.getBoundingClientRect().height)).toBe(12);
|
||||
expect(await experienceBar.evaluate((element) => element.getBoundingClientRect().height)).toBe(12);
|
||||
@@ -968,8 +1011,9 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn
|
||||
await modeButton.focus();
|
||||
await expect(modeButton).toBeFocused();
|
||||
await modeButton.click();
|
||||
const advancedControlGeometry = await page.locator('[data-main-target="commands"] .reserved-command-editor').evaluate(
|
||||
(editor) => {
|
||||
const advancedControlGeometry = await page
|
||||
.locator('[data-main-target="commands"] .reserved-command-editor')
|
||||
.evaluate((editor) => {
|
||||
const range = editor.querySelector<HTMLElement>('.range-menu');
|
||||
const recent = [...editor.querySelectorAll<HTMLElement>('.control-pad summary')].find((element) =>
|
||||
element.textContent?.includes('최근 실행')
|
||||
@@ -984,8 +1028,7 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn
|
||||
advancedBottom: advanced.getBoundingClientRect().bottom,
|
||||
queueTop: queue.getBoundingClientRect().top,
|
||||
};
|
||||
}
|
||||
);
|
||||
});
|
||||
expect(advancedControlGeometry.rangeTop).toBe(advancedControlGeometry.recentTop);
|
||||
expect(advancedControlGeometry.advancedTop).toBeGreaterThan(advancedControlGeometry.rangeTop);
|
||||
expect(advancedControlGeometry.advancedBottom).toBeLessThanOrEqual(advancedControlGeometry.queueTop);
|
||||
@@ -1161,6 +1204,11 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(500);
|
||||
await expect(page.locator('[data-main-target="city"] [role="progressbar"]')).toHaveCount(8);
|
||||
await expect(page.locator('[data-main-target="general"] [role="progressbar"]')).toHaveCount(4);
|
||||
const mobileNationCard = page.locator('[data-main-target="nation"] [data-nation-basic-card]');
|
||||
expect(await mobileNationCard.evaluate((element) => element.getBoundingClientRect().height)).toBe(193);
|
||||
expect(await mobileNationCard.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(
|
||||
0
|
||||
);
|
||||
expect(
|
||||
await page
|
||||
.locator('[data-main-target="city"] [role="progressbar"]')
|
||||
@@ -1362,12 +1410,7 @@ test('real mobile devices initially fit the complete 500px game canvas', async (
|
||||
|
||||
const routeGeometry: Record<string, unknown> = {};
|
||||
if (deviceWidth === 390) {
|
||||
for (const target of [
|
||||
'chief-center',
|
||||
'battle-center',
|
||||
'inherit',
|
||||
'nation-betting',
|
||||
]) {
|
||||
for (const target of ['chief-center', 'battle-center', 'inherit', 'nation-betting']) {
|
||||
await mobilePage.goto(target);
|
||||
await expect
|
||||
.poll(() => mobilePage.locator('#app').evaluate((element) => getComputedStyle(element).minWidth))
|
||||
@@ -1721,10 +1764,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
||||
{ op: 'replace', path: '/general/0/values/1/possible', value: false },
|
||||
{ op: 'replace', path: '/general/0/values/1/status', value: 'blocked' },
|
||||
];
|
||||
await emitReadModelInvalidation(
|
||||
page,
|
||||
readModelInvalidation({ context: true, commands: true, boardAccess: true })
|
||||
);
|
||||
await emitReadModelInvalidation(page, readModelInvalidation({ context: true, commands: true, boardAccess: true }));
|
||||
await expect.poll(() => state.generalMeCalls, { timeout: 4_000 }).toBe(callsBeforeTax + 1);
|
||||
|
||||
const callsBeforeCityState = state.generalMeCalls;
|
||||
@@ -1751,10 +1791,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
||||
state.contextRevision = 'O'.repeat(22);
|
||||
state.contextOperations = [{ op: 'replace', path: '/missing/value', value: 'invalid-delta' }];
|
||||
state.commandTableOperations = [];
|
||||
await emitReadModelInvalidation(
|
||||
page,
|
||||
readModelInvalidation({ context: true, commands: true, boardAccess: true })
|
||||
);
|
||||
await emitReadModelInvalidation(page, readModelInvalidation({ context: true, commands: true, boardAccess: true }));
|
||||
await expect.poll(() => state.generalMeCalls, { timeout: 4_000 }).toBe(callsBeforeFallback + 2);
|
||||
expect(state.forceSnapshotCalls).toBe(forcedBeforeFallback + 1);
|
||||
await expect(page.locator('.general-title')).toContainText('snapshot복구장수');
|
||||
|
||||
@@ -1,86 +1,296 @@
|
||||
<script setup lang="ts">
|
||||
import SkeletonLines from '../ui/SkeletonLines.vue';
|
||||
import { legacyNationTextColor } from '../../utils/legacyNationColor';
|
||||
import { formatOfficerLevelText } from '../../utils/nationFormat';
|
||||
import { getNpcColor } from '../../utils/npcColor';
|
||||
|
||||
interface NationChief {
|
||||
id: number;
|
||||
name: string;
|
||||
npcState: number;
|
||||
}
|
||||
|
||||
interface NationInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
level: number;
|
||||
levelName: string;
|
||||
gold: number;
|
||||
rice: number;
|
||||
tech: number;
|
||||
typeCode: string;
|
||||
typeName: string;
|
||||
capitalCityId: number | null;
|
||||
capitalCityName: string | null;
|
||||
typePros: string;
|
||||
typeCons: string;
|
||||
population: { cityCount: number; current: number; max: number };
|
||||
crew: { generalCount: number; current: number; max: number };
|
||||
power: number;
|
||||
bill: number;
|
||||
taxRate: number;
|
||||
strategicCommandLimit: number;
|
||||
diplomaticLimit: number;
|
||||
prohibitScout: boolean;
|
||||
prohibitWar: boolean;
|
||||
techLevel: number;
|
||||
techLimited: boolean;
|
||||
topChiefs: Record<number, NationChief | undefined>;
|
||||
impossibleStrategicCommands: Array<{
|
||||
name: string;
|
||||
remainingTurns: number;
|
||||
availableYear: number;
|
||||
availableMonth: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
nation: NationInfo | null;
|
||||
loading: boolean;
|
||||
}>();
|
||||
|
||||
const number = (value: number): string => value.toLocaleString('ko-KR');
|
||||
const displayChiefName = (chief: NationChief | undefined): string => {
|
||||
if (!chief) return '-';
|
||||
return chief.npcState > 0 && !/^[ⓜⓝ㉥]/u.test(chief.name) ? `ⓝ${chief.name}` : chief.name;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="nation-card">
|
||||
<div v-if="props.loading">
|
||||
<SkeletonLines :lines="4" />
|
||||
</div>
|
||||
<div class="nation-card" data-nation-basic-card>
|
||||
<div v-if="props.loading" class="loading"><SkeletonLines :lines="6" /></div>
|
||||
<div v-else-if="!props.nation" class="empty">국가 정보를 불러오지 못했습니다.</div>
|
||||
<div v-else class="nation-body">
|
||||
<div v-else class="nation-grid">
|
||||
<div
|
||||
class="title"
|
||||
:style="{ backgroundColor: props.nation.color, color: legacyNationTextColor(props.nation.color) }"
|
||||
>
|
||||
{{ props.nation.name }}<template v-if="props.nation.id > 0"> ({{ props.nation.levelName }})</template>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<span>국고</span
|
||||
><strong>{{ props.nation.id === 0 ? '해당 없음' : props.nation.gold.toLocaleString() }}</strong>
|
||||
<span>국량</span
|
||||
><strong>{{ props.nation.id === 0 ? '해당 없음' : props.nation.rice.toLocaleString() }}</strong>
|
||||
<span>기술</span
|
||||
><strong>{{ props.nation.id === 0 ? '해당 없음' : props.nation.tech.toLocaleString() }}</strong>
|
||||
<span>체제</span><strong>{{ props.nation.id === 0 ? '해당 없음' : props.nation.typeName }}</strong>
|
||||
<span>수도</span
|
||||
><strong>{{ props.nation.id === 0 ? '해당 없음' : (props.nation.capitalCityName ?? '-') }}</strong>
|
||||
<span>국가 등급</span
|
||||
><strong>{{ props.nation.id === 0 ? '해당 없음' : props.nation.levelName }}</strong>
|
||||
{{ props.nation.name }}
|
||||
</div>
|
||||
|
||||
<span class="head">성향</span>
|
||||
<strong class="body type-body">
|
||||
{{ props.nation.typeName }} (<span class="pros">{{ props.nation.typePros }}</span>
|
||||
<span class="cons">{{ props.nation.typeCons }}</span
|
||||
>)
|
||||
</strong>
|
||||
|
||||
<span class="head">{{ formatOfficerLevelText(12, props.nation.level) }}</span>
|
||||
<strong class="body" :style="{ color: getNpcColor(props.nation.topChiefs[12]?.npcState ?? 1) }">
|
||||
{{ displayChiefName(props.nation.topChiefs[12]) }}
|
||||
</strong>
|
||||
<span class="head">{{ formatOfficerLevelText(11, props.nation.level) }}</span>
|
||||
<strong class="body" :style="{ color: getNpcColor(props.nation.topChiefs[11]?.npcState ?? 1) }">
|
||||
{{ displayChiefName(props.nation.topChiefs[11]) }}
|
||||
</strong>
|
||||
|
||||
<span class="head">총 주민</span>
|
||||
<strong class="body">{{
|
||||
props.nation.id === 0
|
||||
? '해당 없음'
|
||||
: `${number(props.nation.population.current)} / ${number(props.nation.population.max)}`
|
||||
}}</strong>
|
||||
<span class="head">총 병사</span>
|
||||
<strong class="body">{{
|
||||
props.nation.id === 0
|
||||
? '해당 없음'
|
||||
: `${number(props.nation.crew.current)} / ${number(props.nation.crew.max)}`
|
||||
}}</strong>
|
||||
|
||||
<span class="head">국고</span>
|
||||
<strong class="body">{{ props.nation.id === 0 ? '해당 없음' : number(props.nation.gold) }}</strong>
|
||||
<span class="head">병량</span>
|
||||
<strong class="body">{{ props.nation.id === 0 ? '해당 없음' : number(props.nation.rice) }}</strong>
|
||||
|
||||
<span class="head">지급률</span>
|
||||
<strong class="body">{{ props.nation.id === 0 ? '해당 없음' : `${props.nation.bill}%` }}</strong>
|
||||
<span class="head">세율</span>
|
||||
<strong class="body">{{ props.nation.id === 0 ? '해당 없음' : `${props.nation.taxRate}%` }}</strong>
|
||||
|
||||
<span class="head">속령</span>
|
||||
<strong class="body">{{
|
||||
props.nation.id === 0 ? '해당 없음' : number(props.nation.population.cityCount)
|
||||
}}</strong>
|
||||
<span class="head">장수</span>
|
||||
<strong class="body">{{
|
||||
props.nation.id === 0 ? '해당 없음' : number(props.nation.crew.generalCount)
|
||||
}}</strong>
|
||||
|
||||
<span class="head">국력</span>
|
||||
<strong class="body">{{ props.nation.id === 0 ? '해당 없음' : number(props.nation.power) }}</strong>
|
||||
<span class="head">기술력</span>
|
||||
<strong class="body">
|
||||
<template v-if="props.nation.id === 0">해당 없음</template>
|
||||
<template v-else>
|
||||
{{ props.nation.techLevel }}등급 /
|
||||
<span :class="props.nation.techLimited ? 'tech-limited' : 'available'">{{
|
||||
number(Math.floor(props.nation.tech))
|
||||
}}</span>
|
||||
</template>
|
||||
</strong>
|
||||
|
||||
<span class="head">전략</span>
|
||||
<strong
|
||||
class="body strategic"
|
||||
:class="{ 'has-tooltip': props.nation.impossibleStrategicCommands.length > 0 }"
|
||||
:tabindex="props.nation.impossibleStrategicCommands.length > 0 ? 0 : undefined"
|
||||
>
|
||||
<template v-if="props.nation.id === 0">해당 없음</template>
|
||||
<span v-else-if="props.nation.strategicCommandLimit" class="blocked"
|
||||
>{{ number(props.nation.strategicCommandLimit) }}턴</span
|
||||
>
|
||||
<span v-else :class="props.nation.impossibleStrategicCommands.length > 0 ? 'warning' : 'available'"
|
||||
>가능</span
|
||||
>
|
||||
<span
|
||||
v-if="props.nation.impossibleStrategicCommands.length > 0"
|
||||
class="cooldown-tooltip"
|
||||
role="tooltip"
|
||||
>
|
||||
<span v-for="command in props.nation.impossibleStrategicCommands" :key="command.name">
|
||||
{{ command.name }}: {{ number(command.remainingTurns) }}턴 뒤({{ command.availableYear }}년
|
||||
{{ command.availableMonth }}월부터)
|
||||
</span>
|
||||
</span>
|
||||
</strong>
|
||||
<span class="head">외교</span>
|
||||
<strong class="body">
|
||||
<template v-if="props.nation.id === 0">해당 없음</template>
|
||||
<span v-else-if="props.nation.diplomaticLimit" class="blocked"
|
||||
>{{ number(props.nation.diplomaticLimit) }}턴</span
|
||||
>
|
||||
<span v-else class="available">가능</span>
|
||||
</strong>
|
||||
|
||||
<span class="head">임관</span>
|
||||
<strong class="body">
|
||||
<template v-if="props.nation.id === 0">해당 없음</template>
|
||||
<span v-else :class="props.nation.prohibitScout ? 'blocked' : 'available'">{{
|
||||
props.nation.prohibitScout ? '금지' : '허가'
|
||||
}}</span>
|
||||
</strong>
|
||||
<span class="head">전쟁</span>
|
||||
<strong class="body">
|
||||
<template v-if="props.nation.id === 0">해당 없음</template>
|
||||
<span v-else :class="props.nation.prohibitWar ? 'blocked' : 'available'">{{
|
||||
props.nation.prohibitWar ? '금지' : '허가'
|
||||
}}</span>
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.title {
|
||||
min-height: 24px;
|
||||
padding: 2px 6px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
.nation-card {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 193px;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
}
|
||||
.grid > * {
|
||||
min-height: 23px;
|
||||
|
||||
.nation-grid {
|
||||
display: grid;
|
||||
box-sizing: border-box;
|
||||
border-right: 1px solid #666;
|
||||
border-bottom: 1px solid #666;
|
||||
padding: 2px 5px;
|
||||
width: 100%;
|
||||
height: 193px;
|
||||
grid-template-columns: 14% 36% 14% 36%;
|
||||
grid-template-rows: repeat(10, calc(192px / 10));
|
||||
border-right: 1px solid gray;
|
||||
border-bottom: 1px solid gray;
|
||||
background-color: #172a52;
|
||||
background-image: var(--sammo-texture-blue);
|
||||
}
|
||||
.grid > span {
|
||||
background: rgb(20 75 42 / 70%);
|
||||
|
||||
.nation-grid > * {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
border-top: 1px solid gray;
|
||||
border-left: 1px solid gray;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
line-height: calc(193px / 10);
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.grid > strong {
|
||||
text-align: right;
|
||||
|
||||
.title {
|
||||
grid-column: 1 / 5;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.head {
|
||||
background-color: rgb(20 75 42 / 70%);
|
||||
}
|
||||
|
||||
.body {
|
||||
position: relative;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.type-body {
|
||||
grid-column: 2 / 5;
|
||||
}
|
||||
|
||||
.pros {
|
||||
color: cyan;
|
||||
}
|
||||
|
||||
.cons,
|
||||
.tech-limited {
|
||||
color: magenta;
|
||||
}
|
||||
|
||||
.blocked {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.available {
|
||||
color: limegreen;
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: yellow;
|
||||
}
|
||||
|
||||
.strategic.has-tooltip {
|
||||
overflow: visible;
|
||||
text-decoration: underline dashed red;
|
||||
}
|
||||
|
||||
.cooldown-tooltip {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
bottom: calc(100% + 3px);
|
||||
left: 50%;
|
||||
display: none;
|
||||
width: max-content;
|
||||
max-width: 280px;
|
||||
transform: translateX(-50%);
|
||||
border: 1px solid #888;
|
||||
padding: 4px 7px;
|
||||
background: #111;
|
||||
color: #fff;
|
||||
line-height: 1.35;
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.cooldown-tooltip > span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.has-tooltip:hover .cooldown-tooltip,
|
||||
.has-tooltip:focus .cooldown-tooltip {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.empty {
|
||||
box-sizing: border-box;
|
||||
min-height: 193px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: rgba(232, 221, 196, 0.6);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user