fix: Ref 장수 기본 카드 표시를 보완
This commit is contained in:
@@ -42,6 +42,12 @@ import {
|
|||||||
resolveMainNationTech,
|
resolveMainNationTech,
|
||||||
splitNationTraitInfo,
|
splitNationTraitInfo,
|
||||||
} from '../../services/mainNationProjection.js';
|
} from '../../services/mainNationProjection.js';
|
||||||
|
import {
|
||||||
|
resolveGeneralTypeCall,
|
||||||
|
resolveLeadershipBonus,
|
||||||
|
resolveRefreshScoreText,
|
||||||
|
resolveRemainingMinutes,
|
||||||
|
} from '../../services/generalBasicCardProjection.js';
|
||||||
|
|
||||||
const zGeneralSettings = z.object({
|
const zGeneralSettings = z.object({
|
||||||
tnmt: z.number().int().optional(),
|
tnmt: z.number().int().optional(),
|
||||||
@@ -268,63 +274,91 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [city, queriedNation, worldState] = await Promise.all([
|
const metaRecord = asRecord(general.meta);
|
||||||
general.cityId > 0
|
const officerCityId = readNumber(metaRecord.officerCity ?? metaRecord.officer_city ?? metaRecord.officerCityId, 0);
|
||||||
? ctx.db.city.findUnique({
|
const [city, queriedNation, worldState, officerCity, troop, troopLeader, troopLeaderFirstTurn, accessLog] =
|
||||||
where: { id: general.cityId },
|
await Promise.all([
|
||||||
select: {
|
general.cityId > 0
|
||||||
id: true,
|
? ctx.db.city.findUnique({
|
||||||
name: true,
|
where: { id: general.cityId },
|
||||||
level: true,
|
select: {
|
||||||
nationId: true,
|
id: true,
|
||||||
population: true,
|
name: true,
|
||||||
populationMax: true,
|
level: true,
|
||||||
agriculture: true,
|
nationId: true,
|
||||||
agricultureMax: true,
|
population: true,
|
||||||
commerce: true,
|
populationMax: true,
|
||||||
commerceMax: true,
|
agriculture: true,
|
||||||
security: true,
|
agricultureMax: true,
|
||||||
securityMax: true,
|
commerce: true,
|
||||||
trust: true,
|
commerceMax: true,
|
||||||
trade: true,
|
security: true,
|
||||||
defence: true,
|
securityMax: true,
|
||||||
defenceMax: true,
|
trust: true,
|
||||||
wall: true,
|
trade: true,
|
||||||
wallMax: true,
|
defence: true,
|
||||||
region: true,
|
defenceMax: true,
|
||||||
supplyState: true,
|
wall: true,
|
||||||
frontState: true,
|
wallMax: true,
|
||||||
},
|
region: true,
|
||||||
})
|
supplyState: true,
|
||||||
: null,
|
frontState: true,
|
||||||
general.nationId > 0
|
},
|
||||||
? ctx.db.nation.findUnique({
|
})
|
||||||
where: { id: general.nationId },
|
: null,
|
||||||
select: {
|
general.nationId > 0
|
||||||
id: true,
|
? ctx.db.nation.findUnique({
|
||||||
name: true,
|
where: { id: general.nationId },
|
||||||
color: true,
|
select: {
|
||||||
level: true,
|
id: true,
|
||||||
gold: true,
|
name: true,
|
||||||
rice: true,
|
color: true,
|
||||||
tech: true,
|
level: true,
|
||||||
typeCode: true,
|
gold: true,
|
||||||
capitalCityId: true,
|
rice: true,
|
||||||
meta: true,
|
tech: true,
|
||||||
},
|
typeCode: true,
|
||||||
})
|
capitalCityId: true,
|
||||||
: Promise.resolve(NEUTRAL_NATION_CONTEXT),
|
meta: true,
|
||||||
ctx.db.worldState.findFirst({ select: { currentYear: true, currentMonth: true, config: 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 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
|
nation.capitalCityId
|
||||||
? ctx.db.city.findUnique({ where: { id: nation.capitalCityId }, select: { name: true } })
|
? ctx.db.city.findUnique({ where: { id: nation.capitalCityId }, select: { name: true } })
|
||||||
: Promise.resolve(null),
|
: Promise.resolve(null),
|
||||||
city && city.nationId > 0
|
city && city.nationId > 0
|
||||||
? ctx.db.nation.findUnique({ where: { id: city.nationId }, select: { name: true } })
|
? ctx.db.nation.findUnique({ where: { id: city.nationId }, select: { name: true } })
|
||||||
: Promise.resolve(null),
|
: Promise.resolve(null),
|
||||||
|
troopLeader && troopLeader.cityId > 0
|
||||||
|
? ctx.db.city.findUnique({ where: { id: troopLeader.cityId }, select: { name: true } })
|
||||||
|
: Promise.resolve(null),
|
||||||
nation.id > 0
|
nation.id > 0
|
||||||
? ctx.db.city.aggregate({
|
? ctx.db.city.aggregate({
|
||||||
where: { nationId: nation.id },
|
where: { nationId: nation.id },
|
||||||
@@ -356,9 +390,12 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
|||||||
loadItemDisplayNames([general.horseCode, general.weaponCode, general.bookCode, general.itemCode]),
|
loadItemDisplayNames([general.horseCode, general.weaponCode, general.bookCode, general.itemCode]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const metaRecord = asRecord(general.meta);
|
|
||||||
const worldConfig = asRecord(worldState?.config);
|
const worldConfig = asRecord(worldState?.config);
|
||||||
const constValues = asRecord(worldConfig.const ?? worldConfig.consts);
|
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 maxDedicationLevel = readNumber(constValues.maxDedLevel, 30);
|
||||||
const settings = resolveUserSettings(metaRecord);
|
const settings = resolveUserSettings(metaRecord);
|
||||||
const penalties = resolvePenalty(general.penalty);
|
const penalties = resolvePenalty(general.penalty);
|
||||||
@@ -379,6 +416,27 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
|||||||
const normalized = normalizeItemCode(code);
|
const normalized = normalizeItemCode(code);
|
||||||
return normalized ? (itemNames.get(normalized) ?? sanitizeInternalDisplayCode(normalized)) : null;
|
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 {
|
return {
|
||||||
general: {
|
general: {
|
||||||
@@ -392,11 +450,11 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
|||||||
imageServer: general.imageServer,
|
imageServer: general.imageServer,
|
||||||
officerLevel: general.officerLevel,
|
officerLevel: general.officerLevel,
|
||||||
officerLevelText: resolveOfficerLevelName(general.officerLevel, nation.level),
|
officerLevelText: resolveOfficerLevelName(general.officerLevel, nation.level),
|
||||||
stats: {
|
officerCityName:
|
||||||
leadership: general.leadership,
|
general.officerLevel >= 2 && general.officerLevel <= 4 ? (officerCity?.name ?? null) : null,
|
||||||
strength: general.strength,
|
generalType: resolveGeneralTypeCall(stats, chiefStatMin, statGradeLevel),
|
||||||
intelligence: general.intel,
|
leadershipBonus: resolveLeadershipBonus(general.officerLevel, nation.level),
|
||||||
},
|
stats,
|
||||||
gold: general.gold,
|
gold: general.gold,
|
||||||
rice: general.rice,
|
rice: general.rice,
|
||||||
crew: general.crew,
|
crew: general.crew,
|
||||||
@@ -406,7 +464,15 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
|||||||
experience: general.experience,
|
experience: general.experience,
|
||||||
dedication: general.dedication,
|
dedication: general.dedication,
|
||||||
age: general.age,
|
age: general.age,
|
||||||
|
retirementYear,
|
||||||
turnTime: general.turnTime.toISOString(),
|
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,
|
crewTypeId: general.crewTypeId,
|
||||||
crewTypeName: crewTypeNames.get(general.crewTypeId) ?? '-',
|
crewTypeName: crewTypeNames.get(general.crewTypeId) ?? '-',
|
||||||
traits: {
|
traits: {
|
||||||
@@ -438,6 +504,18 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
|||||||
book: itemName(general.bookCode),
|
book: itemName(general.bookCode),
|
||||||
item: itemName(general.itemCode),
|
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 ?? []),
|
iconChoices: ctx.auth?.user.canUseGeneralPicture === false ? [] : (ctx.auth?.user.icons ?? []),
|
||||||
canChangeIcon: general.npcState === 0 && ctx.auth?.user.canUseGeneralPicture !== false,
|
canChangeIcon: general.npcState === 0 && ctx.auth?.user.canUseGeneralPicture !== false,
|
||||||
|
|||||||
@@ -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)));
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -81,6 +81,10 @@ const createContext = (options: {
|
|||||||
requestCommand?: ReturnType<typeof vi.fn>;
|
requestCommand?: ReturnType<typeof vi.fn>;
|
||||||
accessToken?: string;
|
accessToken?: string;
|
||||||
logs?: Array<{ id: number; text: string; year?: number; month?: number; createdAt?: Date }>;
|
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 me = options.me === undefined ? buildGeneral() : options.me;
|
||||||
const targets = options.targets ?? (me ? [me] : []);
|
const targets = options.targets ?? (me ? [me] : []);
|
||||||
@@ -94,9 +98,43 @@ const createContext = (options: {
|
|||||||
findFirst: vi.fn(async () => me),
|
findFirst: vi.fn(async () => me),
|
||||||
findUnique: generalFindUnique,
|
findUnique: generalFindUnique,
|
||||||
findMany: vi.fn(async () => targets.filter((general) => general.nationId === (me?.nationId ?? 0))),
|
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(),
|
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: {
|
nation: {
|
||||||
findUnique: vi.fn(async () => ({
|
findUnique: vi.fn(async () => ({
|
||||||
id: 1,
|
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 () => {
|
it('returns the Ref-style neutral nation frame and trait display names on the main read model', async () => {
|
||||||
const fixture = createContext({
|
const fixture = createContext({
|
||||||
me: buildGeneral({
|
me: buildGeneral({
|
||||||
|
|||||||
@@ -201,9 +201,13 @@ const generalContext = (state: NavigationFixture) => ({
|
|||||||
name: state.generalName ?? '메뉴검증장수',
|
name: state.generalName ?? '메뉴검증장수',
|
||||||
nationId: 1,
|
nationId: 1,
|
||||||
cityId: 1,
|
cityId: 1,
|
||||||
troopId: 0,
|
troopId: 7,
|
||||||
npcState: 0,
|
npcState: 0,
|
||||||
officerLevel: state.officerLevel,
|
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,
|
picture: null,
|
||||||
imageServer: 0,
|
imageServer: 0,
|
||||||
stats: { leadership: 70, strength: 60, intelligence: 50 },
|
stats: { leadership: 70, strength: 60, intelligence: 50 },
|
||||||
@@ -215,6 +219,13 @@ const generalContext = (state: NavigationFixture) => ({
|
|||||||
injury: 0,
|
injury: 0,
|
||||||
experience: 100,
|
experience: 100,
|
||||||
dedication: 200,
|
dedication: 200,
|
||||||
|
age: 25,
|
||||||
|
retirementYear: 70,
|
||||||
|
defenceTrain: 80,
|
||||||
|
killTurn: 5,
|
||||||
|
remainingMinutes: 7,
|
||||||
|
troop: { name: '백마대', status: 'present', leaderCityName: '업' },
|
||||||
|
refreshScore: { current: 3, total: 120, text: '보통' },
|
||||||
progression: {
|
progression: {
|
||||||
experienceLevel: 1,
|
experienceLevel: 1,
|
||||||
dedicationLevel: 2,
|
dedicationLevel: 2,
|
||||||
@@ -223,6 +234,10 @@ const generalContext = (state: NavigationFixture) => ({
|
|||||||
dex: [350, 100_000, 500_000, 1_000_000, 1_275_975],
|
dex: [350, 100_000, 500_000, 1_000_000, 1_275_975],
|
||||||
},
|
},
|
||||||
items: { horse: null, weapon: null, book: null, item: null },
|
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',
|
turnTime: state.generalTurnTime ?? '0185-01-01T00:00:00.000Z',
|
||||||
},
|
},
|
||||||
city: {
|
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`);
|
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 = {
|
const state: NavigationFixture = {
|
||||||
officerLevel: 0,
|
officerLevel: 0,
|
||||||
permission: 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();
|
const title = page.locator('[data-main-target="general"] .general-title').first();
|
||||||
await expect(title).toContainText('Administrator');
|
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');
|
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 desktopGeometry = await title.evaluate((element) => {
|
||||||
const rect = element.getBoundingClientRect();
|
const rect = element.getBoundingClientRect();
|
||||||
@@ -768,6 +790,8 @@ test('main general card renders the next turn in the Seoul server timezone', asy
|
|||||||
return {
|
return {
|
||||||
width: rect.width,
|
width: rect.width,
|
||||||
height: rect.height,
|
height: rect.height,
|
||||||
|
scrollWidth: element.scrollWidth,
|
||||||
|
clientWidth: element.clientWidth,
|
||||||
fontSize: style.fontSize,
|
fontSize: style.fontSize,
|
||||||
lineHeight: style.lineHeight,
|
lineHeight: style.lineHeight,
|
||||||
overflow: style.overflow,
|
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.width).toBeGreaterThan(0);
|
||||||
expect(desktopGeometry.height).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) {
|
if (artifactRoot) {
|
||||||
const target = resolve(artifactRoot);
|
const target = resolve(artifactRoot);
|
||||||
await mkdir(target, { recursive: true });
|
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 });
|
await page.setViewportSize({ width: 500, height: 900 });
|
||||||
const mobileTitle = page.locator('[data-main-target="general"] .general-title').first();
|
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 mobileTitle.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(0);
|
||||||
|
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(0);
|
||||||
if (artifactRoot) {
|
if (artifactRoot) {
|
||||||
await page.screenshot({
|
await page.screenshot({
|
||||||
path: resolve(artifactRoot, 'main-turn-time-seoul-mobile-500.png'),
|
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(autoRefresh.locator('strong')).toHaveCSS('color', 'rgb(158, 240, 184)');
|
||||||
await expect(manualRefresh).toHaveAttribute('aria-busy', 'false');
|
await expect(manualRefresh).toHaveAttribute('aria-busy', 'false');
|
||||||
await expect
|
await expect
|
||||||
.poll(() => page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime()))
|
.poll(() =>
|
||||||
|
page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())
|
||||||
|
)
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
|
|
||||||
const refreshGeometry = await page.locator('.bottom-refresh-controls').evaluate((controls) => {
|
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).toHaveAttribute('aria-pressed', 'false');
|
||||||
await expect(disabledAutoRefresh.locator('strong')).toHaveCSS('color', 'rgb(187, 187, 187)');
|
await expect(disabledAutoRefresh.locator('strong')).toHaveCSS('color', 'rgb(187, 187, 187)');
|
||||||
await expect
|
await expect
|
||||||
.poll(() => page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime()))
|
.poll(() =>
|
||||||
|
page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())
|
||||||
|
)
|
||||||
.toBe(false);
|
.toBe(false);
|
||||||
await persistArtifact(page, `${basePath.slice(1)}-mobile-auto-refresh-controls-off`);
|
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(page.getByRole('button', { name: '자동 갱신 ON' })).toHaveAttribute('aria-pressed', 'true');
|
||||||
await expect.poll(() => state.generalMeCalls).toBeGreaterThan(callsBeforeEnable);
|
await expect.poll(() => state.generalMeCalls).toBeGreaterThan(callsBeforeEnable);
|
||||||
await expect
|
await expect
|
||||||
.poll(() => page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime()))
|
.poll(() =>
|
||||||
|
page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())
|
||||||
|
)
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
|
|
||||||
await persistArtifact(page, `${basePath.slice(1)}-mobile-auto-refresh-controls`);
|
await persistArtifact(page, `${basePath.slice(1)}-mobile-auto-refresh-controls`);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { computed } from 'vue';
|
|||||||
|
|
||||||
import SkeletonLines from '../ui/SkeletonLines.vue';
|
import SkeletonLines from '../ui/SkeletonLines.vue';
|
||||||
import LegacyProgressBar from '../ui/LegacyProgressBar.vue';
|
import LegacyProgressBar from '../ui/LegacyProgressBar.vue';
|
||||||
import { formatSeoulHourMinute } from '../../utils/legacyDateTime';
|
import { formatSeoulTimeSeconds } from '../../utils/legacyDateTime';
|
||||||
import { legacyExperiencePercent, ratioPercent } from '../../utils/legacyProgress';
|
import { legacyExperiencePercent, ratioPercent } from '../../utils/legacyProgress';
|
||||||
import { DEFAULT_GENERAL_ICON_URL, resolveGeneralIconBackgroundImage } from '../../utils/generalIcon';
|
import { DEFAULT_GENERAL_ICON_URL, resolveGeneralIconBackgroundImage } from '../../utils/generalIcon';
|
||||||
import { configuredGameAssetUrl } from '../../utils/imageAssets';
|
import { configuredGameAssetUrl } from '../../utils/imageAssets';
|
||||||
@@ -29,6 +29,18 @@ interface ItemDisplayNames {
|
|||||||
item?: string | null;
|
item?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GeneralTroopDisplay {
|
||||||
|
name: string;
|
||||||
|
status: 'inactive' | 'present' | 'away';
|
||||||
|
leaderCityName?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GeneralRefreshScore {
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface GeneralInfo {
|
interface GeneralInfo {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -37,6 +49,9 @@ interface GeneralInfo {
|
|||||||
npcState: number;
|
npcState: number;
|
||||||
officerLevel: number;
|
officerLevel: number;
|
||||||
officerLevelText: string;
|
officerLevelText: string;
|
||||||
|
officerCityName?: string | null;
|
||||||
|
generalType?: string;
|
||||||
|
leadershipBonus?: number;
|
||||||
stats: GeneralStats;
|
stats: GeneralStats;
|
||||||
gold: number;
|
gold: number;
|
||||||
rice: number;
|
rice: number;
|
||||||
@@ -47,8 +62,14 @@ interface GeneralInfo {
|
|||||||
experience: number;
|
experience: number;
|
||||||
dedication: number;
|
dedication: number;
|
||||||
age?: number;
|
age?: number;
|
||||||
|
retirementYear?: number;
|
||||||
turnTime?: string | null;
|
turnTime?: string | null;
|
||||||
|
defenceTrain?: number;
|
||||||
|
killTurn?: number;
|
||||||
|
remainingMinutes?: number | null;
|
||||||
troopId?: number;
|
troopId?: number;
|
||||||
|
troop?: GeneralTroopDisplay | null;
|
||||||
|
refreshScore?: GeneralRefreshScore;
|
||||||
crewTypeId?: number;
|
crewTypeId?: number;
|
||||||
crewTypeName?: string;
|
crewTypeName?: string;
|
||||||
traits?: { personal: string; specialWar: string; specialDomestic: string };
|
traits?: { personal: string; specialWar: string; specialDomestic: string };
|
||||||
@@ -87,14 +108,22 @@ const statRows = computed(() => {
|
|||||||
{
|
{
|
||||||
key: 'leadership',
|
key: 'leadership',
|
||||||
label: '통솔',
|
label: '통솔',
|
||||||
value: general.stats.leadership,
|
value: Math.round((general.stats.leadership * (100 - general.injury)) / 100),
|
||||||
|
bonus: general.leadershipBonus ?? 0,
|
||||||
accumulated: accumulated?.leadership ?? 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',
|
key: 'intelligence',
|
||||||
label: '지력',
|
label: '지력',
|
||||||
value: general.stats.intelligence,
|
value: Math.round((general.stats.intelligence * (100 - general.injury)) / 100),
|
||||||
|
bonus: 0,
|
||||||
accumulated: accumulated?.intelligence ?? 0,
|
accumulated: accumulated?.intelligence ?? 0,
|
||||||
},
|
},
|
||||||
].map((entry) => ({ ...entry, limit, percent: ratioPercent(entry.accumulated, limit) }));
|
].map((entry) => ({ ...entry, limit, percent: ratioPercent(entry.accumulated, limit) }));
|
||||||
@@ -119,9 +148,9 @@ const crewTypeIconBackground = computed(() => {
|
|||||||
|
|
||||||
const injuryInfo = computed(() => {
|
const injuryInfo = computed(() => {
|
||||||
const injury = props.general?.injury ?? 0;
|
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 > 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' };
|
if (injury > 0) return { text: '경상', color: '#ffff00' };
|
||||||
return { text: '건강', color: '#ffffff' };
|
return { text: '건강', color: '#ffffff' };
|
||||||
});
|
});
|
||||||
@@ -143,20 +172,40 @@ const titleStyle = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const ageColor = computed(() => {
|
const ageColor = computed(() => {
|
||||||
const age = props.general?.age;
|
const general = props.general;
|
||||||
if (age === undefined) return '#ffffff';
|
const age = general?.age;
|
||||||
if (age < 53) return '#32cd32';
|
if (!general || age === undefined) return '#ffffff';
|
||||||
if (age < 70) return '#ffff00';
|
const retirementYear = general.retirementYear ?? 70;
|
||||||
return '#ff4d4f';
|
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 displayTroop = computed<GeneralTroopDisplay | null>(() => {
|
||||||
const displayPenalty = computed(() => {
|
if (props.general?.troop) return props.general.troop;
|
||||||
const penalty = props.penaltyText ?? '-';
|
if (props.troopText && props.troopText !== '-') {
|
||||||
const dedication = props.general?.progression?.dedicationText ?? '무품관';
|
return { name: props.troopText, status: 'present' };
|
||||||
return `${penalty} · 계급 ${dedication}`;
|
}
|
||||||
|
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 specialText = computed(() => {
|
||||||
const traits = props.general?.traits;
|
const traits = props.general?.traits;
|
||||||
return traits ? `${traits.specialDomestic || '-'} / ${traits.specialWar || '-'}` : '-';
|
return traits ? `${traits.specialDomestic || '-'} / ${traits.specialWar || '-'}` : '-';
|
||||||
@@ -178,15 +227,20 @@ const specialText = computed(() => {
|
|||||||
:style="{ backgroundImage: generalIconBackground }"
|
:style="{ backgroundImage: generalIconBackground }"
|
||||||
/>
|
/>
|
||||||
<div class="general-title battle-general-name" :style="titleStyle">
|
<div class="general-title battle-general-name" :style="titleStyle">
|
||||||
{{ props.general.name }} 【 {{ props.general.officerLevelText }} |
|
{{ props.general.name }} 【
|
||||||
<span :style="{ color: injuryInfo.color }">{{ injuryInfo.text }}</span> 】 다음 턴
|
<template v-if="props.general.officerCityName">{{ props.general.officerCityName }} </template>
|
||||||
{{ props.general.turnTime ? formatSeoulHourMinute(props.general.turnTime) : '-' }}
|
{{ props.general.officerLevelText }} | {{ props.general.generalType ?? '-' }} |
|
||||||
|
<span :style="{ color: injuryInfo.color }">{{ injuryInfo.text }}</span> 】
|
||||||
|
<span data-general-turn-time>{{
|
||||||
|
props.general.turnTime ? formatSeoulTimeSeconds(props.general.turnTime) : '-'
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-for="stat of statRows" :key="stat.key">
|
<template v-for="stat of statRows" :key="stat.key">
|
||||||
<span class="cell-label">{{ stat.label }}</span>
|
<span class="cell-label">{{ stat.label }}</span>
|
||||||
<strong class="stat-value">
|
<strong class="stat-value" :style="{ color: injuryInfo.color }">
|
||||||
<span>{{ stat.value }}</span>
|
<span>{{ stat.value }}</span>
|
||||||
|
<span v-if="stat.bonus > 0" class="leadership-bonus">+{{ stat.bonus }}</span>
|
||||||
<span class="bar-cell" :data-stat-progress="stat.key">
|
<span class="bar-cell" :data-stat-progress="stat.key">
|
||||||
<LegacyProgressBar
|
<LegacyProgressBar
|
||||||
:percent="stat.percent"
|
:percent="stat.percent"
|
||||||
@@ -230,16 +284,32 @@ const specialText = computed(() => {
|
|||||||
<strong class="age-value" :style="{ color: ageColor }">{{ props.general.age ?? '-' }}세</strong>
|
<strong class="age-value" :style="{ color: ageColor }">{{ props.general.age ?? '-' }}세</strong>
|
||||||
|
|
||||||
<span class="cell-label defence-label">수비</span>
|
<span class="cell-label defence-label">수비</span>
|
||||||
<strong class="defence-value">{{ displayDefence }}</strong>
|
<strong
|
||||||
|
class="defence-value"
|
||||||
|
:class="{
|
||||||
|
'defence-value--active': displayDefence.active === true,
|
||||||
|
'defence-value--inactive': displayDefence.active === false,
|
||||||
|
}"
|
||||||
|
>{{ displayDefence.text }}</strong
|
||||||
|
>
|
||||||
<span class="cell-label kill-label">삭턴</span>
|
<span class="cell-label kill-label">삭턴</span>
|
||||||
<strong class="kill-value">{{ props.killTurn === null ? '-' : `${props.killTurn} 턴` }}</strong>
|
<strong class="kill-value">{{ displayKillTurn === null ? '-' : `${displayKillTurn} 턴` }}</strong>
|
||||||
<span class="cell-label execute-label">실행</span>
|
<span class="cell-label execute-label">실행</span>
|
||||||
<strong class="execute-value">{{
|
<strong class="execute-value">{{
|
||||||
props.remainingMinutes === null ? '-' : `${props.remainingMinutes}분 남음`
|
displayRemainingMinutes === null ? '-' : `${displayRemainingMinutes}분 남음`
|
||||||
}}</strong>
|
}}</strong>
|
||||||
|
|
||||||
<span class="cell-label troop-label">부대</span>
|
<span class="cell-label troop-label">부대</span>
|
||||||
<strong class="troop-value">{{ displayTroop }}</strong>
|
<strong class="troop-value">
|
||||||
|
<s v-if="displayTroop?.status === 'inactive'" class="troop-value--inactive">{{
|
||||||
|
displayTroop.name
|
||||||
|
}}</s>
|
||||||
|
<span v-else-if="displayTroop?.status === 'away'" class="troop-value--away">
|
||||||
|
{{ displayTroop.name
|
||||||
|
}}<template v-if="displayTroop.leaderCityName">({{ displayTroop.leaderCityName }})</template>
|
||||||
|
</span>
|
||||||
|
<span v-else>{{ displayTroop?.name ?? '-' }}</span>
|
||||||
|
</strong>
|
||||||
<span class="cell-label penalty-label">벌점</span>
|
<span class="cell-label penalty-label">벌점</span>
|
||||||
<strong class="penalty-value">{{ displayPenalty }}</strong>
|
<strong class="penalty-value">{{ displayPenalty }}</strong>
|
||||||
</div>
|
</div>
|
||||||
@@ -321,11 +391,19 @@ const specialText = computed(() => {
|
|||||||
|
|
||||||
.stat-value {
|
.stat-value {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(22px, auto) minmax(26px, 1fr);
|
grid-template-columns: minmax(22px, auto) auto minmax(26px, 1fr);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.leadership-bonus {
|
||||||
|
color: #00ffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value > .bar-cell {
|
||||||
|
grid-column: 3;
|
||||||
|
}
|
||||||
|
|
||||||
.bar-cell,
|
.bar-cell,
|
||||||
.experience-bar {
|
.experience-bar {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -413,6 +491,22 @@ const specialText = computed(() => {
|
|||||||
grid-row: 9;
|
grid-row: 9;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.defence-value--active {
|
||||||
|
color: #32cd32;
|
||||||
|
}
|
||||||
|
|
||||||
|
.defence-value--inactive {
|
||||||
|
color: #ff0000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.troop-value--inactive {
|
||||||
|
color: #808080;
|
||||||
|
}
|
||||||
|
|
||||||
|
.troop-value--away {
|
||||||
|
color: #ffa500;
|
||||||
|
}
|
||||||
|
|
||||||
.general-loading,
|
.general-loading,
|
||||||
.empty {
|
.empty {
|
||||||
min-height: 192px;
|
min-height: 192px;
|
||||||
|
|||||||
@@ -4,3 +4,6 @@ export const formatSeoulDateTime = (value: string | Date): string => formatServe
|
|||||||
|
|
||||||
export const formatSeoulHourMinute = (value: string | Date): string =>
|
export const formatSeoulHourMinute = (value: string | Date): string =>
|
||||||
formatServerDateTime(value, { format: 'hourMinute' });
|
formatServerDateTime(value, { format: 'hourMinute' });
|
||||||
|
|
||||||
|
export const formatSeoulTimeSeconds = (value: string | Date): string =>
|
||||||
|
formatServerDateTime(value, { format: 'timeSeconds' });
|
||||||
|
|||||||
@@ -234,12 +234,7 @@ watch(
|
|||||||
<NationBasicCard :nation="nation" :loading="loading" />
|
<NationBasicCard :nation="nation" :loading="loading" />
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
<PanelCard title="장수 스탯" data-main-target="general">
|
<PanelCard title="장수 스탯" data-main-target="general">
|
||||||
<GeneralBasicCard
|
<GeneralBasicCard :general="general" :loading="loading" :nation-color="nation?.color" />
|
||||||
:general="general"
|
|
||||||
:loading="loading"
|
|
||||||
:nation-color="nation?.color"
|
|
||||||
:troop-text="general?.troopId ? String(general.troopId) : '-'"
|
|
||||||
/>
|
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
<PanelCard title="도시 정보" data-main-target="city">
|
<PanelCard title="도시 정보" data-main-target="city">
|
||||||
<CityBasicCard :city="city" :loading="loading" />
|
<CityBasicCard :city="city" :loading="loading" />
|
||||||
@@ -356,12 +351,7 @@ watch(
|
|||||||
<NationBasicCard :nation="nation" :loading="loading" />
|
<NationBasicCard :nation="nation" :loading="loading" />
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
<PanelCard title="장수 스탯" data-main-target="general">
|
<PanelCard title="장수 스탯" data-main-target="general">
|
||||||
<GeneralBasicCard
|
<GeneralBasicCard :general="general" :loading="loading" :nation-color="nation?.color" />
|
||||||
:general="general"
|
|
||||||
:loading="loading"
|
|
||||||
:nation-color="nation?.color"
|
|
||||||
:troop-text="general?.troopId ? String(general.troopId) : '-'"
|
|
||||||
/>
|
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
<MainNationMenu
|
<MainNationMenu
|
||||||
class="nation-menu-middle"
|
class="nation-menu-middle"
|
||||||
|
|||||||
@@ -382,7 +382,6 @@ onMounted(() => {
|
|||||||
:loading="loading"
|
:loading="loading"
|
||||||
:nation-color="data?.nation?.color"
|
:nation-color="data?.nation?.color"
|
||||||
:defence-text="form.defence_train === 999 ? '수비 안함' : `수비 함(훈사${form.defence_train})`"
|
:defence-text="form.defence_train === 999 ? '수비 안함' : `수비 함(훈사${form.defence_train})`"
|
||||||
:troop-text="data?.general.troopId ? String(data.general.troopId) : '-'"
|
|
||||||
:penalty-text="penalties.length || '-'"
|
:penalty-text="penalties.length || '-'"
|
||||||
>
|
>
|
||||||
<template v-if="data" #details>
|
<template v-if="data" #details>
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
import { formatSeoulDateTime, formatSeoulHourMinute } from '../src/utils/legacyDateTime.ts';
|
import { formatSeoulDateTime, formatSeoulHourMinute, formatSeoulTimeSeconds } from '../src/utils/legacyDateTime.ts';
|
||||||
|
|
||||||
void test('formats API UTC timestamps in the server Seoul timezone', () => {
|
void test('formats API UTC timestamps in the server Seoul timezone', () => {
|
||||||
assert.equal(formatSeoulDateTime('2026-08-13T00:07:06.713Z'), '2026-08-13 09:07:06');
|
assert.equal(formatSeoulDateTime('2026-08-13T00:07:06.713Z'), '2026-08-13 09:07:06');
|
||||||
assert.equal(formatSeoulHourMinute('2026-08-13T00:07:06.713Z'), '09:07');
|
assert.equal(formatSeoulHourMinute('2026-08-13T00:07:06.713Z'), '09:07');
|
||||||
|
assert.equal(formatSeoulTimeSeconds('2026-08-13T00:07:06.713Z'), '09:07:06');
|
||||||
});
|
});
|
||||||
|
|
||||||
void test('keeps legacy timezone-less server timestamps unchanged', () => {
|
void test('keeps legacy timezone-less server timestamps unchanged', () => {
|
||||||
assert.equal(formatSeoulDateTime('2026-08-13 09:07:06'), '2026-08-13 09:07:06');
|
assert.equal(formatSeoulDateTime('2026-08-13 09:07:06'), '2026-08-13 09:07:06');
|
||||||
assert.equal(formatSeoulHourMinute('2026-08-13 09:07:06'), '09:07');
|
assert.equal(formatSeoulHourMinute('2026-08-13 09:07:06'), '09:07');
|
||||||
|
assert.equal(formatSeoulTimeSeconds('2026-08-13 09:07:06'), '09:07:06');
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user