feat(game-ui): port Ref progress bars
This commit is contained in:
@@ -249,7 +249,7 @@ export const generalRouter = router({
|
||||
return null;
|
||||
}
|
||||
|
||||
const [city, nation] = await Promise.all([
|
||||
const [city, nation, worldState] = await Promise.all([
|
||||
general.cityId > 0
|
||||
? ctx.db.city.findUnique({
|
||||
where: { id: general.cityId },
|
||||
@@ -259,11 +259,20 @@ export const generalRouter = router({
|
||||
level: true,
|
||||
nationId: true,
|
||||
population: true,
|
||||
populationMax: true,
|
||||
agriculture: true,
|
||||
agricultureMax: true,
|
||||
commerce: true,
|
||||
commerceMax: true,
|
||||
security: true,
|
||||
securityMax: true,
|
||||
trust: true,
|
||||
trade: true,
|
||||
defence: true,
|
||||
defenceMax: true,
|
||||
wall: true,
|
||||
wallMax: true,
|
||||
region: true,
|
||||
supplyState: true,
|
||||
frontState: true,
|
||||
},
|
||||
@@ -285,9 +294,12 @@ export const generalRouter = router({
|
||||
},
|
||||
})
|
||||
: null,
|
||||
ctx.db.worldState.findFirst({ select: { config: true } }),
|
||||
]);
|
||||
|
||||
const metaRecord = asRecord(general.meta);
|
||||
const worldConfig = asRecord(worldState?.config);
|
||||
const constValues = asRecord(worldConfig.const ?? worldConfig.consts);
|
||||
const settings = resolveUserSettings(metaRecord);
|
||||
const penalties = resolvePenalty(general.penalty);
|
||||
|
||||
@@ -326,6 +338,12 @@ export const generalRouter = router({
|
||||
progression: {
|
||||
experienceLevel: readNumber(metaRecord.explevel, 0),
|
||||
dedicationLevel: readNumber(metaRecord.dedlevel, 0),
|
||||
statExperience: {
|
||||
leadership: readNumber(metaRecord.leadership_exp, 0),
|
||||
strength: readNumber(metaRecord.strength_exp, 0),
|
||||
intelligence: readNumber(metaRecord.intel_exp, 0),
|
||||
},
|
||||
statUpgradeLimit: readNumber(constValues.upgradeLimit, 30),
|
||||
dex: [1, 2, 3, 4, 5].map((index) => readNumber(metaRecord[`dex${index}`], 0)),
|
||||
},
|
||||
items: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { LogCategory } from '@sammo-ts/logic';
|
||||
|
||||
import { accessAuthedProcedure } from '../../../trpc.js';
|
||||
@@ -91,6 +92,13 @@ export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const worldConfig = asRecord(worldState.config);
|
||||
const constValues = asRecord(worldConfig.const ?? worldConfig.consts);
|
||||
const statUpgradeLimit =
|
||||
typeof constValues.upgradeLimit === 'number' && Number.isFinite(constValues.upgradeLimit)
|
||||
? constValues.upgradeLimit
|
||||
: 30;
|
||||
|
||||
const generals = generalRows.map((general) => {
|
||||
const meta =
|
||||
general.meta && typeof general.meta === 'object' && !Array.isArray(general.meta)
|
||||
@@ -137,6 +145,16 @@ export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => {
|
||||
specialDomestic: general.specialCode,
|
||||
specialWar: general.special2Code,
|
||||
},
|
||||
progression: {
|
||||
experienceLevel: metaNumber('explevel'),
|
||||
statExperience: {
|
||||
leadership: metaNumber('leadership_exp'),
|
||||
strength: metaNumber('strength_exp'),
|
||||
intelligence: metaNumber('intel_exp'),
|
||||
},
|
||||
statUpgradeLimit,
|
||||
dex: [1, 2, 3, 4, 5].map((index) => metaNumber(`dex${index}`)),
|
||||
},
|
||||
battleStats: {
|
||||
kills: metaNumber('rank_killnum') || metaNumber('killnum'),
|
||||
deaths: metaNumber('deathnum'),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { TurnDaemonCommandResult } from '@sammo-ts/common';
|
||||
import { asRecord, type TurnDaemonCommandResult } from '@sammo-ts/common';
|
||||
import { isValidTroopNameWidth, normalizeTroopName, resolveTroopSecretPermission } from '@sammo-ts/logic';
|
||||
|
||||
import { accessAuthedProcedure, authedProcedure, router } from '../../trpc.js';
|
||||
@@ -39,7 +39,7 @@ export const troopRouter = router({
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '국가에 소속되어 있지 않습니다.' });
|
||||
}
|
||||
|
||||
const [nation, troops, generals, cities] = await Promise.all([
|
||||
const [nation, troops, generals, cities, worldState] = await Promise.all([
|
||||
ctx.db.nation.findUnique({
|
||||
where: { id: me.nationId },
|
||||
select: { id: true, name: true, meta: true },
|
||||
@@ -58,11 +58,17 @@ export const troopRouter = router({
|
||||
picture: true,
|
||||
imageServer: true,
|
||||
turnTime: true,
|
||||
leadership: true,
|
||||
strength: true,
|
||||
intel: true,
|
||||
experience: true,
|
||||
meta: true,
|
||||
},
|
||||
}),
|
||||
ctx.db.city.findMany({
|
||||
select: { id: true, name: true },
|
||||
}),
|
||||
ctx.db.worldState.findFirst({ select: { config: true } }),
|
||||
]);
|
||||
if (!nation) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '국가 정보를 찾을 수 없습니다.' });
|
||||
@@ -80,6 +86,12 @@ export const troopRouter = router({
|
||||
const cityNames = new Map(cities.map((city) => [city.id, city.name]));
|
||||
const generalMap = new Map(generals.map((general) => [general.id, general]));
|
||||
const reservedByLeader = new Map<number, string[]>();
|
||||
const worldConfig = asRecord(worldState?.config);
|
||||
const constValues = asRecord(worldConfig.const ?? worldConfig.consts);
|
||||
const statUpgradeLimit =
|
||||
typeof constValues.upgradeLimit === 'number' && Number.isFinite(constValues.upgradeLimit)
|
||||
? constValues.upgradeLimit
|
||||
: 30;
|
||||
for (const turn of turns) {
|
||||
const list = reservedByLeader.get(turn.generalId) ?? [];
|
||||
list.push(turn.actionCode);
|
||||
@@ -107,12 +119,35 @@ export const troopRouter = router({
|
||||
: null,
|
||||
members: generals
|
||||
.filter((general) => general.troopId === troop.troopLeaderId)
|
||||
.map((general) => ({
|
||||
id: general.id,
|
||||
name: general.name,
|
||||
cityId: general.cityId,
|
||||
cityName: cityNames.get(general.cityId) ?? '알 수 없음',
|
||||
})),
|
||||
.map((general) => {
|
||||
const meta = asRecord(general.meta);
|
||||
const metaNumber = (key: string): number => {
|
||||
const value = meta[key];
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
||||
};
|
||||
return {
|
||||
id: general.id,
|
||||
name: general.name,
|
||||
cityId: general.cityId,
|
||||
cityName: cityNames.get(general.cityId) ?? '알 수 없음',
|
||||
stats: {
|
||||
leadership: general.leadership,
|
||||
strength: general.strength,
|
||||
intelligence: general.intel,
|
||||
},
|
||||
experience: general.experience,
|
||||
progression: {
|
||||
experienceLevel: metaNumber('explevel'),
|
||||
statExperience: {
|
||||
leadership: metaNumber('leadership_exp'),
|
||||
strength: metaNumber('strength_exp'),
|
||||
intelligence: metaNumber('intel_exp'),
|
||||
},
|
||||
statUpgradeLimit,
|
||||
dex: [1, 2, 3, 4, 5].map((index) => metaNumber(`dex${index}`)),
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
})
|
||||
.sort((left, right) => {
|
||||
|
||||
@@ -75,6 +75,7 @@ const auth: GameSessionTokenPayload = {
|
||||
|
||||
const createContext = (options: {
|
||||
me?: GeneralRow | null;
|
||||
city?: Record<string, unknown> | null;
|
||||
targets?: GeneralRow[];
|
||||
nationMeta?: Record<string, unknown>;
|
||||
requestCommand?: ReturnType<typeof vi.fn>;
|
||||
@@ -95,7 +96,7 @@ const createContext = (options: {
|
||||
findMany: vi.fn(async () => targets.filter((general) => general.nationId === (me?.nationId ?? 0))),
|
||||
update: vi.fn(),
|
||||
},
|
||||
city: { findUnique: vi.fn(async () => null) },
|
||||
city: { findUnique: vi.fn(async () => options.city ?? null) },
|
||||
nation: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
id: 1,
|
||||
@@ -115,6 +116,7 @@ const createContext = (options: {
|
||||
currentYear: 185,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
config: { const: { upgradeLimit: 20 } },
|
||||
})),
|
||||
},
|
||||
logEntry: {
|
||||
@@ -162,6 +164,90 @@ const createContext = (options: {
|
||||
};
|
||||
|
||||
describe('in-game my information ownership', () => {
|
||||
it('returns every ref progress-bar input from the owned general and current city read model', async () => {
|
||||
const fixture = createContext({
|
||||
me: buildGeneral({
|
||||
meta: {
|
||||
explevel: 4,
|
||||
dedlevel: 3,
|
||||
leadership_exp: 7,
|
||||
strength_exp: 8,
|
||||
intel_exp: 9,
|
||||
dex1: 350,
|
||||
dex2: 1_375,
|
||||
dex3: 3_500,
|
||||
dex4: 7_125,
|
||||
dex5: 12_650,
|
||||
},
|
||||
}),
|
||||
city: {
|
||||
id: 1,
|
||||
name: '계',
|
||||
level: 5,
|
||||
nationId: 1,
|
||||
population: 322_886,
|
||||
populationMax: 388_500,
|
||||
agriculture: 6_911,
|
||||
agricultureMax: 7_500,
|
||||
commerce: 7_451,
|
||||
commerceMax: 8_000,
|
||||
security: 5_792,
|
||||
securityMax: 6_000,
|
||||
trust: 72,
|
||||
trade: 101,
|
||||
defence: 7_529,
|
||||
defenceMax: 7_800,
|
||||
wall: 7_819,
|
||||
wallMax: 8_100,
|
||||
region: 1,
|
||||
supplyState: 1,
|
||||
frontState: 0,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(appRouter.createCaller(fixture.context).general.me()).resolves.toMatchObject({
|
||||
general: {
|
||||
progression: {
|
||||
experienceLevel: 4,
|
||||
dedicationLevel: 3,
|
||||
statExperience: { leadership: 7, strength: 8, intelligence: 9 },
|
||||
statUpgradeLimit: 20,
|
||||
dex: [350, 1_375, 3_500, 7_125, 12_650],
|
||||
},
|
||||
},
|
||||
city: {
|
||||
population: 322_886,
|
||||
populationMax: 388_500,
|
||||
agriculture: 6_911,
|
||||
agricultureMax: 7_500,
|
||||
commerce: 7_451,
|
||||
commerceMax: 8_000,
|
||||
security: 5_792,
|
||||
securityMax: 6_000,
|
||||
trust: 72,
|
||||
trade: 101,
|
||||
defence: 7_529,
|
||||
defenceMax: 7_800,
|
||||
wall: 7_819,
|
||||
wallMax: 8_100,
|
||||
},
|
||||
});
|
||||
expect(fixture.db.city.findUnique).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
select: expect.objectContaining({
|
||||
populationMax: true,
|
||||
agricultureMax: true,
|
||||
commerceMax: true,
|
||||
securityMax: true,
|
||||
trust: true,
|
||||
trade: true,
|
||||
defenceMax: true,
|
||||
wallMax: true,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('reads legacy top-level settings and dispatches only the session-owned general', async () => {
|
||||
const requestCommand = vi.fn(async () => ({ type: 'setMySetting', ok: true, generalId: 7 }));
|
||||
const fixture = createContext({ requestCommand });
|
||||
@@ -422,6 +508,12 @@ describe('battle-center general and user permissions', () => {
|
||||
id: 7,
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
progression: {
|
||||
experienceLevel: 0,
|
||||
statExperience: { leadership: 0, strength: 0, intelligence: 0 },
|
||||
statUpgradeLimit: 20,
|
||||
dex: [0, 0, 0, 0, 0],
|
||||
},
|
||||
battleStats: { kills: 0, deaths: 0, fire: 0, killCrew: 0, deathCrew: 0, dex: [0, 0, 0, 0, 0] },
|
||||
},
|
||||
],
|
||||
|
||||
@@ -90,17 +90,24 @@ const buildContext = (options: {
|
||||
}
|
||||
return options.target?.id === where.id ? options.target : null;
|
||||
}),
|
||||
findMany: vi.fn(async () => [me, ...(options.target ? [options.target] : [])]),
|
||||
},
|
||||
nation: {
|
||||
findUnique: vi.fn(async ({ where }: { where: { id: number } }) =>
|
||||
where.id === me.nationId ? { id: me.nationId, meta: options.nationMeta ?? {} } : null
|
||||
where.id === me.nationId ? { id: me.nationId, name: '테스트국', meta: options.nationMeta ?? {} } : null
|
||||
),
|
||||
},
|
||||
troop: {
|
||||
findUnique: vi.fn(async ({ where }: { where: { troopLeaderId: number } }) =>
|
||||
options.troop?.troopLeaderId === where.troopLeaderId ? options.troop : null
|
||||
),
|
||||
findMany: vi.fn(async () =>
|
||||
options.troop ? [options.troop] : [{ troopLeaderId: me.id, nationId: me.nationId, name: '백마대' }]
|
||||
),
|
||||
},
|
||||
city: { findMany: vi.fn(async () => [{ id: 1, name: '북평' }]) },
|
||||
worldState: { findFirst: vi.fn(async () => ({ config: { const: { upgradeLimit: 20 } } })) },
|
||||
generalTurn: { findMany: vi.fn(async () => []) },
|
||||
};
|
||||
const accessTokenStore = new RedisAccessTokenStore(
|
||||
{
|
||||
@@ -127,6 +134,43 @@ const buildContext = (options: {
|
||||
};
|
||||
|
||||
describe('troop router permissions and mutations', () => {
|
||||
it('returns the Ref general progress inputs for same-nation troop popups', async () => {
|
||||
const me = buildGeneral({
|
||||
troopId: 1,
|
||||
meta: {
|
||||
explevel: 4,
|
||||
leadership_exp: 7,
|
||||
strength_exp: 8,
|
||||
intel_exp: 9,
|
||||
dex1: 350,
|
||||
dex2: 1_375,
|
||||
dex3: 3_500,
|
||||
dex4: 7_125,
|
||||
dex5: 12_650,
|
||||
},
|
||||
});
|
||||
const fixture = buildContext({ me, result: null });
|
||||
|
||||
await expect(appRouter.createCaller(fixture.context).troop.getList()).resolves.toMatchObject({
|
||||
troops: [
|
||||
{
|
||||
members: [
|
||||
{
|
||||
stats: { leadership: 50, strength: 50, intelligence: 50 },
|
||||
experience: 0,
|
||||
progression: {
|
||||
experienceLevel: 4,
|
||||
statExperience: { leadership: 7, strength: 8, intelligence: 9 },
|
||||
statUpgradeLimit: 20,
|
||||
dex: [350, 1_375, 3_500, 7_125, 12_650],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a troop only for the general owned by the authenticated user', async () => {
|
||||
const { context, requestCommand } = buildContext({
|
||||
result: { type: 'troopCreate', ok: true, generalId: 1, troopId: 1, troopName: '백마대' },
|
||||
|
||||
@@ -72,6 +72,17 @@ const myGeneral = (state: FixtureState) => ({
|
||||
injury: 0,
|
||||
experience: 100,
|
||||
dedication: 200,
|
||||
age: 30,
|
||||
turnTime: '2026-01-01 00:10:00',
|
||||
crewTypeId: 1,
|
||||
traits: { personal: 'None', specialDomestic: 'None', specialWar: 'None' },
|
||||
progression: {
|
||||
experienceLevel: 1,
|
||||
dedicationLevel: 2,
|
||||
statExperience: { leadership: 7, strength: 8, intelligence: 9 },
|
||||
statUpgradeLimit: 20,
|
||||
dex: [350, 1_375, 3_500, 7_125, 1_275_975],
|
||||
},
|
||||
items: { horse: 'che_명마', weapon: null, book: null, item: null },
|
||||
},
|
||||
city: { id: 1, name: '업', level: 8, nationId: 1 },
|
||||
@@ -118,6 +129,17 @@ const battleCenter = (state: FixtureState) => ({
|
||||
crew: 300,
|
||||
train: 80,
|
||||
atmos: 90,
|
||||
age: 30,
|
||||
crewTypeId: 1,
|
||||
equipment: { weapon: 'None', book: 'None', horse: 'None', item: 'None' },
|
||||
traits: { personal: 'None', specialDomestic: 'None', specialWar: 'None' },
|
||||
progression: {
|
||||
experienceLevel: 1,
|
||||
statExperience: { leadership: 7, strength: 8, intelligence: 9 },
|
||||
statUpgradeLimit: 20,
|
||||
dex: [350, 1_375, 3_500, 7_125, 1_275_975],
|
||||
},
|
||||
battleStats: { kills: 1, deaths: 2, fire: 0, killCrew: 300, deathCrew: 100, dex: [] },
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
@@ -137,6 +159,17 @@ const battleCenter = (state: FixtureState) => ({
|
||||
crew: 100,
|
||||
train: 60,
|
||||
atmos: 60,
|
||||
age: 20,
|
||||
crewTypeId: 1,
|
||||
equipment: { weapon: 'None', book: 'None', horse: 'None', item: 'None' },
|
||||
traits: { personal: 'None', specialDomestic: 'None', specialWar: 'None' },
|
||||
progression: {
|
||||
experienceLevel: 0,
|
||||
statExperience: { leadership: 0, strength: 0, intelligence: 0 },
|
||||
statUpgradeLimit: 20,
|
||||
dex: [0, 0, 0, 0, 0],
|
||||
},
|
||||
battleStats: { kills: 0, deaths: 0, fire: 0, killCrew: 0, deathCrew: 0, dex: [] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -153,10 +186,15 @@ const install = async (page: Page, state: FixtureState) => {
|
||||
);
|
||||
await page.route('**/image/game/**', async (route) => {
|
||||
const filename = basename(new URL(route.request().url()).pathname);
|
||||
if (legacyImageRoot && ['back_walnut.jpg', 'back_green.jpg', 'back_blue.jpg'].includes(filename)) {
|
||||
if (
|
||||
legacyImageRoot &&
|
||||
['back_walnut.jpg', 'back_green.jpg', 'back_blue.jpg', 'pr5.gif', 'pb5.gif', 'pr8.gif', 'pb8.gif'].includes(
|
||||
filename
|
||||
)
|
||||
) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'image/jpeg',
|
||||
contentType: filename.endsWith('.gif') ? 'image/gif' : 'image/jpeg',
|
||||
body: await readFile(resolve(legacyImageRoot, filename)),
|
||||
});
|
||||
return;
|
||||
@@ -455,6 +493,8 @@ test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in plac
|
||||
await page.goto('my-page');
|
||||
await expect(page.locator('.title-row')).toContainText('내 정 보');
|
||||
await expect(page.locator('#set_my_setting')).toBeVisible();
|
||||
await expect(page.locator('.general-column [role="progressbar"]')).toHaveCount(14);
|
||||
await expect(page.locator('.general-column [aria-label*="1,275,975 (EX+)"]')).toHaveCount(5);
|
||||
await expect.poll(() => state.generalMeQueries).toBeGreaterThan(0);
|
||||
expect(state.accessPages).not.toContain('my-page');
|
||||
const noDefenceOption = page.locator('option[value="999"]');
|
||||
@@ -911,6 +951,14 @@ test('감찰부 keeps the selector interaction and shows the permission error pa
|
||||
await expect(page.locator('.selector-row select').nth(1)).toHaveValue('8');
|
||||
await page.getByRole('button', { name: '다음 ▶' }).click();
|
||||
await expect(page.locator('.selector-row select').nth(1)).toHaveValue('7');
|
||||
await expect(page.locator('.battle-general-card [role="progressbar"]')).toHaveCount(14);
|
||||
await expect(page.locator('.battle-general-card [aria-label*="1,275,975 (EX+)"]')).toHaveCount(5);
|
||||
expect(
|
||||
await page
|
||||
.locator('.battle-general-card [role="progressbar"]')
|
||||
.first()
|
||||
.evaluate((bar) => getComputedStyle(bar).backgroundImage)
|
||||
).toContain('/game/pr8.gif');
|
||||
const geometry = await page.locator('.battle-page').evaluate((element) => {
|
||||
const selector = element.querySelector<HTMLElement>('.selector-row')!;
|
||||
const controls = [...selector.children].map((child) => (child as HTMLElement).getBoundingClientRect());
|
||||
|
||||
@@ -54,6 +54,13 @@ const generalContext = (state: NavigationFixture) => ({
|
||||
injury: 0,
|
||||
experience: 100,
|
||||
dedication: 200,
|
||||
progression: {
|
||||
experienceLevel: 1,
|
||||
dedicationLevel: 2,
|
||||
statExperience: { leadership: 5, strength: 10, intelligence: 15 },
|
||||
statUpgradeLimit: 20,
|
||||
dex: [350, 100_000, 500_000, 1_000_000, 1_275_975],
|
||||
},
|
||||
items: { horse: null, weapon: null, book: null, item: null },
|
||||
},
|
||||
city: {
|
||||
@@ -385,6 +392,94 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
|
||||
await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`);
|
||||
});
|
||||
|
||||
test('main city and general cards render every ref bar plus dual dexterity progress at ref heights', async ({
|
||||
page,
|
||||
}) => {
|
||||
const state: NavigationFixture = {
|
||||
officerLevel: 1,
|
||||
permission: 0,
|
||||
nationLevel: 1,
|
||||
stage: 0,
|
||||
npcMode: 1,
|
||||
generalMeCalls: 0,
|
||||
operations: [],
|
||||
};
|
||||
await installFixture(page, state);
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
await waitForMain(page);
|
||||
|
||||
const cityBars = page.locator('[data-main-target="city"] [role="progressbar"]');
|
||||
const statBars = page.locator('[data-stat-progress] [role="progressbar"]');
|
||||
const experienceBar = page.locator('[data-experience-progress] [role="progressbar"]');
|
||||
const dexRows = page.locator('[data-dex-progress]');
|
||||
await expect(cityBars).toHaveCount(8);
|
||||
await expect(statBars).toHaveCount(3);
|
||||
await expect(experienceBar).toHaveCount(1);
|
||||
await expect(dexRows).toHaveCount(5);
|
||||
await expect(dexRows.locator('[role="progressbar"]')).toHaveCount(10);
|
||||
|
||||
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);
|
||||
const firstDexBars = dexRows.first().locator('[role="progressbar"]');
|
||||
expect(await firstDexBars.nth(0).evaluate((element) => element.getBoundingClientRect().height)).toBe(12);
|
||||
expect(await firstDexBars.nth(1).evaluate((element) => element.getBoundingClientRect().height)).toBe(9);
|
||||
|
||||
await expect(dexRows.nth(3).locator('[role="progressbar"]').nth(0)).toHaveAttribute('aria-valuemax', '100');
|
||||
await expect(dexRows.nth(3).locator('[role="progressbar"]').nth(0)).toHaveAttribute(
|
||||
'aria-label',
|
||||
/1,000,000 \/ 1,275,975 \(EX\+\)/
|
||||
);
|
||||
await expect(dexRows.nth(3).locator('[role="progressbar"]').nth(1)).toHaveAttribute('aria-label', /까지 .* 남음/);
|
||||
await expect(dexRows.nth(4).locator('[role="progressbar"]').nth(1)).toHaveAttribute('aria-label', /EX\+ 달성/);
|
||||
|
||||
const texture = await cityBars.first().evaluate((element) => getComputedStyle(element).backgroundImage);
|
||||
const fillTexture = await cityBars
|
||||
.first()
|
||||
.locator('.legacy-progress__fill')
|
||||
.evaluate((element) => getComputedStyle(element).backgroundImage);
|
||||
expect(texture).toContain('/game/pr5.gif');
|
||||
expect(fillTexture).toContain('/game/pb5.gif');
|
||||
|
||||
const captureProgress = async (name: string) => {
|
||||
if (!artifactRoot) return;
|
||||
await mkdir(artifactRoot, { recursive: true });
|
||||
const measurement = await page.locator('.legacy-progress').evaluateAll((elements) =>
|
||||
elements.map((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
const fill = element.querySelector<HTMLElement>('.legacy-progress__fill');
|
||||
return {
|
||||
label: element.getAttribute('aria-label'),
|
||||
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
||||
borderTop: style.borderTop,
|
||||
borderBottom: style.borderBottom,
|
||||
backgroundImage: style.backgroundImage,
|
||||
fillWidth: fill?.getBoundingClientRect().width ?? 0,
|
||||
fillBackgroundImage: fill ? getComputedStyle(fill).backgroundImage : '',
|
||||
};
|
||||
})
|
||||
);
|
||||
await Promise.all([
|
||||
page.screenshot({ path: resolve(artifactRoot, `progress-bars-${name}.png`), fullPage: true }),
|
||||
writeFile(resolve(artifactRoot, `progress-bars-${name}.json`), `${JSON.stringify(measurement, null, 2)}\n`),
|
||||
]);
|
||||
};
|
||||
await captureProgress('desktop-1200');
|
||||
|
||||
await page.setViewportSize({ width: 500, height: 900 });
|
||||
await expect(page.locator('.layout-mobile')).toBeVisible();
|
||||
await expect(page.locator('[data-main-target="city"] [role="progressbar"]')).toHaveCount(8);
|
||||
await expect(page.locator('[data-main-target="general"] [role="progressbar"]')).toHaveCount(14);
|
||||
expect(
|
||||
await page
|
||||
.locator('[data-main-target="city"] [role="progressbar"]')
|
||||
.first()
|
||||
.evaluate((element) => element.getBoundingClientRect().height)
|
||||
).toBe(9);
|
||||
await captureProgress('mobile-500');
|
||||
});
|
||||
|
||||
test('the 939/940 boundary switches to the Ref-style 502px single document', async ({ page }) => {
|
||||
const state: NavigationFixture = {
|
||||
officerLevel: 5,
|
||||
|
||||
@@ -22,7 +22,35 @@ const readReferenceImage = async (filename: string): Promise<Buffer> => {
|
||||
throw new Error(`Reference image not found: ${filename}`);
|
||||
};
|
||||
|
||||
type Member = { id: number; name: string; cityId: number; cityName: string };
|
||||
type Member = {
|
||||
id: number;
|
||||
name: string;
|
||||
cityId: number;
|
||||
cityName: string;
|
||||
stats: { leadership: number; strength: number; intelligence: number };
|
||||
experience: number;
|
||||
progression: {
|
||||
experienceLevel: number;
|
||||
statExperience: { leadership: number; strength: number; intelligence: number };
|
||||
statUpgradeLimit: number;
|
||||
dex: number[];
|
||||
};
|
||||
};
|
||||
|
||||
const member = (id: number, name: string, cityId: number, cityName: string): Member => ({
|
||||
id,
|
||||
name,
|
||||
cityId,
|
||||
cityName,
|
||||
stats: { leadership: 70, strength: 60, intelligence: 50 },
|
||||
experience: 450,
|
||||
progression: {
|
||||
experienceLevel: 4,
|
||||
statExperience: { leadership: 7, strength: 8, intelligence: 9 },
|
||||
statUpgradeLimit: 20,
|
||||
dex: [350, 1_375, 3_500, 7_125, 1_275_975],
|
||||
},
|
||||
});
|
||||
type TroopFixture = {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -61,11 +89,7 @@ const baseTroops = (): TroopFixture[] => [
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
},
|
||||
members: [
|
||||
{ id: 1, name: '공손찬', cityId: 1, cityName: '북평' },
|
||||
{ id: 3, name: '조운', cityId: 1, cityName: '북평' },
|
||||
{ id: 4, name: '전예', cityId: 2, cityName: '계' },
|
||||
],
|
||||
members: [member(1, '공손찬', 1, '북평'), member(3, '조운', 1, '북평'), member(4, '전예', 2, '계')],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
@@ -81,7 +105,7 @@ const baseTroops = (): TroopFixture[] => [
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
},
|
||||
members: [{ id: 2, name: '관우', cityId: 2, cityName: '계' }],
|
||||
members: [member(2, '관우', 2, '계')],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -124,11 +148,11 @@ const installApiFixture = async (page: Page, state: FixtureState) => {
|
||||
window.localStorage.setItem('sammo-game-token', 'ga_playwright');
|
||||
window.localStorage.setItem('sammo-game-profile', profile);
|
||||
}, gameProfile);
|
||||
for (const filename of ['back_walnut.jpg', 'back_green.jpg']) {
|
||||
for (const filename of ['back_walnut.jpg', 'back_green.jpg', 'pr5.gif', 'pb5.gif', 'pr8.gif', 'pb8.gif']) {
|
||||
await page.route(`**/image/game/${filename}`, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'image/jpeg',
|
||||
contentType: filename.endsWith('.gif') ? 'image/gif' : 'image/jpeg',
|
||||
body: await readReferenceImage(filename),
|
||||
});
|
||||
});
|
||||
@@ -182,7 +206,7 @@ const installApiFixture = async (page: Page, state: FixtureState) => {
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
},
|
||||
members: [{ id: createdId, name: '유비', cityId: 1, cityName: '북평' }],
|
||||
members: [member(createdId, '유비', 1, '북평')],
|
||||
});
|
||||
return response({ ok: true, troopId: createdId, troopName: '신규대' });
|
||||
}
|
||||
@@ -270,6 +294,15 @@ test('renders the legacy desktop grid with matching computed geometry and states
|
||||
|
||||
await page.locator('.troopMember').nth(1).hover();
|
||||
await expect(page.getByRole('tooltip')).toContainText('조운');
|
||||
await expect(page.getByRole('tooltip').locator('[role="progressbar"]')).toHaveCount(14);
|
||||
await expect(page.getByRole('tooltip').locator('[aria-label*="1,275,975 (EX+)"]')).toHaveCount(5);
|
||||
expect(
|
||||
await page
|
||||
.getByRole('tooltip')
|
||||
.locator('[role="progressbar"]')
|
||||
.first()
|
||||
.evaluate((bar) => getComputedStyle(bar).backgroundImage)
|
||||
).toContain('/game/pr8.gif');
|
||||
expect(await page.getByRole('tooltip').evaluate((tooltip) => tooltip.getBoundingClientRect().width)).toBeCloseTo(
|
||||
500,
|
||||
0
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import SkeletonLines from '../ui/SkeletonLines.vue';
|
||||
import LegacyProgressBar from '../ui/LegacyProgressBar.vue';
|
||||
import { ratioPercent } from '../../utils/legacyProgress';
|
||||
|
||||
interface CityInfo {
|
||||
id: number;
|
||||
@@ -7,11 +10,19 @@ interface CityInfo {
|
||||
level: number;
|
||||
nationId: number;
|
||||
population: number;
|
||||
populationMax: number;
|
||||
agriculture: number;
|
||||
agricultureMax: number;
|
||||
commerce: number;
|
||||
commerceMax: number;
|
||||
security: number;
|
||||
securityMax: number;
|
||||
trust: number;
|
||||
trade: number | null;
|
||||
defence: number;
|
||||
defenceMax: number;
|
||||
wall: number;
|
||||
wallMax: number;
|
||||
supplyState: number;
|
||||
frontState: number;
|
||||
}
|
||||
@@ -20,6 +31,31 @@ const props = defineProps<{
|
||||
city: CityInfo | null;
|
||||
loading: boolean;
|
||||
}>();
|
||||
|
||||
const metrics = computed(() => {
|
||||
const city = props.city;
|
||||
if (!city) return [];
|
||||
const paired = (label: string, current: number, maximum: number) => ({
|
||||
label,
|
||||
percent: ratioPercent(current, maximum),
|
||||
text: `${current.toLocaleString()} / ${maximum.toLocaleString()}`,
|
||||
});
|
||||
const tradeText = city.trade ? `${city.trade}%` : '상인 없음';
|
||||
return [
|
||||
paired('주민', city.population, city.populationMax),
|
||||
{
|
||||
label: '민심',
|
||||
percent: ratioPercent(city.trust, 100),
|
||||
text: city.trust.toLocaleString(undefined, { maximumFractionDigits: 1 }),
|
||||
},
|
||||
paired('농업', city.agriculture, city.agricultureMax),
|
||||
paired('상업', city.commerce, city.commerceMax),
|
||||
paired('치안', city.security, city.securityMax),
|
||||
paired('수비', city.defence, city.defenceMax),
|
||||
paired('성벽', city.wall, city.wallMax),
|
||||
{ label: '시세', percent: city.trade ? ratioPercent(city.trade - 95, 10) : 0, text: tradeText },
|
||||
];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -30,17 +66,26 @@ const props = defineProps<{
|
||||
<div v-else-if="!props.city" class="empty">도시 정보를 불러오지 못했습니다.</div>
|
||||
<div v-else class="city-body">
|
||||
<div class="title">
|
||||
{{ props.city.name }} (Lv {{ props.city.level }}) · 국가 {{ props.city.nationId || '무주' }}
|
||||
{{ props.city.name }} (Lv {{ props.city.level }}) · 국가 {{ props.city.nationId || '무주' }} · 보급
|
||||
{{ props.city.supplyState }} · 전방 {{ props.city.frontState }}
|
||||
</div>
|
||||
<div class="grid">
|
||||
<span>인구</span><strong>{{ props.city.population.toLocaleString() }}</strong> <span>농업</span
|
||||
><strong>{{ props.city.agriculture.toLocaleString() }}</strong> <span>상업</span
|
||||
><strong>{{ props.city.commerce.toLocaleString() }}</strong> <span>치안</span
|
||||
><strong>{{ props.city.security.toLocaleString() }}</strong> <span>수비</span
|
||||
><strong>{{ props.city.defence.toLocaleString() }}</strong> <span>성벽</span
|
||||
><strong>{{ props.city.wall.toLocaleString() }}</strong> <span>보급</span
|
||||
><strong>{{ props.city.supplyState }}</strong> <span>전방</span
|
||||
><strong>{{ props.city.frontState }}</strong>
|
||||
<div class="progress-grid">
|
||||
<div
|
||||
v-for="metric of metrics"
|
||||
:key="metric.label"
|
||||
class="city-progress"
|
||||
:data-city-progress="metric.label"
|
||||
>
|
||||
<span class="city-progress__label">{{ metric.label }}</span>
|
||||
<div class="city-progress__body">
|
||||
<LegacyProgressBar
|
||||
:height="7"
|
||||
:percent="metric.percent"
|
||||
:label="`${metric.label} ${metric.text}`"
|
||||
/>
|
||||
<span class="city-progress__text">{{ metric.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -48,36 +93,60 @@ const props = defineProps<{
|
||||
|
||||
<style scoped>
|
||||
.title {
|
||||
min-height: 24px;
|
||||
padding: 2px 6px;
|
||||
box-sizing: border-box;
|
||||
min-height: 20px;
|
||||
padding: 1px 6px;
|
||||
border-bottom: 1px solid #666;
|
||||
background: #173d27;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.grid {
|
||||
.progress-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
font-size: 12px;
|
||||
}
|
||||
.grid > * {
|
||||
min-height: 23px;
|
||||
box-sizing: border-box;
|
||||
|
||||
.city-progress {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr;
|
||||
min-width: 0;
|
||||
min-height: 31px;
|
||||
border-right: 1px solid #666;
|
||||
border-bottom: 1px solid #666;
|
||||
padding: 2px 5px;
|
||||
}
|
||||
.grid > span {
|
||||
|
||||
.city-progress__label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgb(20 75 42 / 70%);
|
||||
text-align: center;
|
||||
}
|
||||
.grid > strong {
|
||||
text-align: right;
|
||||
font-weight: 400;
|
||||
|
||||
.city-progress__body {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
align-content: center;
|
||||
padding: 1px 3px;
|
||||
}
|
||||
|
||||
.city-progress__text {
|
||||
overflow: hidden;
|
||||
line-height: 14px;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: rgba(232, 221, 196, 0.6);
|
||||
}
|
||||
|
||||
@media (max-width: 939.98px) {
|
||||
.progress-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import SkeletonLines from '../ui/SkeletonLines.vue';
|
||||
import LegacyProgressBar from '../ui/LegacyProgressBar.vue';
|
||||
import { dexProgress, legacyExperiencePercent, ratioPercent } from '../../utils/legacyProgress';
|
||||
|
||||
interface GeneralStats {
|
||||
leadership: number;
|
||||
@@ -7,6 +10,14 @@ interface GeneralStats {
|
||||
intelligence: number;
|
||||
}
|
||||
|
||||
interface GeneralProgression {
|
||||
experienceLevel: number;
|
||||
dedicationLevel: number;
|
||||
statExperience?: { leadership: number; strength: number; intelligence: number };
|
||||
statUpgradeLimit?: number;
|
||||
dex: number[];
|
||||
}
|
||||
|
||||
interface GeneralInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -25,13 +36,47 @@ interface GeneralInfo {
|
||||
turnTime?: string;
|
||||
crewTypeId?: number;
|
||||
traits?: { personal: string; specialWar: string; specialDomestic: string };
|
||||
progression?: { experienceLevel: number; dedicationLevel: number; dex: number[] };
|
||||
progression?: GeneralProgression;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
general: GeneralInfo | null;
|
||||
loading: boolean;
|
||||
}>();
|
||||
|
||||
const statRows = computed(() => {
|
||||
const general = props.general;
|
||||
if (!general) return [];
|
||||
const limit = general.progression?.statUpgradeLimit ?? 30;
|
||||
const accumulated = general.progression?.statExperience;
|
||||
return [
|
||||
{
|
||||
key: 'leadership',
|
||||
label: '통솔',
|
||||
value: general.stats.leadership,
|
||||
accumulated: accumulated?.leadership ?? 0,
|
||||
},
|
||||
{ key: 'strength', label: '무력', value: general.stats.strength, accumulated: accumulated?.strength ?? 0 },
|
||||
{
|
||||
key: 'intelligence',
|
||||
label: '지력',
|
||||
value: general.stats.intelligence,
|
||||
accumulated: accumulated?.intelligence ?? 0,
|
||||
},
|
||||
].map((entry) => ({ ...entry, limit, percent: ratioPercent(entry.accumulated, limit) }));
|
||||
});
|
||||
|
||||
const dexRows = computed(() => {
|
||||
const dex = props.general?.progression?.dex ?? [];
|
||||
return ['보병', '궁병', '기병', '귀병', '차병'].map((label, index) => {
|
||||
const value = dex[index] ?? 0;
|
||||
return { label, value, progress: dexProgress(value) };
|
||||
});
|
||||
});
|
||||
|
||||
const experiencePercent = computed(() =>
|
||||
legacyExperiencePercent(props.general?.experience ?? 0, props.general?.progression?.experienceLevel ?? 0)
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -42,70 +87,182 @@ const props = defineProps<{
|
||||
<div v-else-if="!props.general" class="empty">장수 정보를 불러오지 못했습니다.</div>
|
||||
<div v-else class="general-body">
|
||||
<div class="general-title">
|
||||
{{ props.general.name }} · 관직 {{ props.general.officerLevel }} · {{ props.general.age ?? '-' }}세
|
||||
{{ props.general.name }} · 관직 {{ props.general.officerLevel }} · {{ props.general.age ?? '-' }}세 ·
|
||||
다음 턴
|
||||
{{ props.general.turnTime?.slice(11, 16) ?? '-' }}
|
||||
</div>
|
||||
|
||||
<div class="stat-progress-grid">
|
||||
<template v-for="stat of statRows" :key="stat.key">
|
||||
<span class="cell-label">{{ stat.label }}</span>
|
||||
<strong>{{ stat.value }}</strong>
|
||||
<div class="bar-cell" :data-stat-progress="stat.key">
|
||||
<LegacyProgressBar
|
||||
:percent="stat.percent"
|
||||
:label="`${stat.label} 성장 ${stat.accumulated} / ${stat.limit}`"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="legacy-grid">
|
||||
<span>통솔</span><strong>{{ props.general.stats.leadership }}</strong> <span>무력</span
|
||||
><strong>{{ props.general.stats.strength }}</strong> <span>지력</span
|
||||
><strong>{{ props.general.stats.intelligence }}</strong> <span>자금</span
|
||||
><strong>{{ props.general.gold.toLocaleString() }}</strong> <span>군량</span
|
||||
<span>자금</span><strong>{{ props.general.gold.toLocaleString() }}</strong> <span>군량</span
|
||||
><strong>{{ props.general.rice.toLocaleString() }}</strong> <span>병력</span
|
||||
><strong>{{ props.general.crew.toLocaleString() }}</strong> <span>훈련</span
|
||||
><strong>{{ props.general.train }}</strong> <span>사기</span><strong>{{ props.general.atmos }}</strong>
|
||||
<span>부상</span><strong>{{ props.general.injury }}</strong> <span>명망</span
|
||||
><strong
|
||||
>Lv {{ props.general.progression?.experienceLevel ?? 0 }} ({{ props.general.experience }})</strong
|
||||
>
|
||||
<span>계급</span
|
||||
><strong
|
||||
>Lv {{ props.general.progression?.dedicationLevel ?? 0 }} ({{ props.general.dedication }})</strong
|
||||
>
|
||||
<span>병종</span><strong>{{ props.general.crewTypeId || '-' }}</strong> <span>성격</span
|
||||
<span>부상</span><strong>{{ props.general.injury }}</strong> <span>병종</span
|
||||
><strong>{{ props.general.crewTypeId || '-' }}</strong> <span>성격</span
|
||||
><strong>{{ props.general.traits?.personal ?? '-' }}</strong> <span>전투특기</span
|
||||
><strong>{{ props.general.traits?.specialWar ?? '-' }}</strong> <span>내정특기</span
|
||||
><strong>{{ props.general.traits?.specialDomestic ?? '-' }}</strong> <span>다음 턴</span
|
||||
><strong>{{ props.general.turnTime?.slice(11, 16) ?? '-' }}</strong>
|
||||
><strong>{{ props.general.traits?.specialDomestic ?? '-' }}</strong> <span>계급</span
|
||||
><strong>Lv {{ props.general.progression?.dedicationLevel ?? 0 }}</strong> <span>공헌</span
|
||||
><strong>{{ props.general.dedication.toLocaleString() }}</strong>
|
||||
</div>
|
||||
|
||||
<div class="experience-row">
|
||||
<span class="cell-label">Lv</span>
|
||||
<strong>{{ props.general.progression?.experienceLevel ?? 0 }}</strong>
|
||||
<div class="bar-cell" data-experience-progress>
|
||||
<LegacyProgressBar
|
||||
:percent="experiencePercent"
|
||||
:label="`경험 레벨 진행 ${experiencePercent.toFixed(1)}%`"
|
||||
/>
|
||||
</div>
|
||||
<span class="experience-total">명성 {{ props.general.experience.toLocaleString() }}</span>
|
||||
</div>
|
||||
|
||||
<div class="dex-title">숙 련 도</div>
|
||||
<div v-for="row of dexRows" :key="row.label" class="dex-row" :data-dex-progress="row.label">
|
||||
<span class="cell-label">{{ row.label }}</span>
|
||||
<strong :style="{ color: row.progress.color }">{{ row.progress.name }}</strong>
|
||||
<span class="dex-value">{{ (row.value / 1_000).toFixed(1) }}K</span>
|
||||
<div class="dex-bars">
|
||||
<LegacyProgressBar
|
||||
:percent="row.progress.overallPercent"
|
||||
:label="`${row.label} 전체 ${row.value.toLocaleString()} / 1,275,975 (EX+)`"
|
||||
/>
|
||||
<LegacyProgressBar
|
||||
:height="7"
|
||||
variant="grade"
|
||||
:percent="row.progress.gradePercent"
|
||||
:label="
|
||||
row.progress.nextName
|
||||
? `${row.label} ${row.progress.name}에서 ${row.progress.nextName}까지 ${row.progress.remaining.toLocaleString()} 남음`
|
||||
: `${row.label} EX+ 달성`
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dex">숙련도 {{ props.general.progression?.dex?.join(' / ') ?? '0 / 0 / 0 / 0 / 0' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.general-title {
|
||||
min-height: 24px;
|
||||
padding: 2px 6px;
|
||||
min-height: 20px;
|
||||
padding: 1px 6px;
|
||||
border-bottom: 1px solid #777;
|
||||
background: #173d27;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.legacy-grid {
|
||||
|
||||
.stat-progress-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(3, minmax(30px, 1fr) minmax(34px, 1fr) 45px);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.stat-progress-grid > *,
|
||||
.legacy-grid > * {
|
||||
min-height: 22px;
|
||||
box-sizing: border-box;
|
||||
min-height: 22px;
|
||||
border-right: 1px solid #666;
|
||||
border-bottom: 1px solid #666;
|
||||
padding: 2px 4px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.legacy-grid > span {
|
||||
|
||||
.cell-label,
|
||||
.legacy-grid > span,
|
||||
.dex-title {
|
||||
background: rgb(20 75 42 / 70%);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-progress-grid > strong,
|
||||
.legacy-grid > strong {
|
||||
text-align: right;
|
||||
font-weight: 400;
|
||||
}
|
||||
.dex {
|
||||
padding: 3px 6px;
|
||||
|
||||
.bar-cell {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
padding: 0 1px;
|
||||
}
|
||||
|
||||
.legacy-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
font-size: 12px;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.experience-row {
|
||||
display: grid;
|
||||
grid-template-columns: 32px 38px minmax(120px, 1fr) 112px;
|
||||
min-height: 22px;
|
||||
border-bottom: 1px solid #666;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.experience-row > * {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
box-sizing: border-box;
|
||||
border-right: 1px solid #666;
|
||||
padding: 1px 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dex-title {
|
||||
min-height: 20px;
|
||||
border-bottom: 1px solid #666;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.dex-row {
|
||||
display: grid;
|
||||
grid-template-columns: 64px 40px 60px minmax(0, 1fr);
|
||||
min-height: 26px;
|
||||
border-bottom: 1px solid #666;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dex-row > * {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
box-sizing: border-box;
|
||||
border-right: 1px solid #666;
|
||||
padding: 0 3px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dex-row > strong {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.dex-value {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.dex-bars {
|
||||
gap: 1px;
|
||||
padding: 1px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { dexProgress, legacyExperiencePercent, ratioPercent } from '../../utils/legacyProgress';
|
||||
import LegacyProgressBar from './LegacyProgressBar.vue';
|
||||
|
||||
type GeneralProgress = {
|
||||
stats: { leadership: number; strength: number; intelligence: number };
|
||||
experience: number;
|
||||
progression: {
|
||||
experienceLevel: number;
|
||||
statExperience: { leadership: number; strength: number; intelligence: number };
|
||||
statUpgradeLimit: number;
|
||||
dex: number[];
|
||||
};
|
||||
};
|
||||
|
||||
const props = defineProps<{ general: GeneralProgress }>();
|
||||
|
||||
const statRows = computed(() =>
|
||||
[
|
||||
['leadership', '통솔', props.general.stats.leadership, props.general.progression.statExperience.leadership],
|
||||
['strength', '무력', props.general.stats.strength, props.general.progression.statExperience.strength],
|
||||
[
|
||||
'intelligence',
|
||||
'지력',
|
||||
props.general.stats.intelligence,
|
||||
props.general.progression.statExperience.intelligence,
|
||||
],
|
||||
].map(([key, label, value, accumulated]) => ({
|
||||
key: String(key),
|
||||
label: String(label),
|
||||
value: Number(value),
|
||||
accumulated: Number(accumulated),
|
||||
percent: ratioPercent(Number(accumulated), props.general.progression.statUpgradeLimit),
|
||||
}))
|
||||
);
|
||||
|
||||
const dexRows = computed(() =>
|
||||
['보병', '궁병', '기병', '귀병', '차병'].map((label, index) => {
|
||||
const value = props.general.progression.dex[index] ?? 0;
|
||||
return { label, value, progress: dexProgress(value) };
|
||||
})
|
||||
);
|
||||
|
||||
const experiencePercent = computed(() =>
|
||||
legacyExperiencePercent(props.general.experience, props.general.progression.experienceLevel)
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="legacy-general-progress">
|
||||
<div class="stat-grid">
|
||||
<template v-for="stat of statRows" :key="stat.key">
|
||||
<span class="cell-label">{{ stat.label }}</span>
|
||||
<strong>{{ stat.value }}</strong>
|
||||
<LegacyProgressBar
|
||||
:percent="stat.percent"
|
||||
:label="`${stat.label} 성장 ${stat.accumulated} / ${props.general.progression.statUpgradeLimit}`"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<div class="experience-row">
|
||||
<span class="cell-label">Lv</span>
|
||||
<strong>{{ props.general.progression.experienceLevel }}</strong>
|
||||
<LegacyProgressBar
|
||||
:percent="experiencePercent"
|
||||
:label="`경험 레벨 진행 ${experiencePercent.toFixed(1)}%`"
|
||||
/>
|
||||
</div>
|
||||
<div class="dex-title">숙 련 도</div>
|
||||
<div v-for="row of dexRows" :key="row.label" class="dex-row" :data-dex-progress="row.label">
|
||||
<span class="cell-label">{{ row.label }}</span>
|
||||
<strong :style="{ color: row.progress.color }">{{ row.progress.name }}</strong>
|
||||
<span>{{ (row.value / 1_000).toFixed(1) }}K</span>
|
||||
<div class="dex-bars">
|
||||
<LegacyProgressBar
|
||||
:percent="row.progress.overallPercent"
|
||||
:label="`${row.label} 전체 ${row.value.toLocaleString()} / 1,275,975 (EX+)`"
|
||||
/>
|
||||
<LegacyProgressBar
|
||||
:height="7"
|
||||
variant="grade"
|
||||
:percent="row.progress.gradePercent"
|
||||
:label="
|
||||
row.progress.nextName
|
||||
? `${row.label} ${row.progress.name}에서 ${row.progress.nextName}까지 ${row.progress.remaining.toLocaleString()} 남음`
|
||||
: `${row.label} EX+ 달성`
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.legacy-general-progress {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
border-top: 1px solid #666;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: repeat(3, minmax(30px, 1fr) minmax(34px, 1fr) minmax(42px, 1fr));
|
||||
}
|
||||
|
||||
.stat-grid > *,
|
||||
.experience-row > *,
|
||||
.dex-row > * {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
border-right: 1px solid #666;
|
||||
border-bottom: 1px solid #666;
|
||||
padding: 1px 3px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-grid > .legacy-progress,
|
||||
.experience-row > .legacy-progress {
|
||||
align-self: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.cell-label,
|
||||
.dex-title {
|
||||
background: rgb(20 75 42 / 70%);
|
||||
}
|
||||
|
||||
.experience-row {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: 40px 45px minmax(0, 1fr);
|
||||
min-height: 22px;
|
||||
}
|
||||
|
||||
.dex-title {
|
||||
min-height: 20px;
|
||||
border-bottom: 1px solid #666;
|
||||
font-weight: 700;
|
||||
line-height: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dex-row {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: 54px 40px 58px minmax(0, 1fr);
|
||||
min-height: 26px;
|
||||
}
|
||||
|
||||
.dex-bars {
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
padding: 1px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { configuredGameAssetUrl } from '../../utils/imageAssets';
|
||||
import { clampPercent } from '../../utils/legacyProgress';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
percent: number;
|
||||
height?: 7 | 10;
|
||||
label?: string;
|
||||
variant?: 'overall' | 'grade';
|
||||
}>(),
|
||||
{ height: 10, label: undefined, variant: 'overall' }
|
||||
);
|
||||
|
||||
const imageHeight = computed(() => props.height - 2);
|
||||
const normalizedPercent = computed(() => clampPercent(props.percent));
|
||||
const fallbackLabel = computed(
|
||||
() => `${normalizedPercent.value.toLocaleString(undefined, { maximumFractionDigits: 1 })}%`
|
||||
);
|
||||
const assetRoot = configuredGameAssetUrl();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="legacy-progress"
|
||||
:class="`legacy-progress--${props.variant}`"
|
||||
role="progressbar"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
:aria-valuenow="Number(normalizedPercent.toFixed(1))"
|
||||
:aria-label="props.label ?? fallbackLabel"
|
||||
:title="props.label ?? fallbackLabel"
|
||||
:style="{
|
||||
height: `${props.height + 2}px`,
|
||||
backgroundImage: `url('${assetRoot}/pr${imageHeight}.gif')`,
|
||||
}"
|
||||
>
|
||||
<span
|
||||
class="legacy-progress__fill"
|
||||
:style="{
|
||||
width: `${normalizedPercent}%`,
|
||||
backgroundImage: `url('${assetRoot}/pb${imageHeight}.gif')`,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.legacy-progress {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border-top: 1px solid #888;
|
||||
border-bottom: 1px solid #333;
|
||||
background-position: left center;
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
|
||||
.legacy-progress__fill {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
background-position: left center;
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
|
||||
.legacy-progress--grade {
|
||||
opacity: 0.82;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,78 @@
|
||||
export const DEX_LEVELS = [
|
||||
[0, 'navy', 'F-'],
|
||||
[350, 'navy', 'F'],
|
||||
[1_375, 'navy', 'F+'],
|
||||
[3_500, 'skyblue', 'E-'],
|
||||
[7_125, 'skyblue', 'E'],
|
||||
[12_650, 'skyblue', 'E+'],
|
||||
[20_475, 'seagreen', 'D-'],
|
||||
[31_000, 'seagreen', 'D'],
|
||||
[44_625, 'seagreen', 'D+'],
|
||||
[61_750, 'teal', 'C-'],
|
||||
[82_775, 'teal', 'C'],
|
||||
[108_100, 'teal', 'C+'],
|
||||
[138_125, 'limegreen', 'B-'],
|
||||
[173_250, 'limegreen', 'B'],
|
||||
[213_875, 'limegreen', 'B+'],
|
||||
[260_400, 'darkorange', 'A-'],
|
||||
[313_225, 'darkorange', 'A'],
|
||||
[372_750, 'darkorange', 'A+'],
|
||||
[439_375, 'tomato', 'S-'],
|
||||
[513_500, 'tomato', 'S'],
|
||||
[595_525, 'tomato', 'S+'],
|
||||
[685_850, 'darkviolet', 'Z-'],
|
||||
[784_875, 'darkviolet', 'Z'],
|
||||
[893_000, 'darkviolet', 'Z+'],
|
||||
[1_010_625, 'gold', 'EX-'],
|
||||
[1_138_150, 'gold', 'EX'],
|
||||
[1_275_975, 'white', 'EX+'],
|
||||
] as const;
|
||||
|
||||
export const DEX_EX_PLUS = DEX_LEVELS.at(-1)![0];
|
||||
|
||||
export type DexProgress = {
|
||||
level: number;
|
||||
name: string;
|
||||
color: string;
|
||||
overallPercent: number;
|
||||
gradePercent: number;
|
||||
nextName: string | null;
|
||||
remaining: number;
|
||||
};
|
||||
|
||||
const finiteNonNegative = (value: number): number => (Number.isFinite(value) ? Math.max(0, value) : 0);
|
||||
|
||||
export const clampPercent = (value: number): number => Math.min(100, Math.max(0, Number.isFinite(value) ? value : 0));
|
||||
|
||||
export const ratioPercent = (current: number, maximum: number): number =>
|
||||
maximum > 0 ? clampPercent((finiteNonNegative(current) / maximum) * 100) : 0;
|
||||
|
||||
export const legacyExperiencePercent = (experience: number, level: number): number => {
|
||||
const safeExperience = finiteNonNegative(experience);
|
||||
const safeLevel = Math.max(0, Math.floor(finiteNonNegative(level)));
|
||||
if (safeExperience < 100) return clampPercent(safeExperience);
|
||||
if (safeExperience < 1_000) return clampPercent(safeExperience - safeLevel * 100);
|
||||
return clampPercent(((safeExperience - 10 * safeLevel ** 2) / (2 * safeLevel + 1)) * 10);
|
||||
};
|
||||
|
||||
export const dexProgress = (rawDex: number): DexProgress => {
|
||||
const dex = finiteNonNegative(rawDex);
|
||||
let level = 0;
|
||||
for (let index = 1; index < DEX_LEVELS.length; index += 1) {
|
||||
if (dex < DEX_LEVELS[index]![0]) break;
|
||||
level = index;
|
||||
}
|
||||
|
||||
const [floor, color, name] = DEX_LEVELS[level]!;
|
||||
const next = DEX_LEVELS[level + 1];
|
||||
const gradePercent = next ? ratioPercent(dex - floor, next[0] - floor) : 100;
|
||||
return {
|
||||
level,
|
||||
name,
|
||||
color,
|
||||
overallPercent: ratioPercent(dex, DEX_EX_PLUS),
|
||||
gradePercent,
|
||||
nextName: next?.[2] ?? null,
|
||||
remaining: next ? Math.max(0, next[0] - dex) : 0,
|
||||
};
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import PanelCard from '../components/ui/PanelCard.vue';
|
||||
import SkeletonLines from '../components/ui/SkeletonLines.vue';
|
||||
import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue';
|
||||
import { trpc } from '../utils/trpc';
|
||||
import { getNpcColor } from '../utils/npcColor';
|
||||
import { formatLog } from '../utils/formatLog';
|
||||
@@ -302,9 +303,9 @@ onMounted(() => {
|
||||
><strong>{{ selectedGeneral.battleStats.deathCrew.toLocaleString('ko-KR') }}</strong>
|
||||
<span>전투 특기</span><strong>{{ selectedGeneral.traits.specialWar }}</strong>
|
||||
<span>내정 특기</span><strong>{{ selectedGeneral.traits.specialDomestic }}</strong>
|
||||
<span>성격</span><strong>{{ selectedGeneral.traits.personal }}</strong> <span>숙련도</span
|
||||
><strong>{{ selectedGeneral.battleStats.dex.join(' / ') }}</strong>
|
||||
<span>성격</span><strong>{{ selectedGeneral.traits.personal }}</strong>
|
||||
</div>
|
||||
<LegacyGeneralProgress :general="selectedGeneral" />
|
||||
</div>
|
||||
<div v-if="selectedGeneral" class="general-meta">
|
||||
<div>최근 턴: {{ selectedGeneral.turnTime ? selectedGeneral.turnTime.slice(-5) : '-' }}</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { formatSeoulDateTime } from '../utils/legacyDateTime';
|
||||
import { isDefenceTrainPenaltyWaivedByScenarioEffect } from '@sammo-ts/logic';
|
||||
import { useSessionStore } from '../stores/session';
|
||||
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
|
||||
import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue';
|
||||
|
||||
const SCREEN_MODE_KEY = 'sam.screenMode';
|
||||
const CUSTOM_CSS_KEY = 'sam_customCSS';
|
||||
@@ -417,19 +418,12 @@ onMounted(() => {
|
||||
<div>전투 0 · 계략 0 · 사관 7년</div>
|
||||
<div>승률 0% · 승리 0 · 패배 0</div>
|
||||
<div>살상률 0% · 사살 0 · 피살 0</div>
|
||||
<div class="dexterity-title">숙련도</div>
|
||||
<div>
|
||||
보병 {{ data.general.progression?.dex?.[0] ?? 0 }} · 궁병
|
||||
{{ data.general.progression?.dex?.[1] ?? 0 }} · 기병
|
||||
{{ data.general.progression?.dex?.[2] ?? 0 }} · 귀병
|
||||
{{ data.general.progression?.dex?.[3] ?? 0 }} · 차병
|
||||
{{ data.general.progression?.dex?.[4] ?? 0 }}
|
||||
</div>
|
||||
<div>
|
||||
병종 {{ data.general.crewTypeId || '-' }} · 내정특기
|
||||
{{ data.general.traits?.specialDomestic ?? '-' }} · 부상 {{ data.general.injury }} · 부대
|
||||
{{ data.general.troopId || '-' }} · 벌점 {{ penalties.length || '-' }}
|
||||
</div>
|
||||
<LegacyGeneralProgress :general="data.general" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -791,10 +785,9 @@ button:disabled {
|
||||
.legacy-general-details > div {
|
||||
border-top: 1px solid #557;
|
||||
}
|
||||
.dexterity-title {
|
||||
background: #14241b var(--sammo-texture-green);
|
||||
}
|
||||
.legacy-credit {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
dl {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router';
|
||||
|
||||
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
|
||||
import { trpc } from '../utils/trpc';
|
||||
import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue';
|
||||
|
||||
type TroopList = Awaited<ReturnType<typeof trpc.troop.getList.query>>;
|
||||
type Troop = TroopList['troops'][number];
|
||||
@@ -347,8 +348,11 @@ onMounted(() => {
|
||||
<div></div>
|
||||
</footer>
|
||||
<div v-if="popupMember" id="generalPopup" :style="{ top: `${popupTop}px` }" role="tooltip">
|
||||
<strong>{{ popupMember.name }}</strong>
|
||||
<span>{{ popupMember.cityName }}</span>
|
||||
<div class="popup-title">
|
||||
<strong>{{ popupMember.name }}</strong>
|
||||
<span>{{ popupMember.cityName }}</span>
|
||||
</div>
|
||||
<LegacyGeneralProgress :general="popupMember" />
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
@@ -420,13 +424,19 @@ onMounted(() => {
|
||||
z-index: 10;
|
||||
width: 500px;
|
||||
min-height: 58px;
|
||||
padding: 8px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid #999;
|
||||
background: #202020;
|
||||
}
|
||||
|
||||
.popup-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 3px 6px;
|
||||
}
|
||||
|
||||
.additionalTroopOptions {
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import { DEX_EX_PLUS, dexProgress, legacyExperiencePercent, ratioPercent } from '../src/utils/legacyProgress.ts';
|
||||
|
||||
void describe('legacy progress calculations', () => {
|
||||
void it('uses the real EX+ threshold for the full dexterity bar', () => {
|
||||
assert.equal(DEX_EX_PLUS, 1_275_975);
|
||||
assert.equal(dexProgress(1_000_000).overallPercent, (1_000_000 / 1_275_975) * 100);
|
||||
assert.equal(dexProgress(DEX_EX_PLUS).overallPercent, 100);
|
||||
});
|
||||
|
||||
void it('tracks progress inside the current dexterity grade separately', () => {
|
||||
assert.deepEqual(dexProgress(350), {
|
||||
level: 1,
|
||||
name: 'F',
|
||||
color: 'navy',
|
||||
overallPercent: (350 / DEX_EX_PLUS) * 100,
|
||||
gradePercent: 0,
|
||||
nextName: 'F+',
|
||||
remaining: 1_025,
|
||||
});
|
||||
assert.equal(dexProgress(1_275_975).gradePercent, 100);
|
||||
assert.equal(dexProgress(1_275_975).nextName, null);
|
||||
});
|
||||
|
||||
void it('preserves ref experience-level and guarded ratio percentages', () => {
|
||||
assert.equal(legacyExperiencePercent(55, 0), 55);
|
||||
assert.equal(legacyExperiencePercent(450, 4), 50);
|
||||
assert.equal(legacyExperiencePercent(1_210, 11), 0);
|
||||
assert.equal(legacyExperiencePercent(1_440, 11), 100);
|
||||
assert.equal(ratioPercent(10, 0), 0);
|
||||
assert.equal(ratioPercent(15, 10), 100);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { chromium } from '@playwright/test';
|
||||
|
||||
const baseUrl = process.env.REF_MAIN_URL ?? 'http://127.0.0.1:3400/sam/';
|
||||
const username = process.env.REF_USER_ID ?? 'refuser1';
|
||||
const passwordFile =
|
||||
process.env.REF_USER_PASSWORD_FILE ??
|
||||
'/home/letrhee/sam_rebuild/docker_compose_files/reference/secrets/user1_password';
|
||||
const artifactRoot = resolve(process.env.REF_PROGRESS_ARTIFACT_DIR ?? 'artifacts/ref-progress-bars');
|
||||
|
||||
const password = (await readFile(passwordFile, 'utf8')).trim();
|
||||
await mkdir(artifactRoot, { recursive: true, mode: 0o700 });
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
|
||||
try {
|
||||
const context = await browser.newContext({
|
||||
colorScheme: 'dark',
|
||||
deviceScaleFactor: 1,
|
||||
locale: 'ko-KR',
|
||||
timezoneId: 'UTC',
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.goto(baseUrl, { waitUntil: 'networkidle' });
|
||||
const globalSalt = await page.locator('#global_salt').inputValue();
|
||||
const passwordHash = createHash('sha512')
|
||||
.update(globalSalt + password + globalSalt)
|
||||
.digest('hex');
|
||||
const response = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), {
|
||||
data: { username, password: passwordHash },
|
||||
});
|
||||
if (!response.ok()) throw new Error(`reference login failed: HTTP ${response.status()}`);
|
||||
const loginPayload = await response.json();
|
||||
if (loginPayload?.result !== true) {
|
||||
throw new Error(`reference login rejected: ${String(loginPayload?.reason ?? loginPayload)}`);
|
||||
}
|
||||
|
||||
if (process.env.REF_CREATE_GENERAL === '1') {
|
||||
await page.goto(new URL('hwe/v_join.php', baseUrl).toString(), { waitUntil: 'networkidle' });
|
||||
const createButton = page.getByRole('button', { name: '장수 생성', exact: true });
|
||||
if (await createButton.isVisible()) {
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
await createButton.click();
|
||||
await page.waitForURL(/\/hwe\/?$/u);
|
||||
}
|
||||
}
|
||||
|
||||
for (const viewport of [
|
||||
{ name: 'desktop-1000', width: 1000, height: 900 },
|
||||
{ name: 'mobile-500', width: 500, height: 900 },
|
||||
]) {
|
||||
await page.setViewportSize(viewport);
|
||||
await page.goto(new URL('hwe/', baseUrl).toString(), { waitUntil: 'networkidle' });
|
||||
try {
|
||||
await page
|
||||
.locator('.city-card-basic .sammo-bar, .bar_out')
|
||||
.first()
|
||||
.waitFor({ state: 'visible', timeout: 8_000 });
|
||||
} catch {
|
||||
throw new Error(
|
||||
`reference main progress bars missing: ${JSON.stringify({
|
||||
url: page.url(),
|
||||
title: await page.title(),
|
||||
cityCards: await page.locator('.city-card-basic').count(),
|
||||
generalCards: await page.locator('.general-card-basic').count(),
|
||||
legacyBars: await page.locator('.bar_out').count(),
|
||||
})}`
|
||||
);
|
||||
}
|
||||
const measurement = await page.evaluate(() => {
|
||||
const inspect = (element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
const fill = element.querySelector('.sammo-bar-in');
|
||||
const fillStyle = fill ? getComputedStyle(fill) : null;
|
||||
return {
|
||||
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
||||
borderTop: style.borderTop,
|
||||
borderBottom: style.borderBottom,
|
||||
backgroundImage: getComputedStyle(element.querySelector('.sammo-bar-base')).backgroundImage,
|
||||
fillWidth: fill?.getBoundingClientRect().width ?? 0,
|
||||
fillBackgroundImage: fillStyle?.backgroundImage ?? '',
|
||||
};
|
||||
};
|
||||
return {
|
||||
viewport: { width: innerWidth, height: innerHeight },
|
||||
city: [...document.querySelectorAll('.city-card-basic .sammo-bar')].map(inspect),
|
||||
general: [...document.querySelectorAll('.general-card-basic .sammo-bar')].map(inspect),
|
||||
cityCard: document.querySelector('.city-card-basic').getBoundingClientRect().toJSON(),
|
||||
generalCard: document.querySelector('.general-card-basic').getBoundingClientRect().toJSON(),
|
||||
};
|
||||
});
|
||||
await Promise.all([
|
||||
page.screenshot({ path: resolve(artifactRoot, `ref-${viewport.name}.png`), fullPage: true }),
|
||||
writeFile(resolve(artifactRoot, `ref-${viewport.name}.json`), `${JSON.stringify(measurement, null, 2)}\n`, {
|
||||
mode: 0o600,
|
||||
}),
|
||||
]);
|
||||
await chmod(resolve(artifactRoot, `ref-${viewport.name}.png`), 0o600);
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
viewport: viewport.name,
|
||||
cityBars: measurement.city.length,
|
||||
generalBars: measurement.general.length,
|
||||
cityHeight: measurement.city[0]?.rect.height,
|
||||
generalHeight: measurement.general[0]?.rect.height,
|
||||
})
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
Reference in New Issue
Block a user