Merge branch 'main' into fix/private-realtime-events-20260812

This commit is contained in:
2026-08-13 00:21:13 +00:00
48 changed files with 944 additions and 283 deletions
+13
View File
@@ -383,9 +383,22 @@ test('four legacy menu pages keep the 1000px desktop table contract', async ({ p
.first()
.evaluate((el) => getComputedStyle(el).borderCollapse)
).toBe(borderCollapse);
if (path === 'nation/info') {
await expect(page.locator(selector)).toContainText('작 위호족');
await expect(page.locator(selector)).not.toContainText('작 위1');
}
}
});
test('국가 정보의 작위는 Ref 국가 등급 이름으로 표시된다', async ({ page }) => {
await install(page);
await page.goto('nation/info');
const root = page.locator('.legacy-info-page');
await expect(root).toContainText('작 위호족');
await expect(root).not.toContainText('작 위1');
});
test('global-info renders the ref nation summary columns beside the map', async ({ page }) => {
await install(page);
await page.setViewportSize({ width: 1200, height: 900 });
+100 -7
View File
@@ -63,7 +63,9 @@ const myGeneral = (state: FixtureState) => ({
troopId: 0,
picture: null,
imageServer: 0,
officerLevel: state.permission === 'head' ? 5 : 1,
officerLevel: state.permission === 'head' ? 9 : 1,
officerLevelText:
state.permission === 'head' ? '간의대부' : state.buildNationCandidateEnabled ? '재야' : '일반',
stats: { leadership: 70, strength: 60, intelligence: 50 },
gold: 1_000,
rice: 2_000,
@@ -76,30 +78,74 @@ const myGeneral = (state: FixtureState) => ({
age: 30,
turnTime: '2026-01-01 00:10:00',
crewTypeId: 1,
crewTypeName: '보병',
traits: state.mainTraits ?? { personal: '-', specialDomestic: '-', specialWar: '-' },
progression: {
experienceLevel: 1,
dedicationLevel: 2,
dedicationText: '29품관',
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 },
itemNames: { horse: '명마', weapon: null, book: null, item: null },
},
city: {
id: 1,
name: '업',
level: 8,
levelName: '특',
region: 2,
regionName: '중원',
nationId: 1,
nationName: '위',
population: 1000,
populationMax: 2000,
agriculture: 100,
agricultureMax: 200,
commerce: 100,
commerceMax: 200,
security: 100,
securityMax: 200,
trust: 70,
trade: 100,
defence: 100,
defenceMax: 200,
wall: 100,
wallMax: 200,
supplyState: 1,
frontState: 0,
},
city: { id: 1, name: '업', level: 8, nationId: 1 },
nation: state.buildNationCandidateEnabled
? {
id: 0,
name: '재야',
color: '#000000',
level: 0,
levelName: '방랑군',
gold: 0,
rice: 0,
tech: 0,
typeCode: 'None',
typeName: '해당 없음',
capitalCityId: null,
capitalCityName: null,
}
: { id: 1, name: '위', color: '#777777', level: 3 },
: {
id: 1,
name: '위',
color: '#777777',
level: 3,
levelName: '주자사',
gold: 10_000,
rice: 20_000,
tech: 100,
typeCode: 'che_법가',
typeName: '법가',
capitalCityId: 1,
capitalCityName: '업',
},
settings: {
tnmt: 0,
defence_train: 80,
@@ -116,7 +162,7 @@ const myGeneral = (state: FixtureState) => ({
const battleCenter = (state: FixtureState) => ({
me: {
id: 7,
officerLevel: state.permission === 'head' ? 5 : 1,
officerLevel: state.permission === 'head' ? 9 : 1,
permissionLevel: state.permission === 'head' ? 2 : 0,
},
nation: { id: 1, name: '위', color: '#777777', level: 3 },
@@ -128,7 +174,8 @@ const battleCenter = (state: FixtureState) => ({
id: 7,
name: '검증장수',
npcState: 0,
officerLevel: state.permission === 'head' ? 5 : 1,
officerLevel: state.permission === 'head' ? 9 : 1,
officerLevelText: state.permission === 'head' ? '간의대부' : '일반',
cityId: 1,
turnTime: '2026-01-01 00:10:00',
recentWar: '2026-01-01 00:00:00',
@@ -144,10 +191,14 @@ const battleCenter = (state: FixtureState) => ({
atmos: 90,
age: 30,
crewTypeId: 1,
crewTypeName: '보병',
equipment: { weapon: 'None', book: 'None', horse: 'None', item: 'None' },
traits: { personal: 'None', specialDomestic: 'None', specialWar: 'None' },
equipmentNames: { weapon: '-', book: '-', horse: '-', item: '-' },
traits: { personal: '-', specialDomestic: '-', specialWar: '-' },
progression: {
experienceLevel: 1,
dedicationLevel: 2,
dedicationText: '29품관',
statExperience: { leadership: 7, strength: 8, intelligence: 9 },
statUpgradeLimit: 20,
dex: [350, 1_375, 3_500, 7_125, 1_275_975],
@@ -159,6 +210,7 @@ const battleCenter = (state: FixtureState) => ({
name: '다른장수',
npcState: 2,
officerLevel: 1,
officerLevelText: '일반',
cityId: 1,
turnTime: '2026-01-01 00:20:00',
recentWar: null,
@@ -174,10 +226,14 @@ const battleCenter = (state: FixtureState) => ({
atmos: 60,
age: 20,
crewTypeId: 1,
crewTypeName: '보병',
equipment: { weapon: 'None', book: 'None', horse: 'None', item: 'None' },
traits: { personal: 'None', specialDomestic: 'None', specialWar: 'None' },
equipmentNames: { weapon: '-', book: '-', horse: '-', item: '-' },
traits: { personal: '-', specialDomestic: '-', specialWar: '-' },
progression: {
experienceLevel: 0,
dedicationLevel: 0,
dedicationText: '무품관',
statExperience: { leadership: 0, strength: 0, intelligence: 0 },
statUpgradeLimit: 20,
dex: [0, 0, 0, 0, 0],
@@ -512,6 +568,35 @@ test('재야 메인은 국가 틀과 성격·특기 표기명을 Chromium에 표
await persistParityArtifact(page, 'main-neutral-trait-display', geometry);
});
test('메인 카드의 국가·수도·관직·계급·병종은 Ref 출력명으로 표시된다', async ({ page }) => {
const state: FixtureState = {
permission: 'head',
myset: 3,
mainTraits: { personal: '안전', specialDomestic: '상재', specialWar: '신산' },
settingMutations: [],
accessPages: [],
};
await install(page, state);
await page.setViewportSize({ width: 1000, height: 900 });
await page.goto('');
const nationCard = page.locator('.nation-card');
await expect(nationCard.locator('.title')).toHaveText('위 (주자사)');
await expect(nationCard).toContainText('체제법가');
await expect(nationCard).toContainText('수도업');
await expect(nationCard).toContainText('국가 등급주자사');
const generalCard = page.locator('.general-card');
await expect(generalCard.locator('.general-title')).toContainText('검증장수 · 간의대부');
await expect(generalCard).toContainText('병종보병');
await expect(generalCard).toContainText('계급29품관');
const cityCard = page.locator('.city-card');
await expect(cityCard.locator('.title')).toContainText('【중원 | 특】 업');
await expect(cityCard.locator('.title')).toContainText('지배 국가 【 위 】');
await expect(page.locator('.main-page')).not.toContainText('che_');
});
test('접속량정보 keeps the legacy public 1016px chart geometry', async ({ page }) => {
const state: FixtureState = { permission: 'member', myset: 0, settingMutations: [], accessPages: [] };
await install(page, state);
@@ -566,6 +651,10 @@ test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in plac
await install(page, state);
await page.setViewportSize({ width: 1000, height: 900 });
await page.goto('my-page');
await expect(page.locator('.legacy-general-details')).toContainText('계급 29품관');
await expect(page.locator('.legacy-general-details')).toContainText('병종 보병');
await expect(page.locator('.item-group')).toContainText('명마');
await expect(page.locator('#container')).not.toContainText('che_');
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);
@@ -1020,6 +1109,10 @@ 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-name')).toContainText('검증장수 (간의대부)');
await expect(page.locator('.battle-general-extra')).toContainText('계급29품관');
await expect(page.locator('.battle-general-extra')).toContainText('병종보병');
await expect(page.locator('.battle-general-card')).not.toContainText('che_');
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(
+62 -1
View File
@@ -20,6 +20,7 @@ type NavigationFixture = {
generalMeCalls: number;
operations: string[];
generalName?: string;
generalTurnTime?: string;
cityDefence?: number;
cityState?: number;
nationRate?: number;
@@ -219,7 +220,7 @@ 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 },
turnTime: '0185-01-01T00:00:00.000Z',
turnTime: state.generalTurnTime ?? '0185-01-01T00:00:00.000Z',
},
city: {
id: 1,
@@ -646,6 +647,66 @@ 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 }) => {
const state: NavigationFixture = {
officerLevel: 0,
permission: 0,
nationLevel: 0,
stage: 0,
npcMode: 1,
generalMeCalls: 0,
operations: [],
generalName: 'Administrator',
generalTurnTime: '2026-08-13T00:07:06.713Z',
currentYear: 179,
currentMonth: 8,
};
await installFixture(page, state);
await page.setViewportSize({ width: 1200, height: 900 });
await waitForMain(page);
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).not.toContainText('00:07');
const desktopGeometry = await title.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
width: rect.width,
height: rect.height,
fontSize: style.fontSize,
lineHeight: style.lineHeight,
overflow: style.overflow,
};
});
expect(desktopGeometry.width).toBeGreaterThan(0);
expect(desktopGeometry.height).toBeGreaterThan(0);
if (artifactRoot) {
const target = resolve(artifactRoot);
await mkdir(target, { recursive: true });
await Promise.all([
page.screenshot({ path: resolve(target, 'main-turn-time-seoul-desktop-1200.png'), fullPage: true }),
writeFile(
resolve(target, 'main-turn-time-seoul-desktop-1200.json'),
`${JSON.stringify(desktopGeometry, null, 2)}\n`
),
]);
}
await page.setViewportSize({ width: 500, height: 900 });
const mobileTitle = page.locator('[data-main-target="general"] .general-title').first();
await expect(mobileTitle).toContainText('다음 턴 09:07');
expect(await mobileTitle.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(0);
if (artifactRoot) {
await page.screenshot({
path: resolve(artifactRoot, 'main-turn-time-seoul-mobile-500.png'),
fullPage: true,
});
}
});
test('pure NPC message senders are not rendered as reply targets', async ({ page }) => {
const target = (generalId: number, generalName: string) => ({
generalId,
+11
View File
@@ -41,6 +41,7 @@ const installArchive = async (page: Page) => {
experience: 23000,
dedication: 1200,
officerLevel: 12,
officerLevelText: '황제',
personal: '대담',
special: '상재',
special2: '신산',
@@ -66,6 +67,15 @@ const installArchive = async (page: Page) => {
});
};
test('지난 플레이 관직은 숫자 대신 저장된 Ref 표시명으로 나타난다', async ({ page }) => {
await installArchive(page);
await page.goto('past-plays');
const generalRow = page.locator('tbody tr').filter({ hasText: '관우' });
await expect(generalRow).toContainText('황제');
await expect(generalRow).not.toContainText('che_');
});
test('past plays is available without a current general and preserves desktop interaction geometry', async ({
page,
}) => {
@@ -78,6 +88,7 @@ test('past plays is available without a current general and preserves desktop in
await expect(page.getByRole('heading', { name: '내 지난 플레이 보기' })).toBeVisible();
await expect(page.getByText('천하쟁패 · 51기')).toBeVisible();
await expect(page.locator('.general-name')).toHaveText('관우');
await expect(page.locator('tbody tr').filter({ hasText: '관우' })).toContainText('황제');
await expect(page.getByRole('link', { name: '이 기수 국가 정보' })).toHaveAttribute('href', gamePath('/dynasty/7'));
const historyToggle = page.locator('.history-toggle');
await expect(historyToggle).toHaveText('보기 (2)');
@@ -230,7 +230,7 @@ test.describe('scenario 903 live selection pool', () => {
expect(geometry.footerBanner.height).toBeCloseTo(20.1875, 3);
await expect(page.locator('.invitation-table tbody tr')).toHaveCount(0);
await expect(page.locator('.footer-banner')).toContainText(
'삼국지 모의전투 HiDCHe core2026'
'삼국지 모의전투 HiDCHe'
);
await expect(page.locator('.footer-banner a')).toHaveText('Credit');
@@ -8,7 +8,10 @@ interface CityInfo {
id: number;
name: string;
level: number;
levelName: string;
regionName: string;
nationId: number;
nationName: string;
population: number;
populationMax: number;
agriculture: number;
@@ -66,8 +69,8 @@ const metrics = computed(() => {
<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.supplyState }} · 전방 {{ props.city.frontState }}
{{ props.city.regionName }} | {{ props.city.levelName }} {{ props.city.name }} ·
{{ props.city.nationId > 0 ? `지배 국가 【 ${props.city.nationName}` : '공 백 지' }}
</div>
<div class="progress-grid">
<div
@@ -2,6 +2,7 @@
import { computed } from 'vue';
import SkeletonLines from '../ui/SkeletonLines.vue';
import LegacyProgressBar from '../ui/LegacyProgressBar.vue';
import { formatSeoulHourMinute } from '../../utils/legacyDateTime';
import { legacyExperiencePercent, ratioPercent } from '../../utils/legacyProgress';
interface GeneralStats {
@@ -13,6 +14,7 @@ interface GeneralStats {
interface GeneralProgression {
experienceLevel: number;
dedicationLevel: number;
dedicationText?: string;
statExperience?: { leadership: number; strength: number; intelligence: number };
statUpgradeLimit?: number;
}
@@ -22,6 +24,7 @@ interface GeneralInfo {
name: string;
npcState: number;
officerLevel: number;
officerLevelText: string;
stats: GeneralStats;
gold: number;
rice: number;
@@ -34,6 +37,7 @@ interface GeneralInfo {
age?: number;
turnTime?: string;
crewTypeId?: number;
crewTypeName?: string;
traits?: { personal: string; specialWar: string; specialDomestic: string };
progression?: GeneralProgression;
}
@@ -78,9 +82,9 @@ const experiencePercent = computed(() =>
<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.officerLevelText }} · {{ props.general.age ?? '-' }} ·
다음
{{ props.general.turnTime?.slice(11, 16) ?? '-' }}
{{ props.general.turnTime ? formatSeoulHourMinute(props.general.turnTime) : '-' }}
</div>
<div class="stat-progress-grid">
@@ -102,11 +106,11 @@ const experiencePercent = computed(() =>
><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>{{ props.general.crewTypeId || '-' }}</strong> <span>성격</span
><strong>{{ props.general.crewTypeName ?? '-' }}</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>Lv {{ props.general.progression?.dedicationLevel ?? 0 }}</strong> <span>공헌</span
><strong>{{ props.general.progression?.dedicationText ?? '무품관' }}</strong> <span>공헌</span
><strong>{{ props.general.dedication.toLocaleString() }}</strong>
</div>
@@ -7,11 +7,14 @@ interface NationInfo {
name: string;
color: string;
level: number;
levelName: string;
gold: number;
rice: number;
tech: number;
typeCode: string;
typeName: string;
capitalCityId: number | null;
capitalCityName: string | null;
}
const props = defineProps<{
@@ -31,7 +34,7 @@ const props = defineProps<{
class="title"
:style="{ backgroundColor: props.nation.color, color: legacyNationTextColor(props.nation.color) }"
>
{{ props.nation.name }}<template v-if="props.nation.id > 0"> (Lv {{ props.nation.level }})</template>
{{ props.nation.name }}<template v-if="props.nation.id > 0"> ({{ props.nation.levelName }})</template>
</div>
<div class="grid">
<span>국고</span
@@ -40,10 +43,11 @@ const props = defineProps<{
><strong>{{ props.nation.id === 0 ? '해당 없음' : props.nation.rice.toLocaleString() }}</strong>
<span>기술</span
><strong>{{ props.nation.id === 0 ? '해당 없음' : props.nation.tech.toLocaleString() }}</strong>
<span>체제</span><strong>{{ props.nation.id === 0 ? '해당 없음' : props.nation.typeCode }}</strong>
<span>체제</span><strong>{{ props.nation.id === 0 ? '해당 없음' : props.nation.typeName }}</strong>
<span>수도</span
><strong>{{ props.nation.id === 0 ? '해당 없음' : (props.nation.capitalCityId ?? '-') }}</strong>
<span>국가 등급</span><strong>{{ props.nation.id === 0 ? '해당 없음' : props.nation.level }}</strong>
><strong>{{ props.nation.id === 0 ? '해당 없음' : (props.nation.capitalCityName ?? '-') }}</strong>
<span>국가 등급</span
><strong>{{ props.nation.id === 0 ? '해당 없음' : props.nation.levelName }}</strong>
</div>
</div>
</div>
@@ -20,3 +20,5 @@ export const formatSeoulDateTime = (value: string | Date): string => {
koreaTime.getUTCSeconds()
)}`;
};
export const formatSeoulHourMinute = (value: string | Date): string => formatSeoulDateTime(value).slice(11, 16);
+18 -7
View File
@@ -14,6 +14,19 @@ export const officerLevelMapDefault: Record<number, string> = {
0: '재야',
};
export const nationLevelMap: Record<number, string> = {
7: '황제',
6: '왕',
5: '공',
4: '주목',
3: '주자사',
2: '군벌',
1: '호족',
0: '방랑군',
};
export const formatNationLevelText = (nationLevel: number): string => nationLevelMap[nationLevel] ?? '-';
export const officerLevelMapByNationLevel: Record<number, Record<number, string>> = {
7: {
12: '황제',
@@ -75,15 +88,13 @@ export const officerLevelMapByNationLevel: Record<number, Record<number, string>
export const formatOfficerLevelText = (officerLevel: number, nationLevel?: number): string => {
if (officerLevel < 5) {
return officerLevelMapDefault[officerLevel] ?? '???';
return officerLevelMapDefault[officerLevel] ?? '-';
}
const nationMap =
nationLevel === undefined
? officerLevelMapDefault
: (officerLevelMapByNationLevel[nationLevel] ?? officerLevelMapDefault);
return nationMap[officerLevel] ?? (officerLevelMapDefault[officerLevel] ?? '???');
if (nationLevel === undefined) {
return officerLevelMapDefault[officerLevel] ?? '-';
}
return officerLevelMapByNationLevel[nationLevel]?.[officerLevel] ?? '-';
};
export const regionMap: Record<number, string> = {
+5 -3
View File
@@ -38,6 +38,8 @@ const resolveErrorMessage = (value: unknown): string => {
};
const formatNumber = (value: number | null | undefined): string => (value ?? 0).toLocaleString();
const displayCode = (value: string | null | undefined): string =>
!value || /^\d+$/u.test(value) ? '-' : value.replace(/^che_(?:event_)?/u, '');
const cutDateTime = (value: string | null | undefined, showSecond = false): string => {
if (!value) {
return '-';
@@ -395,7 +397,7 @@ onMounted(() => {
<h2 class="section-title bg2">경매 {{ uniqueDetail.auction.id }} 상세</h2>
<dl class="detail-grid">
<dt class="bg1">경매명</dt>
<dd>{{ uniqueDetail.auction.detail.title ?? uniqueDetail.auction.targetCode }}</dd>
<dd>{{ uniqueDetail.auction.detail.title ?? displayCode(uniqueDetail.auction.targetCode) }}</dd>
<dt class="bg1">주최자(익명)</dt>
<dd :class="{ 'is-me': uniqueDetail.auction.isCallerHost }">{{ uniqueDetail.auction.hostName }}</dd>
<dt class="bg1">종료일시</dt>
@@ -442,7 +444,7 @@ onMounted(() => {
@keydown.space.prevent="selectUnique(auction)"
>
<span>{{ auction.id }}</span
><span>{{ auction.detail.title ?? auction.targetCode }}</span>
><span>{{ auction.detail.title ?? displayCode(auction.targetCode) }}</span>
<span :class="{ 'is-me': auction.isCallerHost }">{{ auction.hostName }}</span>
<span class="tnum">{{ cutDateTime(auction.closeAt) }}</span>
<span>{{ (auction.detail.remainCloseDateExtensionCnt ?? 0) > 0 ? '남음' : '소진' }}</span>
@@ -474,7 +476,7 @@ onMounted(() => {
@keydown.space.prevent="selectUnique(auction)"
>
<span>{{ auction.id }}</span
><span>{{ auction.detail.title ?? auction.targetCode }}</span>
><span>{{ auction.detail.title ?? displayCode(auction.targetCode) }}</span>
<span :class="{ 'is-me': auction.isCallerHost }">{{ auction.hostName }}</span>
<span class="tnum">{{ cutDateTime(auction.closeAt) }}</span>
<span>{{ (auction.detail.remainCloseDateExtensionCnt ?? 0) > 0 ? '남음' : '소진' }}</span>
@@ -269,7 +269,7 @@ onMounted(() => {
<SkeletonLines v-if="loading" :lines="5" />
<div v-else-if="selectedGeneral" class="battle-general-card">
<div class="battle-general-name">
{{ selectedGeneral.name }} (관직 {{ selectedGeneral.officerLevel }})
{{ selectedGeneral.name }} ({{ selectedGeneral.officerLevelText }})
</div>
<span
class="battle-general-portrait"
@@ -293,9 +293,9 @@ onMounted(() => {
</div>
<div class="battle-general-extra">
<span>명성</span><strong>{{ selectedGeneral.experience.toLocaleString('ko-KR') }}</strong>
<span>계급</span><strong>{{ selectedGeneral.dedication.toLocaleString('ko-KR') }}</strong>
<span>계급</span><strong>{{ selectedGeneral.progression.dedicationText }}</strong>
<span>나이</span><strong>{{ selectedGeneral.age }}</strong> <span>병종</span
><strong>{{ selectedGeneral.crewTypeId }}</strong> <span>승리</span
><strong>{{ selectedGeneral.crewTypeName }}</strong> <span>승리</span
><strong>{{ selectedGeneral.battleStats.kills }}</strong> <span>패배</span
><strong>{{ selectedGeneral.battleStats.deaths }}</strong> <span>사살</span
><strong>{{ selectedGeneral.battleStats.killCrew.toLocaleString('ko-KR') }}</strong>
@@ -158,7 +158,7 @@ watch(viewMode, () => {
<button class="legacy-button" type="button" @click="closePage"> 닫기</button>
</div>
<footer class="legacy-banner">
삼국지 모의전투 HiDCHe core2026 / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD /
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD /
<a href="https://sam.hided.net/wiki/hidche/credit" target="_blank" rel="noreferrer">Credit</a>
</footer>
</main>
+1 -1
View File
@@ -248,7 +248,7 @@ const placeBet = async (targetId: number) => {
<button class="close-button" type="button" @click="navigate"> 닫기</button>
</RouterLink>
<small>
삼국지 모의전투 PHP HiDCHe -unknown / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
HideD(hided62@gmail.com) / Credit
</small>
</footer>
@@ -321,7 +321,7 @@ const generalImage = (general: General): string => resolveGeneralIconUrl(general
</tr>
<tr>
<td class="legacy-banner">
삼국지 모의전투 PHP HiDCHe - unknown / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
HideD(hided62@gmail.com) /
<a href="https://sam.hided.net/wiki/hidche/credit" target="_blank" rel="noreferrer">Credit</a>
</td>
@@ -621,7 +621,7 @@ onBeforeUnmount(() => {
<td>
<button class="legacy-button legacy-button--primary" type="button" @click="router.push('/')">돌아가기</button
><br /><br />
삼국지 모의전투 PHP HiDCHe - unknown / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
HideD(hided62@gmail.com) /
<a href="https://github.com/hided/SamK" target="_blank" rel="noopener noreferrer">Credit</a>
</td>
@@ -289,7 +289,7 @@ onMounted(loadDetail);
</tr>
<tr>
<td class="banner">
삼국지 모의전투 TypeScript core2026 / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD
</td>
</tr>
</tbody>
@@ -144,7 +144,7 @@ onMounted(loadDynasty);
</tr>
<tr>
<td class="banner">
삼국지 모의전투 TypeScript core2026 / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD
</td>
</tr>
</tbody>
@@ -174,7 +174,7 @@ onMounted(loadOptions);
<button class="legacy-button" type="button" @click="closePage"> 닫기</button>
</div>
<footer class="legacy-banner">
삼국지 모의전투 HiDCHe core2026 / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD /
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD /
<a href="https://sam.hided.net/wiki/hidche/credit" target="_blank" rel="noreferrer">Credit</a>
</footer>
</main>
+40 -12
View File
@@ -132,12 +132,40 @@ const noDefencePenaltyWaived = computed(() => {
);
});
const noDefenceLabel = computed(() => (noDefencePenaltyWaived.value ? '×' : '× [훈련 -3,사기 -6]'));
const items = computed<Array<{ key: ItemSlotKey; name: string; code: string | null }>>(() => [
{ key: 'horse', name: '', code: data.value?.general.items.horse ?? null },
{ key: 'weapon', name: '무기', code: data.value?.general.items.weapon ?? null },
{ key: 'book', name: '서적', code: data.value?.general.items.book ?? null },
{ key: 'item', name: '도구', code: data.value?.general.items.item ?? null },
]);
const fallbackDisplayCode = (value: string | null | undefined): string | null =>
value && !/^\d+$/u.test(value) ? value.replace(/^che_(?:event_)?/u, '') : null;
const items = computed<Array<{ key: ItemSlotKey; slotName: string; displayName: string | null; code: string | null }>>(
() => [
{
key: 'horse',
slotName: '말',
displayName:
data.value?.general.itemNames?.horse ?? fallbackDisplayCode(data.value?.general.items.horse) ?? null,
code: data.value?.general.items.horse ?? null,
},
{
key: 'weapon',
slotName: '무기',
displayName:
data.value?.general.itemNames?.weapon ?? fallbackDisplayCode(data.value?.general.items.weapon) ?? null,
code: data.value?.general.items.weapon ?? null,
},
{
key: 'book',
slotName: '서적',
displayName:
data.value?.general.itemNames?.book ?? fallbackDisplayCode(data.value?.general.items.book) ?? null,
code: data.value?.general.items.book ?? null,
},
{
key: 'item',
slotName: '도구',
displayName:
data.value?.general.itemNames?.item ?? fallbackDisplayCode(data.value?.general.items.item) ?? null,
code: data.value?.general.items.item ?? null,
},
]
);
const iconChoices = computed(() => data.value?.iconChoices ?? []);
const autorunUser = computed(() => asRecord(world.value?.meta.autorun_user));
@@ -302,8 +330,8 @@ const dieOnPrestart = async () => {
}
};
const dropItem = (item: { key: ItemSlotKey; name: string; code: string | null }) =>
confirmMutation(`${item.code ?? item.name}을(를) 버리시겠습니까?`, () =>
const dropItem = (item: { key: ItemSlotKey; slotName: string; displayName: string | null; code: string | null }) =>
confirmMutation(`${item.displayName ?? item.slotName}을(를) 버리시겠습니까?`, () =>
trpc.general.dropItem.mutate({ itemType: item.key })
);
@@ -416,7 +444,7 @@ onMounted(() => {
>
· 계급
<strong
>Lv {{ data.general.progression?.dedicationLevel ?? 0 }} ({{
>{{ data.general.progression?.dedicationText ?? '무품관' }} ({{
data.general.dedication
}})</strong
>
@@ -425,7 +453,7 @@ onMounted(() => {
<div>승률 0% · 승리 0 · 패배 0</div>
<div>살상률 0% · 사살 0 · 피살 0</div>
<div>
병종 {{ data.general.crewTypeId || '-' }} · 내정특기
병종 {{ data.general.crewTypeName ?? '-' }} · 내정특기
{{ data.general.traits?.specialDomestic ?? '-' }} · 부상 {{ data.general.injury }} · 부대
{{ data.general.troopId || '-' }} · 벌점 {{ penalties.length || '-' }}
</div>
@@ -595,7 +623,7 @@ onMounted(() => {
:disabled="!item.code"
@click="dropItem(item)"
>
{{ item.code ?? '-' }}
{{ item.displayName ?? '-' }}
</button>
</div>
@@ -637,7 +665,7 @@ onMounted(() => {
</article>
</section>
<footer class="legacy-credit">
삼국지 모의전투 PHP HiDCHe - core2026 / KOEI의 이미지를 사용, 응용하였습니다 / 제작: HideD / Credit
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작: HideD / Credit
</footer>
</main>
<div class="my-page-mobile-scroll-spacer" aria-hidden="true"></div>
@@ -252,7 +252,7 @@ onMounted(async () => {
</tr>
<tr>
<td class="legacy-banner">
삼국지 모의전투 PHP HiDCHe - unknown / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
<a href="mailto:hided62@gmail.com">HideD(hided62@gmail.com)</a> /
<a href="https://github.com/hided/SamK" target="_blank" rel="noopener noreferrer">Credit</a>
</td>
@@ -63,7 +63,6 @@ const generals = computed(() =>
})
);
const special = (general: General) => `${general.specialDomestic?.name ?? '-'} / ${general.specialWar?.name ?? '-'}`;
const rank = (general: General) => (general.dedicationLevel ? `${11 - general.dedicationLevel}품관` : '무품관');
const iconUrl = (general: General) => resolveGeneralIconUrl(general);
onMounted(load);
</script>
@@ -78,7 +77,13 @@ onMounted(load);
<strong>세력 장수</strong>
<span class="right-actions">
<span class="dropdown">
<button class="top-button mode-button" :aria-expanded="viewMenuOpen" @click="viewMenuOpen = !viewMenuOpen">보기 모드</button>
<button
class="top-button mode-button"
:aria-expanded="viewMenuOpen"
@click="viewMenuOpen = !viewMenuOpen"
>
보기 모드
</button>
<span v-if="viewMenuOpen" class="dropdown-menu">
<button
@click="
@@ -178,7 +183,7 @@ onMounted(load);
</td>
<td :class="`name-cell npc-${general.npcState}`">{{ general.name }}</td>
<td>{{ formatOfficerLevelText(general.officerLevel, data?.nation.level) }}</td>
<td>{{ rank(general) }}<br />({{ (general.dedicationLevel * 200).toLocaleString() }})</td>
<td>{{ general.dedicationText }}<br />({{ general.bill.toLocaleString() }})</td>
<td>Lv {{ general.experienceLevel }}<br />({{ general.personality?.name ?? '-' }})</td>
<td>{{ general.stats.leadership }}</td>
<td>{{ general.stats.strength }}</td>
@@ -3,6 +3,7 @@ import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { formatLog } from '../utils/formatLog';
import { legacyNationTextColor } from '../utils/legacyNationColor';
import { formatNationLevelText } from '../utils/nationFormat';
import { trpc } from '../utils/trpc';
type Result = Awaited<ReturnType<typeof trpc.nation.getNationInfo.query>>;
@@ -11,9 +12,7 @@ const router = useRouter();
const error = ref('');
const number = (value: number) => value.toLocaleString('ko-KR');
const diff = (value: number) => `${value > 0 ? '+' : ''}${number(value)}`;
const nationLevel = computed(
() => ['두목', '영주', '군벌', '주자사', '주목', '공', '왕', '황제'][data.value?.nation.level ?? 0] ?? '-'
);
const nationLevel = computed(() => formatNationLevelText(data.value?.nation.level ?? 0));
onMounted(async () => {
try {
data.value = await trpc.nation.getNationInfo.query();
@@ -114,7 +113,7 @@ onMounted(async () => {
<td><button type="button" @click="router.push('/')">돌아가기</button></td>
</tr>
<tr>
<td class="credit">삼국지 모의전투 PHP HiDCHe / KOEI의 이미지를 사용했습니다 / 제작: Hide.D</td>
<td class="credit">삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용했습니다 / 제작: Hide.D</td>
</tr>
</tbody>
</table>
+3 -13
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { formatOfficerLevelText } from '../utils/nationFormat';
import { formatNationLevelText, formatOfficerLevelText } from '../utils/nationFormat';
import { getNpcColor } from '../utils/npcColor';
import { trpc } from '../utils/trpc';
@@ -12,16 +12,6 @@ const nations = ref<Directory>([]);
const loading = ref(false);
const error = ref('');
const nationLevelText: Record<number, string> = {
7: '황제',
6: '왕',
5: '공',
4: '주목',
3: '주자사',
2: '군벌',
1: '호족',
0: '방랑군',
};
const whiteTextColors = new Set([
'',
'#330000',
@@ -111,7 +101,7 @@ onMounted(() => {
<td class="label-cell"> </td>
<td class="value-wide type-name">{{ Array.from(nation.type.name).join(' ') }}</td>
<td class="label-cell"> </td>
<td class="value-wide">{{ nationLevelText[nation.level] ?? '-' }}</td>
<td class="value-wide">{{ formatNationLevelText(nation.level) }}</td>
<td class="label-cell"> </td>
<td class="value-wide">{{ nation.power }}</td>
<td class="label-cell">장수 / 속령</td>
@@ -227,7 +217,7 @@ onMounted(() => {
<tr>
<td>
<small>
삼국지 모의전투 PHP HiDCHe - unknown / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
HideD(hided62@gmail.com) /
<a href="https://github.com/hided/SamK" target="_blank" rel="noopener noreferrer">Credit</a>
</small>
@@ -503,7 +503,7 @@ onMounted(() => void loadPersonnel());
</tr>
<tr>
<td class="legacy-banner">
삼국지 모의전투 PHP HiDCHe - unknown / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
HideD(hided62@gmail.com) /
<a href="https://sam.hided.net/wiki/hidche/credit" target="_blank" rel="noreferrer"
>Credit</a
@@ -159,7 +159,7 @@ onMounted(load);
</tr>
<tr>
<td class="legacy-banner">
삼국지 모의전투 PHP HiDCHe - unknown / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
HideD(hided62@gmail.com) /
<a href="https://github.com/hided/SamK" target="_blank" rel="noopener noreferrer">Credit</a>
</td>
+1 -1
View File
@@ -149,7 +149,7 @@ onMounted(() => {
</tr>
<tr>
<td class="banner">
삼국지 모의전투 PHP HiDCHe - unknown / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
<a href="mailto:hided62@gmail.com">HideD(hided62@gmail.com)</a> /
<a href="https://github.com/hided/SamK" target="_blank" rel="noopener noreferrer">Credit</a>
</td>
@@ -140,7 +140,7 @@ onMounted(() => {
<td>{{ valueOrDash(general.leadership) }}</td>
<td>{{ valueOrDash(general.strength) }}</td>
<td>{{ valueOrDash(general.intel) }}</td>
<td>{{ valueOrDash(general.officerLevel) }}</td>
<td>{{ valueOrDash(general.officerLevelText) }}</td>
<td>{{ valueOrDash(general.personal) }}</td>
<td>{{ valueOrDash(general.special) }}</td>
<td>{{ valueOrDash(general.special2) }}</td>
@@ -358,8 +358,11 @@ onBeforeUnmount(() => {
<span role="tooltip">{{ candidate.specialDomesticInfo }}</span>
</span>
/
<span>{{ candidate.specialWar ?? '-' }}</span
><br /><br />
<span v-if="candidate.specialWarName" class="trait-tooltip" tabindex="0">
{{ candidate.specialWarName }}
<span role="tooltip">{{ candidate.specialWarInfo }}</span>
</span>
<span v-else>-</span><br /><br />
보병: {{ Math.trunc(candidate.dex[0] / 1000) }}K<br />
궁병: {{ Math.trunc(candidate.dex[1] / 1000) }}K<br />
기병: {{ Math.trunc(candidate.dex[2] / 1000) }}K<br />
@@ -408,8 +411,11 @@ onBeforeUnmount(() => {
<span role="tooltip">{{ selectedCandidate.specialDomesticInfo }}</span>
</span>
/
<span>{{ selectedCandidate.specialWar ?? '-' }}</span
><br /><br />
<span v-if="selectedCandidate.specialWarName" class="trait-tooltip" tabindex="0">
{{ selectedCandidate.specialWarName }}
<span role="tooltip">{{ selectedCandidate.specialWarInfo }}</span>
</span>
<span v-else>-</span><br /><br />
보병: {{ Math.trunc(selectedCandidate.dex[0] / 1000) }}K<br />
궁병: {{ Math.trunc(selectedCandidate.dex[1] / 1000) }}K<br />
기병: {{ Math.trunc(selectedCandidate.dex[2] / 1000) }}K<br />
@@ -498,7 +504,7 @@ onBeforeUnmount(() => {
</div>
<div class="footer-banner with-border">
<small>
삼국지 모의전투 HiDCHe core2026 / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD /
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD /
<a href="https://sam.hided.net/wiki/hidche/credit" target="_blank" rel="noopener noreferrer"
>Credit</a
>
@@ -287,7 +287,7 @@ const start = async () => {
<button class="close-button" type="button" @click="navigate"> 닫기</button>
</RouterLink>
<small>
삼국지 모의전투 PHP HiDCHe -unknown / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
HideD(hided62@gmail.com) / Credit
</small>
</footer>
+1 -1
View File
@@ -204,7 +204,7 @@ onMounted(() => {
</tr>
<tr>
<td class="banner">
삼국지 모의전투 PHP HiDCHe - unknown / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
HideD(hided62@gmail.com) /
<a href="https://sam.hided.net/wiki/hidche/credit" target="_blank" rel="noreferrer">Credit</a>
</td>
@@ -0,0 +1,14 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { formatSeoulDateTime, formatSeoulHourMinute } from '../src/utils/legacyDateTime.ts';
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(formatSeoulHourMinute('2026-08-13T00:07:06.713Z'), '09:07');
});
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(formatSeoulHourMinute('2026-08-13 09:07:06'), '09:07');
});
@@ -0,0 +1,20 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { formatNationLevelText, formatOfficerLevelText } from '../src/utils/nationFormat.ts';
void describe('nationFormat Ref labels', () => {
void it('uses the Ref nation-level and nation-dependent office names', () => {
assert.equal(formatNationLevelText(0), '방랑군');
assert.equal(formatNationLevelText(3), '주자사');
assert.equal(formatNationLevelText(7), '황제');
assert.equal(formatOfficerLevelText(9, 3), '간의대부');
assert.equal(formatOfficerLevelText(5, 3), '-');
assert.equal(formatOfficerLevelText(5), '제3모사');
});
void it('does not expose unknown numeric levels', () => {
assert.equal(formatNationLevelText(99), '-');
assert.equal(formatOfficerLevelText(99), '-');
});
});