diff --git a/app/game-api/src/router/general/index.ts b/app/game-api/src/router/general/index.ts index 4819e965..308e3d01 100644 --- a/app/game-api/src/router/general/index.ts +++ b/app/game-api/src/router/general/index.ts @@ -42,6 +42,12 @@ import { resolveMainNationTech, splitNationTraitInfo, } from '../../services/mainNationProjection.js'; +import { + resolveGeneralTypeCall, + resolveLeadershipBonus, + resolveRefreshScoreText, + resolveRemainingMinutes, +} from '../../services/generalBasicCardProjection.js'; const zGeneralSettings = z.object({ tnmt: z.number().int().optional(), @@ -268,63 +274,91 @@ export const getGeneralContext = async (ctx: GameApiContext) => { return null; } - const [city, queriedNation, worldState] = await Promise.all([ - general.cityId > 0 - ? ctx.db.city.findUnique({ - where: { id: general.cityId }, - select: { - id: true, - name: true, - 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, - }, - }) - : null, - general.nationId > 0 - ? ctx.db.nation.findUnique({ - where: { id: general.nationId }, - select: { - id: true, - name: true, - color: true, - level: true, - gold: true, - rice: true, - tech: true, - typeCode: true, - capitalCityId: true, - meta: true, - }, - }) - : Promise.resolve(NEUTRAL_NATION_CONTEXT), - ctx.db.worldState.findFirst({ select: { currentYear: true, currentMonth: true, config: true, meta: true } }), - ]); + const metaRecord = asRecord(general.meta); + const officerCityId = readNumber(metaRecord.officerCity ?? metaRecord.officer_city ?? metaRecord.officerCityId, 0); + const [city, queriedNation, worldState, officerCity, troop, troopLeader, troopLeaderFirstTurn, accessLog] = + await Promise.all([ + general.cityId > 0 + ? ctx.db.city.findUnique({ + where: { id: general.cityId }, + select: { + id: true, + name: true, + 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, + }, + }) + : null, + general.nationId > 0 + ? ctx.db.nation.findUnique({ + where: { id: general.nationId }, + select: { + id: true, + name: true, + color: true, + level: true, + gold: true, + rice: true, + tech: true, + typeCode: true, + capitalCityId: true, + meta: true, + }, + }) + : Promise.resolve(NEUTRAL_NATION_CONTEXT), + ctx.db.worldState.findFirst({ + select: { currentYear: true, currentMonth: true, tickSeconds: true, config: true, meta: true }, + }), + officerCityId > 0 + ? ctx.db.city.findUnique({ where: { id: officerCityId }, select: { name: true } }) + : Promise.resolve(null), + general.troopId > 0 + ? ctx.db.troop.findUnique({ where: { troopLeaderId: general.troopId }, select: { name: true } }) + : Promise.resolve(null), + general.troopId > 0 + ? ctx.db.general.findUnique({ where: { id: general.troopId }, select: { cityId: true } }) + : Promise.resolve(null), + general.troopId > 0 + ? ctx.db.generalTurn.findFirst({ + where: { generalId: general.troopId }, + orderBy: { turnIdx: 'asc' }, + select: { actionCode: true }, + }) + : Promise.resolve(null), + ctx.db.generalAccessLog.findUnique({ + where: { generalId: general.id }, + select: { refreshScore: true, refreshScoreTotal: true }, + }), + ]); const nation = queriedNation ?? NEUTRAL_NATION_CONTEXT; - const [capitalCity, cityNation, nationPopulation, nationCrew, topChiefRows] = await Promise.all([ + const [capitalCity, cityNation, troopLeaderCity, nationPopulation, nationCrew, topChiefRows] = await Promise.all([ nation.capitalCityId ? ctx.db.city.findUnique({ where: { id: nation.capitalCityId }, select: { name: true } }) : Promise.resolve(null), city && city.nationId > 0 ? ctx.db.nation.findUnique({ where: { id: city.nationId }, select: { name: true } }) : Promise.resolve(null), + troopLeader && troopLeader.cityId > 0 + ? ctx.db.city.findUnique({ where: { id: troopLeader.cityId }, select: { name: true } }) + : Promise.resolve(null), nation.id > 0 ? ctx.db.city.aggregate({ where: { nationId: nation.id }, @@ -356,9 +390,12 @@ export const getGeneralContext = async (ctx: GameApiContext) => { loadItemDisplayNames([general.horseCode, general.weaponCode, general.bookCode, general.itemCode]), ]); - const metaRecord = asRecord(general.meta); const worldConfig = asRecord(worldState?.config); const constValues = asRecord(worldConfig.const ?? worldConfig.consts); + const scenarioStat = asRecord(worldConfig.stat); + const chiefStatMin = readNumber(scenarioStat.chiefMin, 70); + const statGradeLevel = readNumber(constValues.statGradeLevel, 5); + const retirementYear = readNumber(constValues.retirementYear, 70); const maxDedicationLevel = readNumber(constValues.maxDedLevel, 30); const settings = resolveUserSettings(metaRecord); const penalties = resolvePenalty(general.penalty); @@ -379,6 +416,27 @@ export const getGeneralContext = async (ctx: GameApiContext) => { const normalized = normalizeItemCode(code); return normalized ? (itemNames.get(normalized) ?? sanitizeInternalDisplayCode(normalized)) : null; }; + const worldMeta = asRecord(worldState?.meta); + const rawLastExecuted = worldMeta.lastTurnTime ?? worldMeta.turntime; + const parsedLastExecuted = + rawLastExecuted instanceof Date + ? rawLastExecuted + : typeof rawLastExecuted === 'string' + ? new Date(rawLastExecuted) + : null; + const stats = { + leadership: general.leadership, + strength: general.strength, + intelligence: general.intel, + }; + const refreshScore = accessLog?.refreshScore ?? 0; + const refreshScoreTotal = accessLog?.refreshScoreTotal ?? 0; + const troopStatus: 'inactive' | 'present' | 'away' = + troopLeaderFirstTurn?.actionCode !== undefined && troopLeaderFirstTurn.actionCode !== 'che_집합' + ? 'inactive' + : troopLeader?.cityId === general.cityId + ? 'present' + : 'away'; return { general: { @@ -392,11 +450,11 @@ export const getGeneralContext = async (ctx: GameApiContext) => { imageServer: general.imageServer, officerLevel: general.officerLevel, officerLevelText: resolveOfficerLevelName(general.officerLevel, nation.level), - stats: { - leadership: general.leadership, - strength: general.strength, - intelligence: general.intel, - }, + officerCityName: + general.officerLevel >= 2 && general.officerLevel <= 4 ? (officerCity?.name ?? null) : null, + generalType: resolveGeneralTypeCall(stats, chiefStatMin, statGradeLevel), + leadershipBonus: resolveLeadershipBonus(general.officerLevel, nation.level), + stats, gold: general.gold, rice: general.rice, crew: general.crew, @@ -406,7 +464,15 @@ export const getGeneralContext = async (ctx: GameApiContext) => { experience: general.experience, dedication: general.dedication, age: general.age, + retirementYear, turnTime: general.turnTime.toISOString(), + defenceTrain: settings.defence_train, + killTurn: readNumber(metaRecord.killturn ?? metaRecord.killTurn, 0), + remainingMinutes: resolveRemainingMinutes( + general.turnTime, + parsedLastExecuted, + worldState?.tickSeconds ?? 0 + ), crewTypeId: general.crewTypeId, crewTypeName: crewTypeNames.get(general.crewTypeId) ?? '-', traits: { @@ -438,6 +504,18 @@ export const getGeneralContext = async (ctx: GameApiContext) => { book: itemName(general.bookCode), item: itemName(general.itemCode), }, + troop: troop + ? { + name: troop.name, + status: troopStatus, + leaderCityName: troopLeaderCity?.name ?? null, + } + : null, + refreshScore: { + current: refreshScore, + total: refreshScoreTotal, + text: resolveRefreshScoreText(refreshScoreTotal), + }, }, iconChoices: ctx.auth?.user.canUseGeneralPicture === false ? [] : (ctx.auth?.user.icons ?? []), canChangeIcon: general.npcState === 0 && ctx.auth?.user.canUseGeneralPicture !== false, diff --git a/app/game-api/src/services/generalBasicCardProjection.ts b/app/game-api/src/services/generalBasicCardProjection.ts new file mode 100644 index 00000000..556c53aa --- /dev/null +++ b/app/game-api/src/services/generalBasicCardProjection.ts @@ -0,0 +1,55 @@ +export interface GeneralBasicStats { + leadership: number; + strength: number; + intelligence: number; +} + +export const resolveGeneralTypeCall = (stats: GeneralBasicStats, chiefStatMin: number, statGradeLevel = 5): string => { + const { leadership, strength, intelligence } = stats; + if (leadership < 40) { + if (strength + intelligence < 40) return '아둔'; + if (intelligence >= chiefStatMin && strength < intelligence * 0.8) return '학자'; + if (strength >= chiefStatMin && intelligence < strength * 0.8) return '장사'; + return '명사'; + } + + const maxStat = Math.max(leadership, strength, intelligence); + const sumTwoStats = Math.min(leadership + strength, strength + intelligence, intelligence + leadership); + if (maxStat >= chiefStatMin + statGradeLevel && sumTwoStats >= maxStat * 1.7) return '만능'; + if (strength >= chiefStatMin - statGradeLevel && intelligence < strength * 0.8) return '용장'; + if (intelligence >= chiefStatMin - statGradeLevel && strength < intelligence * 0.8) return '명장'; + if (leadership >= chiefStatMin - statGradeLevel && strength + intelligence < leadership) return '차장'; + return '평범'; +}; + +export const resolveLeadershipBonus = (officerLevel: number, nationLevel: number): number => { + if (officerLevel === 12) return nationLevel * 2; + if (officerLevel >= 5) return nationLevel; + return 0; +}; + +export const resolveRefreshScoreText = (score: number): string => { + if (score < 50) return '안함'; + if (score < 100) return '무관심'; + if (score < 200) return '보통'; + if (score < 400) return '가끔'; + if (score < 800) return '자주'; + if (score < 1_600) return '열심'; + if (score < 3_200) return '중독'; + if (score < 6_400) return '폐인'; + if (score < 12_800) return '경고'; + return '헐...'; +}; + +export const resolveRemainingMinutes = ( + turnTime: Date, + lastExecuted: Date | null, + turnTermSeconds: number +): number | null => { + if (!lastExecuted || !Number.isFinite(lastExecuted.getTime()) || turnTermSeconds <= 0) return null; + let nextTurnMillis = turnTime.getTime(); + if (nextTurnMillis < lastExecuted.getTime()) { + nextTurnMillis += turnTermSeconds * 1_000; + } + return Math.floor(Math.min(999, Math.max(0, (nextTurnMillis - lastExecuted.getTime()) / 60_000))); +}; diff --git a/app/game-api/test/generalBasicCardProjection.test.ts b/app/game-api/test/generalBasicCardProjection.test.ts new file mode 100644 index 00000000..9df312ad --- /dev/null +++ b/app/game-api/test/generalBasicCardProjection.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; + +import { + resolveGeneralTypeCall, + resolveLeadershipBonus, + resolveRefreshScoreText, + resolveRemainingMinutes, +} from '../src/services/generalBasicCardProjection.js'; + +describe('general basic card Ref projection', () => { + it.each([ + [{ leadership: 20, strength: 9, intelligence: 10 }, '아둔'], + [{ leadership: 20, strength: 20, intelligence: 70 }, '학자'], + [{ leadership: 20, strength: 70, intelligence: 20 }, '장사'], + [{ leadership: 20, strength: 35, intelligence: 35 }, '명사'], + [{ leadership: 80, strength: 75, intelligence: 75 }, '만능'], + [{ leadership: 60, strength: 70, intelligence: 40 }, '용장'], + [{ leadership: 60, strength: 40, intelligence: 70 }, '명장'], + [{ leadership: 70, strength: 30, intelligence: 30 }, '차장'], + [{ leadership: 60, strength: 60, intelligence: 60 }, '평범'], + ] as const)('matches the Ref general type rules for %o', (stats, expected) => { + expect(resolveGeneralTypeCall(stats, 70, 5)).toBe(expected); + }); + + it('matches the Ref officer leadership bonus', () => { + expect(resolveLeadershipBonus(12, 7)).toBe(14); + expect(resolveLeadershipBonus(5, 7)).toBe(7); + expect(resolveLeadershipBonus(4, 7)).toBe(0); + }); + + it('uses the Ref refresh score thresholds', () => { + expect(resolveRefreshScoreText(99)).toBe('무관심'); + expect(resolveRefreshScoreText(100)).toBe('보통'); + expect(resolveRefreshScoreText(200)).toBe('가끔'); + expect(resolveRefreshScoreText(12_800)).toBe('헐...'); + }); + + it('matches the Ref remaining-minute calculation and one-turn rollover', () => { + const lastExecuted = new Date('2026-08-13T00:00:00.000Z'); + expect(resolveRemainingMinutes(new Date('2026-08-13T00:07:06.000Z'), lastExecuted, 3_600)).toBe(7); + expect(resolveRemainingMinutes(new Date('2026-08-12T23:59:00.000Z'), lastExecuted, 3_600)).toBe(59); + expect(resolveRemainingMinutes(new Date('2026-08-13T00:07:06.000Z'), null, 3_600)).toBeNull(); + }); +}); diff --git a/app/game-api/test/inGameMenuPermissions.test.ts b/app/game-api/test/inGameMenuPermissions.test.ts index e634681b..3db5975d 100644 --- a/app/game-api/test/inGameMenuPermissions.test.ts +++ b/app/game-api/test/inGameMenuPermissions.test.ts @@ -81,6 +81,10 @@ const createContext = (options: { requestCommand?: ReturnType; accessToken?: string; logs?: Array<{ id: number; text: string; year?: number; month?: number; createdAt?: Date }>; + troopName?: string | null; + troopLeaderAction?: string | null; + refreshScore?: number; + refreshScoreTotal?: number; }) => { const me = options.me === undefined ? buildGeneral() : options.me; const targets = options.targets ?? (me ? [me] : []); @@ -94,9 +98,43 @@ const createContext = (options: { findFirst: vi.fn(async () => me), findUnique: generalFindUnique, findMany: vi.fn(async () => targets.filter((general) => general.nationId === (me?.nationId ?? 0))), + aggregate: vi.fn(async () => ({ + _count: targets.filter((general) => general.nationId === (me?.nationId ?? 0)).length, + _sum: { + crew: targets.reduce((sum, general) => sum + general.crew, 0), + leadership: targets.reduce((sum, general) => sum + general.leadership, 0), + }, + })), update: vi.fn(), }, - city: { findUnique: vi.fn(async () => options.city ?? null) }, + troop: { + findUnique: vi.fn(async () => + options.troopName === undefined ? null : options.troopName === null ? null : { name: options.troopName } + ), + }, + generalTurn: { + findFirst: vi.fn(async () => + options.troopLeaderAction === undefined || options.troopLeaderAction === null + ? null + : { actionCode: options.troopLeaderAction } + ), + }, + generalAccessLog: { + findUnique: vi.fn(async () => ({ + refreshScore: options.refreshScore ?? 0, + refreshScoreTotal: options.refreshScoreTotal ?? 0, + })), + }, + city: { + findUnique: vi.fn(async () => options.city ?? null), + aggregate: vi.fn(async () => ({ + _count: options.city ? 1 : 0, + _sum: { + population: Number(options.city?.population ?? 0), + populationMax: Number(options.city?.populationMax ?? 0), + }, + })), + }, nation: { findUnique: vi.fn(async () => ({ id: 1, @@ -297,6 +335,60 @@ describe('in-game my information ownership', () => { }); }); + it('returns the Ref general-card title, execution, troop, and refresh-score projection', async () => { + const fixture = createContext({ + me: buildGeneral({ + troopId: 7, + officerLevel: 4, + strength: 70, + intel: 40, + turnTime: new Date('2026-01-01T00:07:06.000Z'), + meta: { officerCity: 1, killturn: 6, defence_train: 80 }, + }), + city: { + id: 1, + name: '업', + level: 8, + nationId: 1, + population: 1_000, + populationMax: 2_000, + agriculture: 100, + agricultureMax: 200, + commerce: 100, + commerceMax: 200, + security: 100, + securityMax: 200, + trust: 70, + trade: 100, + defence: 100, + defenceMax: 200, + wall: 100, + wallMax: 200, + region: 2, + supplyState: 1, + frontState: 0, + }, + troopName: '정밀검증부대', + troopLeaderAction: '휴식', + refreshScore: 3, + refreshScoreTotal: 1_141, + }); + + await expect(appRouter.createCaller(fixture.context).general.me()).resolves.toMatchObject({ + general: { + officerCityName: '업', + generalType: '용장', + leadershipBonus: 0, + retirementYear: 70, + defenceTrain: 80, + killTurn: 6, + remainingMinutes: null, + troop: { name: '정밀검증부대', status: 'inactive', leaderCityName: '업' }, + refreshScore: { current: 3, total: 1_141, text: '열심' }, + }, + }); + }); + it('returns the Ref-style neutral nation frame and trait display names on the main read model', async () => { const fixture = createContext({ me: buildGeneral({ diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 8d6e45ba..a6c9de84 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -201,9 +201,13 @@ const generalContext = (state: NavigationFixture) => ({ name: state.generalName ?? '메뉴검증장수', nationId: 1, cityId: 1, - troopId: 0, + troopId: 7, npcState: 0, officerLevel: state.officerLevel, + officerLevelText: state.officerLevel === 0 ? '재야' : '군주', + officerCityName: state.officerLevel >= 2 && state.officerLevel <= 4 ? '업' : null, + generalType: '용장', + leadershipBonus: state.officerLevel === 12 ? state.nationLevel * 2 : 0, picture: null, imageServer: 0, stats: { leadership: 70, strength: 60, intelligence: 50 }, @@ -215,6 +219,13 @@ const generalContext = (state: NavigationFixture) => ({ injury: 0, experience: 100, dedication: 200, + age: 25, + retirementYear: 70, + defenceTrain: 80, + killTurn: 5, + remainingMinutes: 7, + troop: { name: '백마대', status: 'present', leaderCityName: '업' }, + refreshScore: { current: 3, total: 120, text: '보통' }, progression: { experienceLevel: 1, dedicationLevel: 2, @@ -223,6 +234,10 @@ const generalContext = (state: NavigationFixture) => ({ dex: [350, 100_000, 500_000, 1_000_000, 1_275_975], }, items: { horse: null, weapon: null, book: null, item: null }, + itemNames: { horse: '적토마', weapon: '청룡언월도', book: '육도', item: '옥벽' }, + crewTypeId: 1, + crewTypeName: '보병', + traits: { personal: '대담', specialDomestic: '상재', specialWar: '무쌍' }, turnTime: state.generalTurnTime ?? '0185-01-01T00:00:00.000Z', }, city: { @@ -739,7 +754,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`); }); -test('main general card renders the next turn in the Seoul server timezone', async ({ page }) => { +test('main general card renders Ref title and omitted rows with second precision', async ({ page }) => { const state: NavigationFixture = { officerLevel: 0, permission: 0, @@ -759,8 +774,15 @@ test('main general card renders the next turn in the Seoul server timezone', asy const title = page.locator('[data-main-target="general"] .general-title').first(); await expect(title).toContainText('Administrator'); - await expect(title).toContainText('다음 턴 09:07'); + await expect(title).toContainText('용장'); + await expect(title).toContainText('09:07:06'); await expect(title).not.toContainText('00:07'); + const generalCard = page.locator('[data-main-target="general"] [data-general-basic-card]').first(); + await expect(generalCard).toContainText('수비 함(훈사80)'); + await expect(generalCard).toContainText('5 턴'); + await expect(generalCard).toContainText('7분 남음'); + await expect(generalCard).toContainText('백마대'); + await expect(generalCard).toContainText('보통 120점(3)'); const desktopGeometry = await title.evaluate((element) => { const rect = element.getBoundingClientRect(); @@ -768,6 +790,8 @@ test('main general card renders the next turn in the Seoul server timezone', asy return { width: rect.width, height: rect.height, + scrollWidth: element.scrollWidth, + clientWidth: element.clientWidth, fontSize: style.fontSize, lineHeight: style.lineHeight, overflow: style.overflow, @@ -775,6 +799,8 @@ test('main general card renders the next turn in the Seoul server timezone', asy }); expect(desktopGeometry.width).toBeGreaterThan(0); expect(desktopGeometry.height).toBeGreaterThan(0); + expect(desktopGeometry.scrollWidth - desktopGeometry.clientWidth).toBeLessThanOrEqual(0); + expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(0); if (artifactRoot) { const target = resolve(artifactRoot); await mkdir(target, { recursive: true }); @@ -789,8 +815,9 @@ test('main general card renders the next turn in the Seoul server timezone', asy await page.setViewportSize({ width: 500, height: 900 }); const mobileTitle = page.locator('[data-main-target="general"] .general-title').first(); - await expect(mobileTitle).toContainText('다음 턴 09:07'); + await expect(mobileTitle).toContainText('09:07:06'); expect(await mobileTitle.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(0); + expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(0); if (artifactRoot) { await page.screenshot({ path: resolve(artifactRoot, 'main-turn-time-seoul-mobile-500.png'), @@ -1546,7 +1573,9 @@ test('mobile single document refreshes once and preserves tokens on lobby return await expect(autoRefresh.locator('strong')).toHaveCSS('color', 'rgb(158, 240, 184)'); await expect(manualRefresh).toHaveAttribute('aria-busy', 'false'); await expect - .poll(() => page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())) + .poll(() => + page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime()) + ) .toBe(true); const refreshGeometry = await page.locator('.bottom-refresh-controls').evaluate((controls) => { @@ -1580,7 +1609,9 @@ test('mobile single document refreshes once and preserves tokens on lobby return await expect(disabledAutoRefresh).toHaveAttribute('aria-pressed', 'false'); await expect(disabledAutoRefresh.locator('strong')).toHaveCSS('color', 'rgb(187, 187, 187)'); await expect - .poll(() => page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())) + .poll(() => + page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime()) + ) .toBe(false); await persistArtifact(page, `${basePath.slice(1)}-mobile-auto-refresh-controls-off`); @@ -1595,7 +1626,9 @@ test('mobile single document refreshes once and preserves tokens on lobby return await expect(page.getByRole('button', { name: '자동 갱신 ON' })).toHaveAttribute('aria-pressed', 'true'); await expect.poll(() => state.generalMeCalls).toBeGreaterThan(callsBeforeEnable); await expect - .poll(() => page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())) + .poll(() => + page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime()) + ) .toBe(true); await persistArtifact(page, `${basePath.slice(1)}-mobile-auto-refresh-controls`); diff --git a/app/game-frontend/src/components/main/GeneralBasicCard.vue b/app/game-frontend/src/components/main/GeneralBasicCard.vue index 9d819272..cc0aa0e5 100644 --- a/app/game-frontend/src/components/main/GeneralBasicCard.vue +++ b/app/game-frontend/src/components/main/GeneralBasicCard.vue @@ -3,7 +3,7 @@ import { computed } from 'vue'; import SkeletonLines from '../ui/SkeletonLines.vue'; import LegacyProgressBar from '../ui/LegacyProgressBar.vue'; -import { formatSeoulHourMinute } from '../../utils/legacyDateTime'; +import { formatSeoulTimeSeconds } from '../../utils/legacyDateTime'; import { legacyExperiencePercent, ratioPercent } from '../../utils/legacyProgress'; import { DEFAULT_GENERAL_ICON_URL, resolveGeneralIconBackgroundImage } from '../../utils/generalIcon'; import { configuredGameAssetUrl } from '../../utils/imageAssets'; @@ -29,6 +29,18 @@ interface ItemDisplayNames { item?: string | null; } +interface GeneralTroopDisplay { + name: string; + status: 'inactive' | 'present' | 'away'; + leaderCityName?: string | null; +} + +interface GeneralRefreshScore { + current: number; + total: number; + text: string; +} + interface GeneralInfo { id: number; name: string; @@ -37,6 +49,9 @@ interface GeneralInfo { npcState: number; officerLevel: number; officerLevelText: string; + officerCityName?: string | null; + generalType?: string; + leadershipBonus?: number; stats: GeneralStats; gold: number; rice: number; @@ -47,8 +62,14 @@ interface GeneralInfo { experience: number; dedication: number; age?: number; + retirementYear?: number; turnTime?: string | null; + defenceTrain?: number; + killTurn?: number; + remainingMinutes?: number | null; troopId?: number; + troop?: GeneralTroopDisplay | null; + refreshScore?: GeneralRefreshScore; crewTypeId?: number; crewTypeName?: string; traits?: { personal: string; specialWar: string; specialDomestic: string }; @@ -87,14 +108,22 @@ const statRows = computed(() => { { key: 'leadership', label: '통솔', - value: general.stats.leadership, + value: Math.round((general.stats.leadership * (100 - general.injury)) / 100), + bonus: general.leadershipBonus ?? 0, accumulated: accumulated?.leadership ?? 0, }, - { key: 'strength', label: '무력', value: general.stats.strength, accumulated: accumulated?.strength ?? 0 }, + { + key: 'strength', + label: '무력', + value: Math.round((general.stats.strength * (100 - general.injury)) / 100), + bonus: 0, + accumulated: accumulated?.strength ?? 0, + }, { key: 'intelligence', label: '지력', - value: general.stats.intelligence, + value: Math.round((general.stats.intelligence * (100 - general.injury)) / 100), + bonus: 0, accumulated: accumulated?.intelligence ?? 0, }, ].map((entry) => ({ ...entry, limit, percent: ratioPercent(entry.accumulated, limit) })); @@ -119,9 +148,9 @@ const crewTypeIconBackground = computed(() => { const injuryInfo = computed(() => { const injury = props.general?.injury ?? 0; - if (injury > 60) return { text: '위독', color: '#ff4d4f' }; + if (injury > 60) return { text: '위독', color: '#ff0000' }; if (injury > 40) return { text: '심각', color: '#ff00ff' }; - if (injury > 20) return { text: '중상', color: '#ff9f1a' }; + if (injury > 20) return { text: '중상', color: '#ffa500' }; if (injury > 0) return { text: '경상', color: '#ffff00' }; return { text: '건강', color: '#ffffff' }; }); @@ -143,20 +172,40 @@ const titleStyle = computed(() => { }); const ageColor = computed(() => { - const age = props.general?.age; - if (age === undefined) return '#ffffff'; - if (age < 53) return '#32cd32'; - if (age < 70) return '#ffff00'; - return '#ff4d4f'; + const general = props.general; + const age = general?.age; + if (!general || age === undefined) return '#ffffff'; + const retirementYear = general.retirementYear ?? 70; + if (age < retirementYear * 0.75) return '#32cd32'; + if (age < retirementYear) return '#ffff00'; + return '#ff0000'; }); -const displayTroop = computed(() => props.troopText ?? (props.general?.troopId ? String(props.general.troopId) : '-')); -const displayPenalty = computed(() => { - const penalty = props.penaltyText ?? '-'; - const dedication = props.general?.progression?.dedicationText ?? '무품관'; - return `${penalty} · 계급 ${dedication}`; +const displayTroop = computed(() => { + if (props.general?.troop) return props.general.troop; + if (props.troopText && props.troopText !== '-') { + return { name: props.troopText, status: 'present' }; + } + return null; }); -const displayDefence = computed(() => props.defenceText ?? '-'); +const displayPenalty = computed(() => { + const refreshScore = props.general?.refreshScore; + if (refreshScore) { + return `${refreshScore.text} ${refreshScore.total.toLocaleString('ko-KR')}점(${refreshScore.current})`; + } + const penalty = props.penaltyText ?? '-'; + return String(penalty); +}); +const displayDefence = computed(() => { + if (props.general?.defenceTrain !== undefined) { + return props.general.defenceTrain === 999 + ? { text: '수비 안함', active: false } + : { text: `수비 함(훈사${props.general.defenceTrain})`, active: true }; + } + return { text: props.defenceText ?? '-', active: null }; +}); +const displayKillTurn = computed(() => props.general?.killTurn ?? props.killTurn); +const displayRemainingMinutes = computed(() => props.general?.remainingMinutes ?? props.remainingMinutes); const specialText = computed(() => { const traits = props.general?.traits; return traits ? `${traits.specialDomestic || '-'} / ${traits.specialWar || '-'}` : '-'; @@ -178,15 +227,20 @@ const specialText = computed(() => { :style="{ backgroundImage: generalIconBackground }" />
- {{ props.general.name }} 【 {{ props.general.officerLevelText }} | - {{ injuryInfo.text }} 】 다음 턴 - {{ props.general.turnTime ? formatSeoulHourMinute(props.general.turnTime) : '-' }} + {{ props.general.name }} 【 + + {{ props.general.officerLevelText }} | {{ props.general.generalType ?? '-' }} | + {{ injuryInfo.text }} 】 + {{ + props.general.turnTime ? formatSeoulTimeSeconds(props.general.turnTime) : '-' + }}