Merge remote-tracking branch 'origin/main' into feature/recruitment-command-parity-20260813

# Conflicts:
#	app/game-api/src/router/turns/index.ts
#	app/game-api/src/turns/commandInput.ts
#	app/game-frontend/e2e/commandArguments.spec.ts
#	app/game-frontend/src/components/command/ReservedCommandEditor.vue
#	app/game-frontend/src/components/command/types.ts
#	app/game-frontend/src/views/ChiefCenterView.vue
This commit is contained in:
2026-08-13 16:28:35 +00:00
83 changed files with 4463 additions and 1050 deletions
+7
View File
@@ -39,6 +39,13 @@ body {
min-width: 500px;
}
/* These redesigned identity/tournament screens own a true handheld layout. */
#app:has(.responsive-settings-page),
#app:has(#tournament-container),
#app:has(#tournament-betting-container) {
min-width: 320px;
}
body:has(.battle-page),
body:has(.chief-page),
body:has(.global-page),
@@ -1,7 +1,13 @@
<script setup lang="ts">
import { computed } from 'vue';
import ReservedCommandEditor from '../command/ReservedCommandEditor.vue';
import type { CommandPatternEntry, CommandTable, ReservedCommandRow } from '../command/types';
import type {
CommandMapData,
CommandMapLayout,
CommandPatternEntry,
CommandTable,
ReservedCommandRow,
} from '../command/types';
const props = defineProps<{
officerLevelText: string;
@@ -13,6 +19,8 @@ const props = defineProps<{
generalId: number;
officerLevel: number;
mobile?: boolean;
mapData?: CommandMapData | null;
mapLayout?: CommandMapLayout | null;
}>();
const commandRows = computed(() => props.rows.map((row) => ({ ...row, action: row.actionCode ?? row.action })));
@@ -37,6 +45,8 @@ const emit = defineEmits<{
:title="props.officerLevelText"
:name="props.name"
:current-time="props.rows[0]?.time"
:map-data="props.mapData"
:map-layout="props.mapLayout"
@reserve-bulk="emit('reserve-bulk', $event)"
@shift="emit('shift', $event)"
@repeat="emit('repeat', $event)"
@@ -2,6 +2,7 @@
import { computed, onMounted, ref, shallowRef, watch } from 'vue';
import CommandArgumentForm from '../main/CommandArgumentForm.vue';
import CommandSelectForm from '../main/CommandSelectForm.vue';
import { commandArgumentPresentation } from './commandArgumentPresentation';
import DragSelect from './DragSelect.vue';
import RecruitmentCommandForm from './RecruitmentCommandForm.vue';
import {
@@ -12,7 +13,14 @@ import {
normalizedSelection,
selectStep,
} from './commandQueue';
import type { CommandAvailability, CommandPatternEntry, CommandTable, ReservedCommandRow } from './types';
import type {
CommandAvailability,
CommandMapData,
CommandMapLayout,
CommandPatternEntry,
CommandTable,
ReservedCommandRow,
} from './types';
const props = withDefaults(
defineProps<{
@@ -27,8 +35,19 @@ const props = withDefaults(
title?: string;
name?: string | null;
currentTime?: string;
mapData?: CommandMapData | null;
mapLayout?: CommandMapLayout | null;
}>(),
{ maxPushTurn: 6, compact: false, mobile: false, title: '', name: null, currentTime: '--:--' }
{
maxPushTurn: 6,
compact: false,
mobile: false,
title: '',
name: null,
currentTime: '--:--',
mapData: null,
mapLayout: null,
}
);
const emit = defineEmits<{
@@ -153,6 +172,14 @@ const closePicker = () => {
quickTarget.value = null;
selectedCommand.value = null;
};
const togglePicker = (turnIndex?: number) => {
const target = turnIndex ?? null;
if (pickerOpen.value && quickTarget.value === target) {
closePicker();
return;
}
openPicker(turnIndex);
};
const selectCommand = (commandKey: string) => {
const command = props.commandTable?.[props.scope]
.flatMap((group) => group.values)
@@ -242,7 +269,15 @@ const clickOutsideMenu = (event: Event) => {
<template>
<article
class="reserved-command-editor"
:class="{ compact: props.compact, mobile: props.mobile, 'edit-mode': editMode, 'picker-open': pickerOpen }"
:class="{
compact: props.compact,
mobile: props.mobile,
'edit-mode': editMode,
'picker-open': pickerOpen,
'argument-expanded': Boolean(
selectedCommand?.reqArg && commandArgumentPresentation(selectedCommand.key).lines.length
),
}"
:data-command-scope="props.scope"
>
<header v-if="props.compact && !props.mobile" class="identity legacy-bg1">
@@ -309,6 +344,7 @@ const clickOutsideMenu = (event: Event) => {
>
짝수턴
</button>
<hr class="menu-divider" />
<template v-for="step in [3, 4, 5, 6, 7]" :key="step">
<small>{{ step }} 간격</small>
<div class="step-buttons">
@@ -433,6 +469,7 @@ const clickOutsideMenu = (event: Event) => {
>
붙여넣기
</button>
<hr class="menu-divider" />
<button
@click="
textCopy();
@@ -441,6 +478,7 @@ const clickOutsideMenu = (event: Event) => {
>
텍스트 복사
</button>
<hr class="menu-divider" />
<button
@click="
saveTemplate();
@@ -457,6 +495,7 @@ const clickOutsideMenu = (event: Event) => {
>
반복하기
</button>
<hr class="menu-divider" />
<button
@click="
clearSelection();
@@ -483,7 +522,7 @@ const clickOutsideMenu = (event: Event) => {
</button>
</div>
</details>
<button type="button" class="select-command" @click="openPicker()">명령 선택 </button>
<button type="button" class="select-command" @click="togglePicker()">명령 선택 </button>
</div>
<div class="queue-area">
@@ -546,7 +585,7 @@ const clickOutsideMenu = (event: Event) => {
:key="row.index"
type="button"
:aria-label="`${row.index + 1} 명령 입력`"
@click="openPicker(row.index)"
@click="togglePicker(row.index)"
>
</button>
@@ -606,6 +645,8 @@ const clickOutsideMenu = (event: Event) => {
:command-key="selectedCommand.key"
:fields="selectedCommand.inputFields"
:options="props.commandTable.inputOptions"
:map-data="props.mapData"
:map-layout="props.mapLayout"
@update:args="commandArgs = $event"
@update:valid="commandArgsValid = $event"
/>
@@ -725,6 +766,14 @@ const clickOutsideMenu = (event: Event) => {
padding: 5px 8px;
color: #bbb;
}
.menu-divider {
width: 100%;
height: 0;
margin: 4px 0;
border: 0;
border-top: 1px solid #444;
opacity: 1;
}
.step-buttons,
.template-row {
display: flex;
@@ -933,6 +982,11 @@ const clickOutsideMenu = (event: Event) => {
}
@media (min-width: 1025px) {
.argument-expanded:not(.compact) .command-picker {
right: 0;
left: auto;
width: 700px;
}
.compact:not(.mobile) .command-picker {
position: fixed;
z-index: 1000;
@@ -941,6 +995,13 @@ const clickOutsideMenu = (event: Event) => {
left: calc(50% - 476px);
width: 238px;
}
.compact.argument-expanded:not(.mobile) .command-picker {
left: calc(50% - 350px);
width: 700px;
height: auto;
max-height: calc(100vh - 104px);
overflow: auto;
}
.compact:not(.mobile) .command-picker.recruitment-picker {
top: 76px;
left: 50%;
@@ -984,12 +1045,25 @@ const clickOutsideMenu = (event: Event) => {
width: 370px;
height: 327px;
}
.mobile.compact.argument-expanded .command-picker {
position: relative;
top: auto;
left: auto;
width: 100%;
height: auto;
max-height: none;
margin-top: -330px;
overflow: visible;
}
.mobile.compact .command-picker.recruitment-picker {
position: fixed;
top: 76px;
left: 0;
width: 500px;
height: auto;
max-height: calc(100vh - 82px);
margin-top: 0;
overflow: auto;
transform: none;
}
.mobile.compact .advanced-actions {
@@ -0,0 +1,92 @@
export type CommandArgumentPresentation = {
lines: string[];
mapTarget?: 'city' | 'nation';
};
const cityTarget = (lines: string[]): CommandArgumentPresentation => ({ lines, mapTarget: 'city' });
const nationTarget = (lines: string[]): CommandArgumentPresentation => ({ lines, mapTarget: 'nation' });
// Ref hwe/ts/processing의 명령별 안내를 예약 명령 옵션창에 맞게 옮긴다.
// 징병/모병은 별도 이관 범위이므로 이 표에 넣지 않는다.
const PRESENTATIONS: Record<string, CommandArgumentPresentation> = {
che_강행: cityTarget(['선택한 도시로 강행합니다.', '최대 3칸 안의 도시만 선택할 수 있습니다.']),
che_이동: cityTarget(['선택한 도시로 이동합니다.', '인접한 도시로만 이동할 수 있습니다.']),
che_출병: cityTarget([
'선택한 도시를 향해 침공합니다.',
'침공 경로에 적군 도시가 있으면 그 도시에서 전투를 벌입니다.',
]),
che_첩보: cityTarget(['선택한 도시에 첩보를 실행합니다.', '인접 도시에서는 더 많은 정보를 얻습니다.']),
che_화계: cityTarget(['선택한 도시에 화계를 실행합니다.']),
che_탈취: cityTarget(['선택한 도시에 탈취를 실행합니다.']),
che_파괴: cityTarget(['선택한 도시에 파괴를 실행합니다.']),
che_선동: cityTarget(['선택한 도시에 선동을 실행합니다.']),
che_수몰: cityTarget(['선택한 도시에 수몰을 발동합니다.', '전쟁 중인 상대국 도시만 대상이 됩니다.']),
che_백성동원: cityTarget(['선택한 도시에 백성을 동원해 성벽을 쌓습니다.', '아국 도시만 대상이 됩니다.']),
che_천도: cityTarget([
'선택한 도시로 수도를 옮깁니다.',
'현재 수도에서 연결된 도시만 가능하며 1 + 2 × 거리만큼의 턴이 필요합니다.',
]),
che_허보: cityTarget(['선택한 도시에 허보를 발동합니다.', '선포 또는 전쟁 중인 상대국 도시만 대상이 됩니다.']),
che_초토화: cityTarget([
'선택한 도시를 초토화해 공백지로 만듭니다.',
'인구와 내정 상태에 따라 국고를 확보하고, 수뇌 명성과 모든 장수의 배신 수치에 영향을 줍니다.',
]),
cr_인구이동: cityTarget(['현재 도시의 인구를 선택한 인접 도시로 이동합니다.']),
che_발령: cityTarget(['선택한 도시로 아국 장수를 발령합니다.', '아국 도시만 대상이 됩니다.']),
che_선전포고: nationTarget([
'선택한 국가에 선전포고합니다.',
'고립되지 않은 아국 도시와 인접한 국가에만 가능하며 초반 제한의 영향을 받습니다.',
]),
che_급습: nationTarget(['선택한 국가에 급습을 발동합니다.', '선포 또는 전쟁 중인 상대국만 대상이 됩니다.']),
che_불가침파기제의: nationTarget(['불가침 중인 국가에 조약 파기를 제의합니다.']),
che_이호경식: nationTarget(['선택한 국가에 이호경식을 발동합니다.', '선포 또는 전쟁 중인 상대국만 대상이 됩니다.']),
che_종전제의: nationTarget(['전쟁 중인 국가에 종전을 제의합니다.']),
che_불가침제의: nationTarget([
'선택한 국가에 불가침을 제의합니다.',
'불가침 기한 다음 달부터 다시 선전포고할 수 있습니다.',
]),
che_피장파장: nationTarget([
'선택한 국가가 지정한 전략을 일정 턴 동안 사용하지 못하게 합니다.',
'아국에도 지정 전략의 재사용 제한이 생깁니다.',
]),
che_물자원조: nationTarget(['타국에 금과 쌀을 원조합니다.', '국가 작위에 따라 보낼 수 있는 금액이 제한됩니다.']),
che_증여: { lines: ['자신의 금이나 쌀을 선택한 장수에게 증여합니다.'] },
che_헌납: { lines: ['자신의 금이나 쌀을 국가 재산으로 헌납합니다.'] },
che_군량매매: { lines: ['자신의 군량을 사거나 팝니다.'] },
che_몰수: { lines: ['선택한 장수의 금이나 쌀을 몰수해 국가 재산으로 귀속합니다.'] },
che_포상: { lines: ['국고에서 선택한 장수에게 금이나 쌀을 지급합니다.'] },
che_부대탈퇴지시: { lines: ['선택한 장수에게 부대 탈퇴를 지시합니다.', '현재 부대원인 장수만 대상이 됩니다.'] },
che_등용: { lines: ['재야 또는 타국 장수에게 등용 서신을 보냅니다.', '서신은 개인 메시지로 전달됩니다.'] },
che_선양: { lines: ['군주의 자리를 선택한 아국 장수에게 물려줍니다.'] },
che_임관: {
lines: [
'선택한 국가에 임관하고 군주의 위치로 이동합니다.',
'이미 임관하거나 등용되었던 국가는 선택할 수 없습니다.',
],
},
che_장수대상임관: {
lines: ['선택한 장수를 따라 그 장수의 국가에 임관하고 군주의 위치로 이동합니다.'],
},
che_숙련전환: {
lines: ['선택한 병과 숙련을 40% 줄이고, 줄어든 숙련의 90%를 다른 병과 숙련으로 전환합니다.'],
},
che_장비매매: { lines: ['장비를 구입하거나 매각합니다.', '가격과 요구 치안, 장비 효과를 확인한 뒤 선택하세요.'] },
che_건국: {
lines: ['현재 중·소도시에서 나라를 세웁니다.', '국가 성향별 장단점을 확인한 뒤 국명과 색상을 정하세요.'],
},
che_무작위건국: {
lines: ['무작위 공백 중·소도시에서 나라를 세웁니다.', '국가 성향별 장단점을 확인한 뒤 국명과 색상을 정하세요.'],
},
cr_건국: { lines: ['현재 도시에서 규모 제한 없이 나라를 세웁니다.', '국가 성향별 장단점을 확인하세요.'] },
che_국기변경: { lines: ['국기의 색상을 변경합니다.', '이 명령은 한 번만 실행할 수 있습니다.'] },
che_국호변경: { lines: ['국가 이름을 변경합니다.', '황제가 된 뒤 한 번만 실행할 수 있습니다.'] },
che_등용수락: { lines: ['도착한 등용 제의에 응할 행동을 선택합니다.'] },
che_NPC능동: { lines: ['NPC 장수의 능동 행동 방식을 선택합니다.'] },
};
export const commandArgumentPresentation = (commandKey: string): CommandArgumentPresentation =>
PRESENTATIONS[commandKey] ?? { lines: [] };
export const presentedCommandKeys = (): string[] => Object.keys(PRESENTATIONS);
@@ -1,4 +1,36 @@
export type CommandOption = { value: string | number; label: string; color?: string };
export type CommandOption = {
value: string | number;
label: string;
color?: string;
description?: string;
};
export type CommandMapData = {
year: number;
month: number;
startYear: number;
techLevelLimit?: { maxLevel: number; initialLevel: number; increaseYears: number };
cityList: [number, number, number, number, number, number][];
nationList: [number, string, string, number][];
myCity?: number | null;
myNation?: number | null;
};
export type CommandMapLayout = {
mapName: string;
cityList: Array<{ id: number; name: string; level: number; region: number; x: number; y: number; path: number[] }>;
regionMap: Record<number, string>;
levelMap: Record<number, string>;
};
export type CommandInputContext = {
actorGold: number;
actorRice: number;
citySecurity?: number;
nationGold?: number;
nationRice?: number;
nationLevel?: number;
};
export type CommandInputField = {
key: string;
@@ -69,6 +101,7 @@ export type CommandTable = {
colors: CommandOption[];
items: Record<string, CommandOption[]>;
recruitment: RecruitmentInfo | null;
context?: CommandInputContext;
};
};
@@ -1,40 +1,24 @@
<script setup lang="ts">
import { computed, reactive, watch } from 'vue';
import MapViewer from './MapViewer.vue';
import { commandArgumentPresentation } from '../command/commandArgumentPresentation';
import type {
CommandInputContext,
CommandInputField,
CommandMapData,
CommandMapLayout,
CommandOption,
CommandTable,
} from '../command/types';
type OptionValue = string | number;
interface CommandOption {
value: OptionValue;
label: string;
color?: string;
}
interface CommandInputField {
key: string;
label: string;
kind: 'text' | 'number' | 'boolean' | 'select' | 'numberTuple' | 'hidden';
required: boolean;
min?: number;
max?: number;
step?: number;
constValue?: OptionValue;
options?: CommandOption[];
optionSource?: 'cities' | 'nations' | 'generals' | 'crewTypes' | 'armTypes' | 'nationTypes' | 'colors' | 'items';
tupleLabels?: string[];
}
interface CommandInputOptions {
cities: CommandOption[];
nations: CommandOption[];
generals: CommandOption[];
crewTypes: CommandOption[];
armTypes: CommandOption[];
nationTypes: CommandOption[];
colors: CommandOption[];
items: Record<string, CommandOption[]>;
}
type CommandInputOptions = CommandTable['inputOptions'];
const props = defineProps<{
commandKey: string;
fields: CommandInputField[];
options: CommandInputOptions;
mapData?: CommandMapData | null;
mapLayout?: CommandMapLayout | null;
}>();
const emit = defineEmits<{
@@ -43,6 +27,8 @@ const emit = defineEmits<{
}>();
const values = reactive<Record<string, unknown>>({});
const presentation = computed(() => commandArgumentPresentation(props.commandKey));
const visibleFields = computed(() => props.fields.filter((entry) => entry.kind !== 'hidden'));
const optionsFor = (field: CommandInputField): CommandOption[] => {
if (field.options) return field.options;
@@ -58,7 +44,16 @@ const defaultValue = (field: CommandInputField): unknown => {
if (field.kind === 'boolean') return true;
if (field.kind === 'numberTuple') return [field.min ?? 0, field.min ?? 0];
if (field.kind === 'number') return field.min ?? 0;
if (field.kind === 'select') return optionsFor(field)[0]?.value ?? '';
if (field.kind === 'select') {
const options = optionsFor(field);
const mapDefault =
field.optionSource === 'cities' && (field.key === 'destCityId' || field.key === 'destCityID')
? props.mapData?.myCity
: field.optionSource === 'nations' && field.key === 'destNationId'
? props.mapData?.myNation
: null;
return options.find((option) => option.value === mapDefault)?.value ?? options[0]?.value ?? '';
}
return '';
};
@@ -78,6 +73,129 @@ const setSelectValue = (field: CommandInputField, rawValue: string) => {
}
};
const selectedOptionFor = (field: CommandInputField): CommandOption | undefined =>
optionsFor(field).find((entry) => entry.value === values[field.key]);
const cityTargetField = computed(() =>
props.fields.find(
(field) =>
field.kind === 'select' &&
field.optionSource === 'cities' &&
(field.key === 'destCityId' || field.key === 'destCityID')
)
);
const nationTargetField = computed(() =>
props.fields.find(
(field) => field.kind === 'select' && field.optionSource === 'nations' && field.key === 'destNationId'
)
);
const showMap = computed(
() =>
Boolean(props.mapData && props.mapLayout) &&
((presentation.value.mapTarget === 'city' && cityTargetField.value) ||
(presentation.value.mapTarget === 'nation' && nationTargetField.value))
);
const mapSelectedCityId = computed<number | null>(() => {
if (!props.mapData) return null;
if (presentation.value.mapTarget === 'city' && cityTargetField.value) {
const value = values[cityTargetField.value.key];
return typeof value === 'number' ? value : null;
}
if (presentation.value.mapTarget === 'nation' && nationTargetField.value) {
const value = values[nationTargetField.value.key];
if (typeof value !== 'number') return null;
return props.mapData.cityList.find((entry) => entry[3] === value)?.[0] ?? null;
}
return null;
});
const distanceFromMyCity = (destination: number): number | null => {
const start = props.mapData?.myCity;
if (!start || !props.mapLayout) return null;
if (start === destination) return 0;
const paths = new Map(props.mapLayout.cityList.map((city) => [city.id, city.path]));
const visited = new Set<number>([start]);
let frontier = [start];
for (let distance = 1; frontier.length; distance += 1) {
const next: number[] = [];
for (const cityId of frontier) {
for (const adjacentId of paths.get(cityId) ?? []) {
if (visited.has(adjacentId)) continue;
if (adjacentId === destination) return distance;
visited.add(adjacentId);
next.push(adjacentId);
}
}
frontier = next;
}
return null;
};
const mapTargetSummary = computed(() => {
if (!props.mapData || !props.mapLayout) return '';
if (presentation.value.mapTarget === 'city' && mapSelectedCityId.value) {
const city = props.mapLayout.cityList.find((entry) => entry.id === mapSelectedCityId.value);
const dynamic = props.mapData.cityList.find((entry) => entry[0] === mapSelectedCityId.value);
if (!city) return '';
const nation = props.mapData.nationList.find((entry) => entry[0] === dynamic?.[3]);
const distance = distanceFromMyCity(city.id);
return [
city.name,
nation?.[1] ?? '무주',
props.mapLayout.regionMap[dynamic?.[4] ?? city.region],
props.mapLayout.levelMap[dynamic?.[1] ?? city.level],
distance === null ? null : `현재 도시에서 ${distance}`,
]
.filter(Boolean)
.join(' · ');
}
if (presentation.value.mapTarget === 'nation' && nationTargetField.value) {
const value = values[nationTargetField.value.key];
if (typeof value !== 'number') return '';
const nation = props.mapData.nationList.find((entry) => entry[0] === value);
if (!nation) return '';
const capital = props.mapLayout.cityList.find((entry) => entry.id === nation[3]);
const cityCount = props.mapData.cityList.filter((entry) => entry[3] === value).length;
return `${nation[1]} · 수도 ${capital?.name ?? '-'} · 도시 ${cityCount.toLocaleString()}`;
}
return '';
});
const selectMapCity = (cityId: number) => {
if (!props.mapData) return;
if (presentation.value.mapTarget === 'city' && cityTargetField.value) {
setSelectValue(cityTargetField.value, String(cityId));
return;
}
if (presentation.value.mapTarget === 'nation' && nationTargetField.value) {
const nationId = props.mapData.cityList.find((entry) => entry[0] === cityId)?.[3];
if (nationId && nationId > 0) setSelectValue(nationTargetField.value, String(nationId));
}
};
const resourceSummary = computed(() => {
const context: CommandInputContext | undefined = props.options.context;
if (!context) return [];
const result: string[] = [];
const usesActorResources = new Set(['che_증여', 'che_헌납', 'che_군량매매', 'che_장비매매']);
const usesNationResources = new Set(['che_몰수', 'che_포상', 'che_물자원조']);
if (usesActorResources.has(props.commandKey)) {
result.push(
`현재 자금 ${context.actorGold.toLocaleString()}`,
`현재 군량 ${context.actorRice.toLocaleString()}`
);
}
if (props.commandKey === 'che_장비매매' && context.citySecurity !== undefined) {
result.push(`현재 도시 치안 ${context.citySecurity.toLocaleString()}`);
}
if (usesNationResources.has(props.commandKey)) {
if (context.nationGold !== undefined) result.push(`국고 ${context.nationGold.toLocaleString()}`);
if (context.nationRice !== undefined) result.push(`국가 군량 ${context.nationRice.toLocaleString()}`);
if (context.nationLevel !== undefined) result.push(`국가 작위 ${context.nationLevel}`);
}
return result;
});
const setTupleValue = (field: CommandInputField, index: number, rawValue: string) => {
const tuple = Array.isArray(values[field.key]) ? [...(values[field.key] as unknown[])] : [0, 0];
tuple[index] = Number(rawValue);
@@ -89,17 +207,32 @@ const isValid = computed(() =>
const value = values[field.key];
if (field.kind === 'text') {
const length = typeof value === 'string' ? value.trim().length : 0;
return (!field.required || length > 0) && (field.min === undefined || length >= field.min) &&
(field.max === undefined || length <= field.max);
return (
(!field.required || length > 0) &&
(field.min === undefined || length >= field.min) &&
(field.max === undefined || length <= field.max)
);
}
if (field.kind === 'number') {
return typeof value === 'number' && Number.isFinite(value) &&
(field.min === undefined || value >= field.min) && (field.max === undefined || value <= field.max);
return (
typeof value === 'number' &&
Number.isFinite(value) &&
(field.min === undefined || value >= field.min) &&
(field.max === undefined || value <= field.max)
);
}
if (field.kind === 'numberTuple') {
return Array.isArray(value) && value.length === 2 &&
value.every((entry) => typeof entry === 'number' && Number.isFinite(entry) &&
(field.min === undefined || entry >= field.min) && (field.max === undefined || entry <= field.max));
return (
Array.isArray(value) &&
value.length === 2 &&
value.every(
(entry) =>
typeof entry === 'number' &&
Number.isFinite(entry) &&
(field.min === undefined || entry >= field.min) &&
(field.max === undefined || entry <= field.max)
)
);
}
if (field.kind === 'select') return optionsFor(field).some((option) => option.value === value);
return value !== undefined;
@@ -119,11 +252,28 @@ watch(
<template>
<div v-if="props.fields.length" class="command-argument-form" data-testid="command-argument-form">
<div
v-for="field in props.fields.filter((entry) => entry.kind !== 'hidden')"
:key="field.key"
class="argument-row"
>
<div v-if="showMap" class="command-map" data-testid="command-argument-map">
<MapViewer
:map-data="props.mapData ?? null"
:map-layout="props.mapLayout ?? null"
:loading="false"
:selected-city-id="mapSelectedCityId"
:detail-mode="false"
:fit-container="true"
@select-city="selectMapCity"
/>
<small>지도에서 도시를 클릭하거나 아래 목록에서 대상을 선택하세요.</small>
<div v-if="mapTargetSummary" class="map-target-summary" data-testid="command-map-target-summary">
{{ mapTargetSummary }}
</div>
</div>
<div v-if="presentation.lines.length" class="command-guidance" data-testid="command-argument-guidance">
<div v-for="line in presentation.lines" :key="line">{{ line }}</div>
</div>
<div v-if="resourceSummary.length" class="resource-summary" data-testid="command-resource-summary">
<span v-for="entry in resourceSummary" :key="entry">{{ entry }}</span>
</div>
<div v-for="field in visibleFields" :key="field.key" class="argument-row">
<label :for="`command-arg-${field.key}`">{{ field.label }}</label>
<input
v-if="field.kind === 'text'"
@@ -182,6 +332,21 @@ watch(
/>
</label>
</div>
<div
v-if="
field.kind === 'select' &&
(selectedOptionFor(field)?.description || selectedOptionFor(field)?.color)
"
class="option-detail"
>
<span
v-if="selectedOptionFor(field)?.color"
class="option-color"
:style="{ backgroundColor: selectedOptionFor(field)?.color }"
aria-hidden="true"
/>
<span>{{ selectedOptionFor(field)?.description }}</span>
</div>
</div>
<div v-if="!isValid" class="argument-error" role="alert">필수 입력을 확인하세요.</div>
</div>
@@ -193,6 +358,43 @@ watch(
font-size: 0.75rem;
}
.command-map {
width: 100%;
overflow: hidden;
background: #111;
}
.command-map small {
display: block;
padding: 5px 8px;
color: rgba(232, 221, 196, 0.72);
}
.map-target-summary {
padding: 0 8px 6px;
color: #f1d89a;
line-height: 1.35;
}
.command-guidance {
display: grid;
gap: 3px;
padding: 8px;
border-bottom: 1px solid rgba(201, 164, 90, 0.35);
background: #191919;
color: #eee;
line-height: 1.35;
}
.resource-summary {
display: flex;
flex-wrap: wrap;
gap: 5px 14px;
padding: 6px 8px;
border-bottom: 1px solid rgba(201, 164, 90, 0.25);
color: #f1d89a;
}
.argument-row {
display: grid;
grid-template-columns: minmax(76px, 0.36fr) 1fr;
@@ -200,6 +402,23 @@ watch(
align-items: center;
}
.option-detail {
grid-column: 2;
display: flex;
align-items: center;
gap: 6px;
padding: 0 6px 6px 0;
color: rgba(232, 221, 196, 0.74);
line-height: 1.35;
}
.option-color {
width: 18px;
height: 18px;
flex: 0 0 18px;
border: 1px solid #ddd;
}
.argument-row:nth-child(odd) {
background: rgba(255, 255, 255, 0.035);
}
@@ -2,7 +2,13 @@
import { computed } from 'vue';
import { addMinutes } from 'date-fns';
import ReservedCommandEditor from '../command/ReservedCommandEditor.vue';
import type { CommandPatternEntry, CommandTable, ReservedCommandRow } from '../command/types';
import type {
CommandMapData,
CommandMapLayout,
CommandPatternEntry,
CommandTable,
ReservedCommandRow,
} from '../command/types';
const props = defineProps<{
commandTable: CommandTable | null;
@@ -14,6 +20,8 @@ const props = defineProps<{
turnTermMinutes?: number;
autorunLimit?: number | null;
storageKey?: string;
mapData?: CommandMapData | null;
mapLayout?: CommandMapLayout | null;
}>();
const emit = defineEmits<{
@@ -63,6 +71,8 @@ const rows = computed<ReservedCommandRow[]>(() => {
:loading="props.loading"
:storage-key="props.storageKey ?? `core2026:general:${props.general?.id ?? 0}`"
:current-time="rows[0]?.time"
:map-data="props.mapData"
:map-layout="props.mapLayout"
@reserve-bulk="emit('set-general-turns', $event)"
@shift="emit('shift-general-turns', $event)"
@repeat="emit('repeat-general-turns', $event)"
@@ -1,9 +1,12 @@
<script setup lang="ts">
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';
import { DEFAULT_GENERAL_ICON_URL, resolveGeneralIconBackgroundImage } from '../../utils/generalIcon';
import { configuredGameAssetUrl } from '../../utils/imageAssets';
interface GeneralStats {
leadership: number;
@@ -19,9 +22,18 @@ interface GeneralProgression {
statUpgradeLimit?: number;
}
interface ItemDisplayNames {
horse?: string | null;
weapon?: string | null;
book?: string | null;
item?: string | null;
}
interface GeneralInfo {
id: number;
name: string;
picture?: string | null;
imageServer?: number | null;
npcState: number;
officerLevel: number;
officerLevelText: string;
@@ -35,17 +47,36 @@ interface GeneralInfo {
experience: number;
dedication: number;
age?: number;
turnTime?: string;
turnTime?: string | null;
troopId?: number;
crewTypeId?: number;
crewTypeName?: string;
traits?: { personal: string; specialWar: string; specialDomestic: string };
progression?: GeneralProgression;
itemNames?: ItemDisplayNames;
equipmentNames?: ItemDisplayNames;
}
const props = defineProps<{
general: GeneralInfo | null;
loading: boolean;
}>();
const props = withDefaults(
defineProps<{
general: GeneralInfo | null;
loading: boolean;
nationColor?: string | null;
defenceText?: string | null;
killTurn?: number | null;
remainingMinutes?: number | null;
troopText?: string | null;
penaltyText?: string | number | null;
}>(),
{
nationColor: '#173d27',
defenceText: null,
killTurn: null,
remainingMinutes: null,
troopText: null,
penaltyText: null,
}
);
const statRows = computed(() => {
const general = props.general;
@@ -72,137 +103,320 @@ const statRows = computed(() => {
const experiencePercent = computed(() =>
legacyExperiencePercent(props.general?.experience ?? 0, props.general?.progression?.experienceLevel ?? 0)
);
const itemNames = computed<ItemDisplayNames>(() => props.general?.itemNames ?? props.general?.equipmentNames ?? {});
const generalIconBackground = computed(() => resolveGeneralIconBackgroundImage(props.general ?? {}));
const crewTypeIconBackground = computed(() => {
const crewTypeId = props.general?.crewTypeId;
if (crewTypeId === undefined || !Number.isFinite(crewTypeId)) {
return `url(${JSON.stringify(DEFAULT_GENERAL_ICON_URL)})`;
}
const crewTypeUrl = `${configuredGameAssetUrl()}/crewtype${Math.trunc(crewTypeId)}.png`;
return `url(${JSON.stringify(crewTypeUrl)}), url(${JSON.stringify(DEFAULT_GENERAL_ICON_URL)})`;
});
const injuryInfo = computed(() => {
const injury = props.general?.injury ?? 0;
if (injury > 60) return { text: '위독', color: '#ff4d4f' };
if (injury > 40) return { text: '심각', color: '#ff00ff' };
if (injury > 20) return { text: '중상', color: '#ff9f1a' };
if (injury > 0) return { text: '경상', color: '#ffff00' };
return { text: '건강', color: '#ffffff' };
});
const isBrightColor = (color: string): boolean => {
const normalized = /^#[0-9a-f]{6}$/iu.test(color) ? color.slice(1) : '173d27';
const red = Number.parseInt(normalized.slice(0, 2), 16);
const green = Number.parseInt(normalized.slice(2, 4), 16);
const blue = Number.parseInt(normalized.slice(4, 6), 16);
return (red * 299 + green * 587 + blue * 114) / 1000 >= 150;
};
const titleStyle = computed(() => {
const backgroundColor = props.nationColor || '#173d27';
return {
backgroundColor,
color: isBrightColor(backgroundColor) ? '#000000' : '#ffffff',
};
});
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 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 displayDefence = computed(() => props.defenceText ?? '-');
const specialText = computed(() => {
const traits = props.general?.traits;
return traits ? `${traits.specialDomestic || '-'} / ${traits.specialWar || '-'}` : '-';
});
</script>
<template>
<div class="general-card">
<div v-if="props.loading">
<div class="general-card" data-general-basic-card>
<div v-if="props.loading" class="general-loading">
<SkeletonLines :lines="5" />
</div>
<div v-else-if="!props.general" class="empty">장수 정보를 불러오지 못했습니다.</div>
<div v-else class="general-body">
<div class="general-title">
{{ props.general.name }} · {{ props.general.officerLevelText }} · {{ props.general.age ?? '-' }} ·
다음
{{ props.general.turnTime ? formatSeoulHourMinute(props.general.turnTime) : '-' }}
</div>
<template v-else>
<div class="general-basic-grid general-body">
<span
class="general-image general-icon"
role="img"
:aria-label="`${props.general.name} 초상`"
:style="{ backgroundImage: generalIconBackground }"
/>
<div class="general-title battle-general-name" :style="titleStyle">
{{ props.general.name }} {{ props.general.officerLevelText }} |
<span :style="{ color: injuryInfo.color }">{{ injuryInfo.text }}</span> 다음
{{ props.general.turnTime ? formatSeoulHourMinute(props.general.turnTime) : '-' }}
</div>
<div class="stat-progress-grid">
<template v-for="stat of statRows" :key="stat.key">
<span class="cell-label">{{ stat.label }}</span>
<strong>{{ stat.value }}</strong>
<div class="bar-cell" :data-stat-progress="stat.key">
<LegacyProgressBar
:percent="stat.percent"
:label="`${stat.label} 성장 ${stat.accumulated} / ${stat.limit}`"
/>
</div>
<strong class="stat-value">
<span>{{ stat.value }}</span>
<span class="bar-cell" :data-stat-progress="stat.key">
<LegacyProgressBar
:percent="stat.percent"
:label="`${stat.label} 성장 ${stat.accumulated} / ${stat.limit}`"
/>
</span>
</strong>
</template>
</div>
<div class="legacy-grid">
<span>자금</span><strong>{{ props.general.gold.toLocaleString() }}</strong> <span>군량</span
><strong>{{ props.general.rice.toLocaleString() }}</strong> <span>병력</span
><strong>{{ props.general.crew.toLocaleString() }}</strong> <span>훈련</span
><strong>{{ props.general.train }}</strong> <span>사기</span><strong>{{ props.general.atmos }}</strong>
<span>부상</span><strong>{{ props.general.injury }}</strong> <span>병종</span
><strong>{{ 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>{{ props.general.progression?.dedicationText ?? '무품관' }}</strong> <span>공헌</span
><strong>{{ props.general.dedication.toLocaleString() }}</strong>
</div>
<span class="cell-label">명마</span><strong>{{ itemNames.horse ?? '-' }}</strong>
<span class="cell-label">무기</span><strong>{{ itemNames.weapon ?? '-' }}</strong>
<span class="cell-label">서적</span><strong>{{ itemNames.book ?? '-' }}</strong>
<div class="experience-row">
<span class="cell-label">Lv</span>
<strong>{{ props.general.progression?.experienceLevel ?? 0 }}</strong>
<div class="bar-cell" data-experience-progress>
<span
class="general-image general-crew-type-icon"
role="img"
:aria-label="`${props.general.crewTypeName ?? '병종'} 이미지`"
:style="{ backgroundImage: crewTypeIconBackground }"
/>
<span class="cell-label">자금</span><strong>{{ props.general.gold.toLocaleString('ko-KR') }}</strong>
<span class="cell-label">군량</span><strong>{{ props.general.rice.toLocaleString('ko-KR') }}</strong>
<span class="cell-label">도구</span><strong>{{ itemNames.item ?? '-' }}</strong>
<span class="cell-label">병종</span><strong>{{ props.general.crewTypeName ?? '-' }}</strong>
<span class="cell-label">병사</span><strong>{{ props.general.crew.toLocaleString('ko-KR') }}</strong>
<span class="cell-label">성격</span><strong>{{ props.general.traits?.personal ?? '-' }}</strong>
<span class="cell-label">훈련</span><strong>{{ props.general.train }}</strong>
<span class="cell-label">사기</span><strong>{{ props.general.atmos }}</strong>
<span class="cell-label">특기</span><strong :title="specialText">{{ specialText }}</strong>
<span class="cell-label level-label">Lv</span>
<strong class="level-value">{{ props.general.progression?.experienceLevel ?? 0 }}</strong>
<span class="experience-bar" data-experience-progress>
<LegacyProgressBar
:percent="experiencePercent"
:label="`경험 레벨 진행 ${experiencePercent.toFixed(1)}%`"
/>
</div>
<span class="experience-total">명성 {{ props.general.experience.toLocaleString() }}</span>
</span>
<span class="cell-label age-label">연령</span>
<strong class="age-value" :style="{ color: ageColor }">{{ props.general.age ?? '-' }}</strong>
<span class="cell-label defence-label">수비</span>
<strong class="defence-value">{{ displayDefence }}</strong>
<span class="cell-label kill-label">삭턴</span>
<strong class="kill-value">{{ props.killTurn === null ? '-' : `${props.killTurn}` }}</strong>
<span class="cell-label execute-label">실행</span>
<strong class="execute-value">{{
props.remainingMinutes === null ? '-' : `${props.remainingMinutes}분 남음`
}}</strong>
<span class="cell-label troop-label">부대</span>
<strong class="troop-value">{{ displayTroop }}</strong>
<span class="cell-label penalty-label">벌점</span>
<strong class="penalty-value">{{ displayPenalty }}</strong>
</div>
</div>
<slot name="details" />
</template>
</div>
</template>
<style scoped>
.general-title {
.general-card {
box-sizing: border-box;
height: 20px;
min-height: 20px;
padding: 1px 6px;
border-bottom: 1px solid #777;
background: #173d27;
text-align: center;
font-size: 12px;
font-weight: 700;
}
.stat-progress-grid {
display: grid;
grid-template-columns: repeat(3, minmax(30px, 1fr) minmax(34px, 1fr) 45px);
grid-auto-rows: 21px;
font-size: 12px;
}
.stat-progress-grid > *,
.legacy-grid > * {
box-sizing: border-box;
height: 21px;
min-height: 0;
border-right: 1px solid #666;
border-bottom: 1px solid #666;
padding: 2px 4px;
width: 100%;
min-width: 0;
overflow: hidden;
background-color: #172a52;
background-image: var(--sammo-texture-blue);
color: #fff;
font-size: 12px;
}
.general-basic-grid {
display: grid;
box-sizing: border-box;
width: 100%;
min-width: 0;
grid-template-columns: 64px repeat(3, minmax(30px, 2fr) minmax(60px, 5fr));
grid-template-rows: repeat(9, calc(64px / 3));
border-right: 1px solid #777;
border-bottom: 1px solid #777;
text-align: center;
}
.general-basic-grid > * {
box-sizing: border-box;
min-width: 0;
min-height: 0;
border-top: 1px solid #777;
border-left: 1px solid #777;
padding: 1px 3px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cell-label,
.legacy-grid > span {
background: rgb(20 75 42 / 70%);
.general-basic-grid > strong {
font-weight: 500;
text-align: center;
}
.stat-progress-grid > strong,
.legacy-grid > strong {
text-align: right;
font-weight: 400;
.cell-label {
background-color: rgb(20 75 42 / 70%);
}
.bar-cell {
.general-image {
display: block;
width: 64px;
height: 64px;
padding: 0;
background-position: center;
background-repeat: no-repeat;
background-size: contain;
pointer-events: none;
user-select: none;
-webkit-user-drag: none;
}
.general-icon {
grid-column: 1;
grid-row: 1 / 4;
}
.general-title {
grid-column: 2 / 8;
grid-row: 1;
font-size: 12px;
font-weight: 700;
line-height: 18px;
}
.stat-value {
display: grid;
grid-template-columns: minmax(22px, auto) minmax(26px, 1fr);
align-items: center;
gap: 2px;
}
.bar-cell,
.experience-bar {
display: grid;
align-content: center;
padding: 0 1px;
}
.legacy-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
grid-auto-rows: 21px;
font-size: 12px;
.general-crew-type-icon {
grid-column: 1;
grid-row: 4 / 7;
}
.experience-row {
display: grid;
box-sizing: border-box;
grid-template-columns: 32px 38px minmax(120px, 1fr) 112px;
height: 20px;
min-height: 20px;
border-bottom: 1px solid #666;
font-size: 12px;
.level-label {
grid-column: 1;
grid-row: 7;
}
.experience-row > * {
display: grid;
align-content: center;
box-sizing: border-box;
border-right: 1px solid #666;
padding: 1px 4px;
text-align: center;
.level-value {
grid-column: 2;
grid-row: 7;
}
.experience-bar {
grid-column: 3 / 6;
grid-row: 7;
}
.age-label {
grid-column: 6;
grid-row: 7;
}
.age-value {
grid-column: 7;
grid-row: 7;
}
.defence-label {
grid-column: 1;
grid-row: 8;
}
.defence-value {
grid-column: 2 / 4;
grid-row: 8;
}
.kill-label {
grid-column: 4;
grid-row: 8;
}
.kill-value {
grid-column: 5;
grid-row: 8;
}
.execute-label {
grid-column: 6;
grid-row: 8;
}
.execute-value {
grid-column: 7;
grid-row: 8;
}
.troop-label {
grid-column: 1;
grid-row: 9;
}
.troop-value {
grid-column: 2 / 4;
grid-row: 9;
}
.penalty-label {
grid-column: 4;
grid-row: 9;
}
.penalty-value {
grid-column: 5 / 8;
grid-row: 9;
}
.general-loading,
.empty {
min-height: 192px;
padding: 8px;
}
.empty {
@@ -1,5 +1,9 @@
<script setup lang="ts">
defineProps<{
import { computed } from 'vue';
import { resolveTournamentStageName } from '../../utils/tournamentStatus';
const props = defineProps<{
tournamentStage: number;
status: {
onlineUserCount: number;
onlineNations: string;
@@ -13,15 +17,24 @@ defineProps<{
} | null;
} | null;
}>();
const tournamentStatus = computed(() => resolveTournamentStageName(props.tournamentStage));
</script>
<template>
<section class="front-status" aria-label="접속 현황과 국가 방침">
<div class="status-row vote-status">
<RouterLink v-if="status?.latestVote" to="/survey">
<span class="vote-label">설문 진행 : </span>{{ status.latestVote.title }}
</RouterLink>
<span v-else class="vote-empty">진행중인 설문 없음</span>
<div class="activity-status" aria-label="설문과 토너먼트 진행 현황">
<div class="status-row tournament-status">
<RouterLink to="/tournament">
<span class="tournament-label">토너먼트: </span>{{ tournamentStatus }}
</RouterLink>
</div>
<div class="status-row vote-status">
<RouterLink v-if="status?.latestVote" to="/survey">
<span class="vote-label">설문: </span>{{ status.latestVote.title }}
</RouterLink>
<span v-else class="vote-empty">설문: 진행 중인 설문 없음</span>
</div>
</div>
<div class="status-row online-nations">접속중인 국가: {{ status?.onlineNations ?? '' }}</div>
<div class="status-row online-users"> 접속자 {{ status?.onlineGenerals ?? '' }}</div>
@@ -71,19 +84,28 @@ defineProps<{
margin: 0;
}
.vote-status {
width: 33.333333%;
.activity-status {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
width: 66.666667%;
margin-left: auto;
}
.activity-status .status-row {
padding-right: 0;
padding-left: 0;
text-align: center;
}
.vote-status a {
.activity-status a {
color: #fff;
text-decoration: gray underline;
}
.tournament-label {
color: #ffc107;
}
.vote-label {
color: cyan;
}
@@ -93,8 +115,8 @@ defineProps<{
}
@media (max-width: 991px) {
.vote-status {
width: 50%;
.activity-status {
width: 100%;
}
}
</style>
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue';
import { RouterLink } from 'vue-router';
interface MapCityView {
id: number;
name: string;
@@ -20,6 +21,7 @@ const props = defineProps<{
city: MapCityView;
showName: boolean;
mapScale: number;
selectOnly?: boolean;
}>();
const emit = defineEmits<{
@@ -31,12 +33,15 @@ const emit = defineEmits<{
const size = computed(() => (6 + props.city.level * 2) * props.mapScale);
const stateSize = computed(() => 8 * props.mapScale);
const stateOffset = computed(() => -6 * props.mapScale);
const selectCity = () => emit('select', props.city.id);
</script>
<template>
<RouterLink
<component
:is="props.selectOnly ? 'button' : RouterLink"
class="map-city"
:to="{ name: 'current-city', query: { cityId: props.city.id } }"
:type="props.selectOnly ? 'button' : undefined"
:to="props.selectOnly ? undefined : { name: 'current-city', query: { cityId: props.city.id } }"
:class="[
`state-${props.city.stateClass}`,
{ mine: props.city.isMyCity, selected: props.city.selected, 'supply-off': !props.city.supply },
@@ -44,7 +49,7 @@ const stateOffset = computed(() => -6 * props.mapScale);
:style="{ left: `${props.city.x}px`, top: `${props.city.y}px` }"
@mouseenter="emit('hover', props.city.id)"
@mouseleave="emit('leave')"
@click.stop="emit('select', props.city.id)"
@click.stop="selectCity"
>
<div class="city-dot" :style="{ backgroundColor: props.city.color, width: `${size}px`, height: `${size}px` }">
<span v-if="props.city.isCapital" class="capital" />
@@ -61,7 +66,7 @@ const stateOffset = computed(() => -6 * props.mapScale);
}"
/>
<div v-if="props.showName" class="city-name">{{ props.city.name }}</div>
</RouterLink>
</component>
</template>
<style scoped>
@@ -76,6 +81,9 @@ const stateOffset = computed(() => -6 * props.mapScale);
color: rgba(232, 221, 196, 0.8);
cursor: pointer;
text-decoration: none;
padding: 0;
border: 0;
background: transparent;
}
.city-dot {
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue';
import { RouterLink } from 'vue-router';
import { buildAssetUrl, normalizeColorToken } from '../../utils/mapAssets';
interface MapCityView {
@@ -46,6 +47,7 @@ const props = defineProps<{
imageBaseUrl: string;
themeName: string;
mapScale: number;
selectOnly?: boolean;
}>();
const emit = defineEmits<{
@@ -141,6 +143,8 @@ const capitalIconStyle = computed(() => ({
height: `${10 * props.mapScale}px`,
}));
const selectCity = () => emit('select', props.city.id);
const cityStateStyle = computed(() => ({
width: `${12 * props.mapScale}px`,
height: `${12 * props.mapScale}px`,
@@ -149,14 +153,16 @@ const cityStateStyle = computed(() => ({
</script>
<template>
<RouterLink
<component
:is="props.selectOnly ? 'button' : RouterLink"
class="city-base"
:to="{ name: 'current-city', query: { cityId: props.city.id } }"
:type="props.selectOnly ? 'button' : undefined"
:to="props.selectOnly ? undefined : { name: 'current-city', query: { cityId: props.city.id } }"
:class="[{ mine: props.city.isMyCity, selected: props.city.selected, 'supply-off': !props.city.supply }]"
:style="cityBaseStyle"
@mouseenter="emit('hover', props.city.id)"
@mouseleave="emit('leave')"
@click.stop="emit('select', props.city.id)"
@click.stop="selectCity"
>
<div v-if="cityBgStyle" class="city-bg" :style="[cityBgWrapperStyle, cityBgStyle]" />
<div class="city-img" :style="cityIconStyle">
@@ -173,7 +179,7 @@ const cityStateStyle = computed(() => ({
<div v-if="stateIcon" class="city-state" :style="cityStateStyle">
<img :src="stateIcon" />
</div>
</RouterLink>
</component>
</template>
<style scoped>
@@ -184,6 +190,9 @@ const cityStateStyle = computed(() => ({
color: #fff;
cursor: auto;
text-decoration: none;
padding: 0;
border: 0;
background: transparent;
}
.city-bg {
@@ -67,6 +67,13 @@ const props = defineProps<{
mapData: MapSummary | null;
mapLayout: MapLayout | null;
loading: boolean;
selectedCityId?: number | null;
detailMode?: boolean;
fitContainer?: boolean;
}>();
const emit = defineEmits<{
(event: 'select-city', cityId: number): void;
}>();
const BASE_MAP_WIDTH = 700;
@@ -75,7 +82,12 @@ const SMALL_MAP_SCALE = 5 / 7;
const isWide = useMediaQuery('(min-width: 1024px)');
const mapStore = useMapViewerStore();
const { showCityName, detailMode, hoveredCityId, selectedCityId } = storeToRefs(mapStore);
const {
showCityName,
detailMode: storeDetailMode,
hoveredCityId,
selectedCityId: storeSelectedCityId,
} = storeToRefs(mapStore);
const mapArea = ref<HTMLElement | null>(null);
const mapBody = ref<HTMLElement | null>(null);
@@ -140,15 +152,20 @@ const dynamicCityById = computed(() => {
});
const mapScale = computed(() => {
if (isWide.value) {
if (isWide.value && !props.fitContainer) {
return 1;
}
if (mapBodyWidth.value <= 0) {
return SMALL_MAP_SCALE;
}
return Math.min(SMALL_MAP_SCALE, mapBodyWidth.value / BASE_MAP_WIDTH);
return Math.min(props.fitContainer ? 1 : SMALL_MAP_SCALE, mapBodyWidth.value / BASE_MAP_WIDTH);
});
const effectiveDetailMode = computed(() => props.detailMode ?? storeDetailMode.value);
const effectiveSelectedCityId = computed(() =>
props.selectedCityId === undefined ? storeSelectedCityId.value : props.selectedCityId
);
const mapWidth = computed(() => `${BASE_MAP_WIDTH * mapScale.value}px`);
const mapHeight = computed(() => `${BASE_MAP_HEIGHT * mapScale.value}px`);
@@ -185,7 +202,7 @@ const cityViews = computed<CityView[]>(() => {
y,
isCapital: nation?.capitalCityId === layoutCity.id,
isMyCity: props.mapData?.myCity === layoutCity.id,
selected: selectedCityId.value === layoutCity.id,
selected: effectiveSelectedCityId.value === layoutCity.id,
};
});
});
@@ -258,7 +275,7 @@ const titleTooltipLines = computed(() => {
});
const titleBandStyle = computed(() =>
detailMode.value
effectiveDetailMode.value
? {
backgroundImage: `url('${resolveAsset('ltitle.jpg')}'), url('${resolveAsset('rtitle.jpg')}')`,
}
@@ -266,7 +283,7 @@ const titleBandStyle = computed(() =>
);
const titleTextStyle = computed(() =>
detailMode.value
effectiveDetailMode.value
? {
color: titleColor.value,
backgroundImage: `url('${resolveAsset('ad.gif')}'), url('${resolveAsset(`${mapSeason.value}.gif`)}')`,
@@ -327,7 +344,7 @@ const mapRoadStyle = computed(() => ({
}));
const detailProps = computed(() =>
detailMode.value
effectiveDetailMode.value
? {
imageBaseUrl: assetBaseUrl.value,
themeName: mapTheme.value,
@@ -365,7 +382,10 @@ const setHoveredCity = (cityId: number | null) => {
};
const selectCity = (cityId: number) => {
mapStore.setSelectedCity(cityId);
emit('select-city', cityId);
if (props.selectedCityId === undefined) {
mapStore.setSelectedCity(cityId);
}
};
</script>
@@ -394,12 +414,13 @@ const selectCity = (cityId: number) => {
<div class="map-layer map-bglayer2" />
<div v-if="mapRoadImage" class="map-layer map-bgroad" :style="mapRoadStyle" />
<component
:is="detailMode ? MapCityDetail : MapCityBasic"
:is="effectiveDetailMode ? MapCityDetail : MapCityBasic"
v-for="city in cityViews"
:key="city.id"
:city="city"
:map-scale="mapScale"
:show-name="showCityName"
:select-only="props.selectedCityId !== undefined"
v-bind="detailProps"
@hover="setHoveredCity"
@leave="setHoveredCity(null)"
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue';
import GeneralIdentity from '../ui/GeneralIdentity.vue';
import {
buildTournamentBracket,
type TournamentBracketMatch,
@@ -89,7 +90,12 @@ const odds = (id: number | null) => {
:class="{ advanced: bracket.champion.advanced }"
:data-general-id="bracket.champion.id ?? undefined"
>
{{ bracket.champion.name }}
<GeneralIdentity
:name="bracket.champion.name"
:picture="bracket.champion.picture"
:image-server="bracket.champion.imageServer"
:icon-size="24"
/>
</span>
</div>
@@ -110,7 +116,12 @@ const odds = (id: number | null) => {
:class="{ advanced: slot.advanced }"
:data-general-id="slot.id ?? undefined"
>
{{ slot.name }}
<GeneralIdentity
:name="slot.name"
:picture="slot.picture"
:image-server="slot.imageServer"
:icon-size="22"
/>
</span>
</div>
<div class="connector-row" :style="{ '--connector-count': round.slots.length }">
@@ -140,7 +151,12 @@ const odds = (id: number | null) => {
:class="{ advanced: slot.advanced }"
:data-general-id="slot.id ?? undefined"
>
{{ slot.name }}
<GeneralIdentity
:name="slot.name"
:picture="slot.picture"
:image-server="slot.imageServer"
:icon-size="20"
/>
</span>
</div>
<div class="bracket-round bracket-odds" :style="roundStyle(bracket.top16)">
@@ -183,9 +199,17 @@ const odds = (id: number | null) => {
:key="`mobile-${columnIndex}-${slot.id ?? 'empty'}-${slotIndex}`"
class="mobile-bracket-name"
:class="{ advanced: slot.advanced }"
:style="{ left: `${mobileX[columnIndex]}px`, top: `${mobileY(columnIndex, slotIndex)}px` }"
:style="{
left: `${(mobileX[columnIndex]! / 390) * 100}%`,
top: `${mobileY(columnIndex, slotIndex)}px`,
}"
>
{{ slot.name }}
<GeneralIdentity
:name="slot.name"
:picture="slot.picture"
:image-server="slot.imageServer"
:icon-size="18"
/>
</span>
</template>
</div>
@@ -210,21 +234,23 @@ const odds = (id: number | null) => {
white-space: nowrap;
}
.bracket-canvas {
width: 2000px;
min-width: 2000px;
width: 100%;
min-width: 1000px;
max-width: 1200px;
margin: 0 auto;
}
.mobile-bracket {
position: relative;
display: none;
width: 390px;
width: 100%;
max-width: 390px;
height: 544px;
margin: 0 auto;
}
.mobile-bracket svg {
position: absolute;
inset: 0;
width: 390px;
width: 100%;
height: 544px;
}
.mobile-connector {
@@ -239,14 +265,16 @@ const odds = (id: number | null) => {
.mobile-bracket-name {
position: absolute;
z-index: 1;
width: 64px;
width: clamp(58px, 18vw, 72px);
overflow: hidden;
transform: translate(-50%, -50%);
border: 1px solid #555;
background: rgb(58 33 24 / 92%);
color: #fff;
font-size: 12px;
line-height: 22px;
min-height: 26px;
padding: 2px;
font-size: 11px;
line-height: 20px;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -265,7 +293,7 @@ const odds = (id: number | null) => {
}
.bracket-name {
overflow: hidden;
padding: 0 3px;
padding: 2px 3px;
color: #fff;
text-overflow: ellipsis;
white-space: nowrap;
@@ -321,8 +349,8 @@ const odds = (id: number | null) => {
}
@media (max-width: 800px) {
.tournament-bracket {
width: 100vw;
max-width: 100vw;
width: 100%;
max-width: 100%;
overflow-x: hidden;
}
.bracket-canvas {
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { computed } from 'vue';
import { resolveGeneralIconUrl, useDefaultGeneralIcon, type GeneralIconSource } from '../../utils/generalIcon';
const props = withDefaults(
defineProps<{
name: string;
picture?: GeneralIconSource['picture'];
imageServer?: GeneralIconSource['imageServer'];
iconSize?: number;
hideIcon?: boolean;
}>(),
{
picture: null,
imageServer: 0,
iconSize: 28,
hideIcon: false,
}
);
const iconUrl = computed(() =>
resolveGeneralIconUrl({
picture: props.picture,
imageServer: props.imageServer,
})
);
const identityStyle = computed(() => ({ '--general-identity-icon-size': `${props.iconSize}px` }));
</script>
<template>
<span class="general-identity" :style="identityStyle">
<img
v-if="!hideIcon && name !== '-'"
class="general-identity-icon"
:src="iconUrl"
alt=""
aria-hidden="true"
@error="useDefaultGeneralIcon"
/>
<span class="general-identity-name">{{ name }}</span>
</span>
</template>
<style scoped>
.general-identity {
display: inline-flex;
min-width: 0;
max-width: 100%;
align-items: center;
justify-content: center;
gap: 5px;
vertical-align: middle;
}
.general-identity-icon {
width: var(--general-identity-icon-size);
height: var(--general-identity-icon-size);
flex: 0 0 var(--general-identity-icon-size);
border: 1px solid rgb(255 255 255 / 28%);
background: #111;
object-fit: cover;
}
.general-identity-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
@@ -15,7 +15,9 @@ type GeneralProgress = {
};
};
const props = defineProps<{ general: GeneralProgress }>();
const props = withDefaults(defineProps<{ general: GeneralProgress; showPrimary?: boolean }>(), {
showPrimary: true,
});
const statRows = computed(() =>
[
@@ -50,7 +52,7 @@ const experiencePercent = computed(() =>
<template>
<div class="legacy-general-progress">
<div class="stat-grid">
<div v-if="props.showPrimary" class="stat-grid">
<template v-for="stat of statRows" :key="stat.key">
<span class="cell-label">{{ stat.label }}</span>
<strong>{{ stat.value }}</strong>
@@ -60,7 +62,7 @@ const experiencePercent = computed(() =>
/>
</template>
</div>
<div class="experience-row">
<div v-if="props.showPrimary" class="experience-row">
<span class="cell-label">Lv</span>
<strong>{{ props.general.progression.experienceLevel }}</strong>
<LegacyProgressBar
+4 -22
View File
@@ -1,24 +1,6 @@
const KOREA_TIME_OFFSET_MS = 9 * 60 * 60 * 1000;
import { formatServerDateTime } from '@sammo-ts/common';
const pad = (value: number): string => String(value).padStart(2, '0');
export const formatSeoulDateTime = (value: string | Date): string => formatServerDateTime(value);
export const formatSeoulDateTime = (value: string | Date): string => {
if (
typeof value === 'string' &&
!/(?:Z|[+-]\d{2}:?\d{2})$/i.test(value.trim())
) {
return value.trim().replace('T', ' ').slice(0, 19);
}
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) {
return typeof value === 'string' ? value.slice(0, 19) : '';
}
const koreaTime = new Date(date.getTime() + KOREA_TIME_OFFSET_MS);
return `${koreaTime.getUTCFullYear()}-${pad(koreaTime.getUTCMonth() + 1)}-${pad(
koreaTime.getUTCDate()
)} ${pad(koreaTime.getUTCHours())}:${pad(koreaTime.getUTCMinutes())}:${pad(
koreaTime.getUTCSeconds()
)}`;
};
export const formatSeoulHourMinute = (value: string | Date): string => formatSeoulDateTime(value).slice(11, 16);
export const formatSeoulHourMinute = (value: string | Date): string =>
formatServerDateTime(value, { format: 'hourMinute' });
@@ -0,0 +1,299 @@
export type NationGeneralColumnId =
| 'icon'
| 'name'
| 'officerLevel'
| 'expDedLv_1'
| 'dedlevel'
| 'explevel'
| 'stat_1'
| 'leadership'
| 'strength'
| 'intel'
| 'troop'
| 'goldRice_1'
| 'gold'
| 'rice'
| 'city'
| 'crew'
| 'specials_1'
| 'personal'
| 'specialDomestic'
| 'specialWar'
| 'years_1'
| 'belong'
| 'killturnAndRefresh_1'
| 'refreshScoreTotal';
export type NationGeneralGroupId = 'expDedLv' | 'stat' | 'goldRice' | 'specials' | 'years' | 'killturnAndRefresh';
export type SortDirection = 'asc' | 'desc';
export type NationGeneralViewMode = 'normal' | 'war';
export type NationGeneralColumnState = {
colId: NationGeneralColumnId;
width: number;
hide: boolean;
sort: SortDirection | null;
sortIndex?: number;
};
export type NationGeneralGroupState = {
groupId: NationGeneralGroupId;
open: boolean;
};
export type NationGeneralDisplaySetting = {
column: NationGeneralColumnState[];
columnGroup: NationGeneralGroupState[];
};
export type NationGeneralSettingKey = [true, NationGeneralViewMode] | [false, string];
export const DISPLAY_SETTINGS_KEY = 'GeneralListDisplaySetting';
export const DISPLAY_SETTINGS_VERSION = 1;
export const lastUsedSettingsKey = (role: string): string => `LastUsedSettingsKey_${role}`;
const baseColumns = (): NationGeneralColumnState[] => [
{ colId: 'icon', width: 80, hide: false, sort: null },
{ colId: 'name', width: 126, hide: false, sort: null },
{ colId: 'officerLevel', width: 70, hide: false, sort: null },
{ colId: 'expDedLv_1', width: 60, hide: false, sort: null },
{ colId: 'dedlevel', width: 70, hide: false, sort: null },
{ colId: 'explevel', width: 60, hide: false, sort: null },
{ colId: 'stat_1', width: 88, hide: false, sort: null },
{ colId: 'leadership', width: 60, hide: false, sort: null },
{ colId: 'strength', width: 60, hide: false, sort: null },
{ colId: 'intel', width: 60, hide: false, sort: null },
{ colId: 'troop', width: 90, hide: true, sort: null },
{ colId: 'goldRice_1', width: 80, hide: false, sort: null },
{ colId: 'gold', width: 70, hide: false, sort: null },
{ colId: 'rice', width: 70, hide: false, sort: null },
{ colId: 'city', width: 60, hide: true, sort: null },
{ colId: 'crew', width: 70, hide: true, sort: null },
{ colId: 'specials_1', width: 80, hide: false, sort: null },
{ colId: 'personal', width: 60, hide: false, sort: null },
{ colId: 'specialDomestic', width: 60, hide: false, sort: null },
{ colId: 'specialWar', width: 60, hide: false, sort: null },
{ colId: 'years_1', width: 60, hide: false, sort: null },
{ colId: 'belong', width: 60, hide: false, sort: null },
{ colId: 'killturnAndRefresh_1', width: 70, hide: false, sort: null },
{ colId: 'refreshScoreTotal', width: 70, hide: false, sort: null },
];
const groupState = (overrides: Partial<Record<NationGeneralGroupId, boolean>>): NationGeneralGroupState[] =>
(['expDedLv', 'stat', 'goldRice', 'specials', 'years', 'killturnAndRefresh'] as const).map((groupId) => ({
groupId,
open: overrides[groupId] ?? false,
}));
const withColumnOverrides = (
columns: NationGeneralColumnState[],
overrides: Partial<Record<NationGeneralColumnId, Partial<NationGeneralColumnState>>>
): NationGeneralColumnState[] =>
columns.map((column) => ({
...column,
...overrides[column.colId],
}));
export const defaultNationGeneralDisplaySettings: Record<NationGeneralViewMode, NationGeneralDisplaySetting> = {
normal: {
column: withColumnOverrides(baseColumns(), {
troop: { hide: true },
city: { hide: true },
crew: { hide: true },
refreshScoreTotal: { sort: 'desc', sortIndex: 0 },
}),
columnGroup: groupState({
expDedLv: true,
stat: true,
goldRice: true,
specials: false,
years: false,
killturnAndRefresh: true,
}),
},
war: {
column: withColumnOverrides(baseColumns(), {
icon: { hide: true },
officerLevel: { hide: true },
expDedLv_1: { hide: true },
dedlevel: { hide: true },
explevel: { hide: true },
troop: { hide: false },
city: { hide: false },
crew: { hide: false },
specials_1: { hide: true },
personal: { hide: true },
specialDomestic: { hide: true },
specialWar: { hide: true },
years_1: { hide: true },
belong: { hide: true },
killturnAndRefresh_1: { hide: true },
refreshScoreTotal: { hide: true },
}),
columnGroup: groupState({
expDedLv: false,
stat: false,
goldRice: true,
specials: false,
years: false,
killturnAndRefresh: true,
}),
},
};
export const cloneNationGeneralDisplaySetting = (
setting: NationGeneralDisplaySetting
): NationGeneralDisplaySetting => ({
column: setting.column.map((column) => ({ ...column })),
columnGroup: setting.columnGroup.map((group) => ({ ...group })),
});
const validColumnIds = new Set<NationGeneralColumnId>(baseColumns().map((column) => column.colId));
const validGroupIds = new Set<NationGeneralGroupId>(groupState({}).map((group) => group.groupId));
const isSortDirection = (value: unknown): value is SortDirection => value === 'asc' || value === 'desc';
export const normalizeNationGeneralDisplaySetting = (raw: unknown): NationGeneralDisplaySetting | null => {
if (!raw || typeof raw !== 'object') {
return null;
}
const candidate = raw as { column?: unknown; columnGroup?: unknown };
if (!Array.isArray(candidate.column) || !Array.isArray(candidate.columnGroup)) {
return null;
}
const fallback = cloneNationGeneralDisplaySetting(defaultNationGeneralDisplaySettings.normal);
const rawColumns = new Map<string, Record<string, unknown>>();
for (const value of candidate.column) {
if (!value || typeof value !== 'object') continue;
const column = value as Record<string, unknown>;
if (typeof column.colId === 'string' && validColumnIds.has(column.colId as NationGeneralColumnId)) {
rawColumns.set(column.colId, column);
}
}
fallback.column = fallback.column.map((column) => {
const saved = rawColumns.get(column.colId);
if (!saved) return column;
return {
...column,
width: typeof saved.width === 'number' && saved.width > 0 ? saved.width : column.width,
hide: typeof saved.hide === 'boolean' ? saved.hide : column.hide,
sort: isSortDirection(saved.sort) ? saved.sort : null,
...(typeof saved.sortIndex === 'number' && saved.sortIndex >= 0
? { sortIndex: Math.trunc(saved.sortIndex) }
: {}),
};
});
const rawGroups = new Map<string, boolean>();
for (const value of candidate.columnGroup) {
if (!value || typeof value !== 'object') continue;
const group = value as Record<string, unknown>;
if (
typeof group.groupId === 'string' &&
validGroupIds.has(group.groupId as NationGeneralGroupId) &&
typeof group.open === 'boolean'
) {
rawGroups.set(group.groupId, group.open);
}
}
fallback.columnGroup = fallback.columnGroup.map((group) => ({
...group,
open: rawGroups.get(group.groupId) ?? group.open,
}));
return fallback;
};
export const parseStoredDisplaySettings = (raw: string | null): Map<string, NationGeneralDisplaySetting> => {
if (!raw) return new Map();
try {
const parsed = JSON.parse(raw) as { version?: unknown; settings?: unknown };
if (parsed.version !== DISPLAY_SETTINGS_VERSION || !Array.isArray(parsed.settings)) return new Map();
const result = new Map<string, NationGeneralDisplaySetting>();
for (const entry of parsed.settings) {
if (!Array.isArray(entry) || typeof entry[0] !== 'string') continue;
const setting = normalizeNationGeneralDisplaySetting(entry[1]);
if (setting) result.set(entry[0], setting);
}
return result;
} catch {
return new Map();
}
};
export const serializeDisplaySettings = (settings: Map<string, NationGeneralDisplaySetting>): string =>
JSON.stringify({
version: DISPLAY_SETTINGS_VERSION,
settings: [...settings.entries()],
});
export const parseStoredSettingKey = (raw: string | null): NationGeneralSettingKey | null => {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as unknown;
if (
!Array.isArray(parsed) ||
parsed.length !== 2 ||
typeof parsed[0] !== 'boolean' ||
typeof parsed[1] !== 'string'
) {
return null;
}
if (parsed[0]) return parsed[1] === 'normal' || parsed[1] === 'war' ? [true, parsed[1]] : null;
return [false, parsed[1]];
} catch {
return null;
}
};
const initialConsonants = 'ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ';
const hangulInitials = (value: string): string =>
[...value]
.map((character) => {
const code = character.charCodeAt(0);
if (code < 0xac00 || code > 0xd7a3) return character;
return initialConsonants[Math.floor((code - 0xac00) / 588)] ?? character;
})
.join('');
const normalizeSearchText = (value: string): string => value.toLocaleLowerCase('ko-KR').replace(/\s+/g, '');
export const matchesKoreanSearch = (value: string, query: string): boolean => {
const normalizedQuery = normalizeSearchText(query);
if (!normalizedQuery) return true;
const normalizedValue = normalizeSearchText(value);
return (
normalizedValue.includes(normalizedQuery) ||
normalizeSearchText(hangulInitials(value)).includes(normalizedQuery)
);
};
export const matchesNumberSearch = (value: number | null, query: string): boolean => {
const normalized = query.trim();
if (!normalized) return true;
if (value === null || !Number.isFinite(value)) return false;
const match = /^(<=|>=|<|>|=)?\s*(-?\d+(?:\.\d+)?)$/.exec(normalized);
if (!match) return false;
const expected = Number(match[2]);
switch (match[1] ?? '=') {
case '<':
return value < expected;
case '<=':
return value <= expected;
case '>':
return value > expected;
case '>=':
return value >= expected;
default:
return value === expected;
}
};
export const compareGridValues = (left: string | number | null, right: string | number | null): number => {
if (left === right) return 0;
if (left === null) return 1;
if (right === null) return -1;
if (typeof left === 'number' && typeof right === 'number') return left - right;
return String(left).localeCompare(String(right), 'ko-KR', { numeric: true });
};
@@ -1,6 +1,8 @@
export interface TournamentBracketParticipant {
id: number;
name: string;
picture?: string | null;
imageServer?: number | null;
}
export interface TournamentBracketMatch {
@@ -15,6 +17,8 @@ export interface TournamentBracketMatch {
export interface TournamentBracketSlot {
id: number | null;
name: string;
picture: string | null;
imageServer: number;
advanced: boolean;
}
@@ -31,7 +35,13 @@ export interface TournamentBracketModel {
top16: TournamentBracketRound;
}
const emptySlot = (): TournamentBracketSlot => ({ id: null, name: '-', advanced: false });
const emptySlot = (): TournamentBracketSlot => ({
id: null,
name: '-',
picture: null,
imageServer: 0,
advanced: false,
});
export const buildTournamentBracket = (
participants: TournamentBracketParticipant[],
@@ -39,19 +49,24 @@ export const buildTournamentBracket = (
winnerId?: number
): TournamentBracketModel => {
const participantsById = new Map(participants.map((participant) => [participant.id, participant]));
const nameOf = (id: number | null): string =>
id === null ? '-' : (participantsById.get(id)?.name ?? `#${id}`);
const participantOf = (id: number | null): TournamentBracketParticipant | null =>
id === null ? null : (participantsById.get(id) ?? { id, name: `#${id}` });
const buildRound = (stage: number, slotCount: number): TournamentBracketRound => {
const roundMatches = matches
.filter((match) => match.stage === stage)
.sort((lhs, rhs) => lhs.roundIndex - rhs.roundIndex || lhs.id - rhs.id);
const slots: TournamentBracketSlot[] = roundMatches.flatMap((match) =>
[match.attackerId, match.defenderId].map((id) => ({
id,
name: nameOf(id),
advanced: match.winnerId === id,
}))
[match.attackerId, match.defenderId].map((id) => {
const participant = participantOf(id);
return {
id,
name: participant?.name ?? '-',
picture: participant?.picture ?? null,
imageServer: participant?.imageServer ?? 0,
advanced: match.winnerId === id,
};
})
);
while (slots.length < slotCount) {
slots.push(emptySlot());
@@ -65,7 +80,9 @@ export const buildTournamentBracket = (
return {
champion: {
id: resolvedWinnerId,
name: nameOf(resolvedWinnerId),
name: participantOf(resolvedWinnerId)?.name ?? '-',
picture: participantOf(resolvedWinnerId)?.picture ?? null,
imageServer: participantOf(resolvedWinnerId)?.imageServer ?? 0,
advanced: resolvedWinnerId !== null,
},
final,
@@ -0,0 +1,15 @@
export const tournamentStageNames = [
'경기 없음',
'참가 모집중',
'예선 진행중',
'본선 추첨중',
'본선 진행중',
'16강 배정중',
'베팅 진행중',
'16강 진행중',
'8강 진행중',
'4강 진행중',
'결승 진행중',
] as const;
export const resolveTournamentStageName = (stage: number): string => tournamentStageNames[stage] ?? '상태 확인 중';
+5 -18
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
@@ -41,24 +42,10 @@ const formatNumber = (value: number | null | undefined): string => (value ?? 0).
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 '-';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value.slice(5, showSecond ? 19 : 16);
}
const parts = new Intl.DateTimeFormat('ko-KR', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
...(showSecond ? { second: '2-digit' } : {}),
hour12: false,
}).formatToParts(date);
const part = (type: Intl.DateTimeFormatPartTypes): string =>
parts.find((entry) => entry.type === type)?.value ?? '';
return `${part('month')}-${part('day')} ${part('hour')}:${part('minute')}${showSecond ? `:${part('second')}` : ''}`;
return formatServerDateTime(value, {
format: showSecond ? 'monthDayTimeSeconds' : 'monthDayTime',
fallback: '-',
});
};
const buyRice = computed(() =>
@@ -1,13 +1,14 @@
<script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue';
import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
import { trpc } from '../utils/trpc';
import { getNpcColor } from '../utils/npcColor';
import { formatLog } from '../utils/formatLog';
import { resolveGeneralIconUrl } from '../utils/generalIcon';
type BattleCenterResponse = Awaited<ReturnType<typeof trpc.nation.getBattleCenter.query>>;
type GeneralEntry = BattleCenterResponse['generals'][number];
@@ -126,9 +127,9 @@ const selectedGeneral = computed(() => {
const formatGeneralLabel = (general: GeneralEntry): string => {
const name = general.officerLevel > 4 ? `*${general.name}*` : general.name;
const time = general.turnTime ? general.turnTime.slice(-5) : '--:--';
const time = formatServerDateTime(general.turnTime, { format: 'hourMinute', fallback: '--:--' });
if (orderBy.value === 'recentWar') {
return `${name} (${general.recentWar ? general.recentWar.slice(-5) : '--:--'})`;
return `${name} (${formatServerDateTime(general.recentWar, { format: 'hourMinute', fallback: '--:--' })})`;
}
if (orderBy.value === 'warnum') {
return `${name} (${general.warnum}회)`;
@@ -136,8 +137,6 @@ const formatGeneralLabel = (general: GeneralEntry): string => {
return `${name} (${time})`;
};
const generalImageUrl = (general: GeneralEntry): string => resolveGeneralIconUrl(general);
let logRequestId = 0;
const loadLogs = async (generalId: number) => {
@@ -156,7 +155,10 @@ const loadLogs = async (generalId: number) => {
}
for (const response of responses) {
const formatted = response.logs.map((entry) => {
const eventTime = response.type === 'generalAction' ? ` ${entry.createdAt.slice(-8, -3)}` : '';
const eventTime =
response.type === 'generalAction'
? ` ${formatServerDateTime(entry.createdAt, { format: 'hourMinute' })}`
: '';
return {
id: entry.id,
html: formatLog(`${entry.text}${eventTime}`),
@@ -266,49 +268,36 @@ onMounted(() => {
</PanelCard>
<PanelCard title="장수 정보">
<SkeletonLines v-if="loading" :lines="5" />
<div v-else-if="selectedGeneral" class="battle-general-card">
<div class="battle-general-name">
{{ selectedGeneral.name }} ({{ selectedGeneral.officerLevelText }})
</div>
<span
class="battle-general-portrait"
role="img"
:aria-label="`${selectedGeneral.name} 초상`"
:style="{ backgroundImage: `url(${generalImageUrl(selectedGeneral)})` }"
/>
<div class="battle-general-grid">
<span>통솔</span><strong>{{ selectedGeneral.stats.leadership }}</strong> <span>무력</span
><strong>{{ selectedGeneral.stats.strength }}</strong> <span>지력</span
><strong>{{ selectedGeneral.stats.intelligence }}</strong> <span>자금</span
><strong>{{ selectedGeneral.gold }}</strong> <span>군량</span
><strong>{{ selectedGeneral.rice }}</strong> <span>병력</span
><strong>{{ selectedGeneral.crew }}</strong> <span>훈련</span
><strong>{{ selectedGeneral.train }}</strong> <span>사기</span
><strong>{{ selectedGeneral.atmos }}</strong> <span>부상</span
><strong>{{ selectedGeneral.injury }}</strong> <span>경험</span
><strong>{{ selectedGeneral.experience }}</strong> <span>공헌</span
><strong>{{ selectedGeneral.dedication }}</strong> <span>전투</span
><strong>{{ selectedGeneral.warnum }}</strong>
</div>
<div class="battle-general-extra">
<span>명성</span><strong>{{ selectedGeneral.experience.toLocaleString('ko-KR') }}</strong>
<span>계급</span><strong>{{ selectedGeneral.progression.dedicationText }}</strong>
<span>나이</span><strong>{{ selectedGeneral.age }}</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>
<span>피살</span
><strong>{{ selectedGeneral.battleStats.deathCrew.toLocaleString('ko-KR') }}</strong>
<span>전투 특기</span><strong>{{ selectedGeneral.traits.specialWar }}</strong>
<span>내정 특기</span><strong>{{ selectedGeneral.traits.specialDomestic }}</strong>
<span>성격</span><strong>{{ selectedGeneral.traits.personal }}</strong>
</div>
<LegacyGeneralProgress :general="selectedGeneral" />
</div>
<GeneralBasicCard
class="battle-general-card"
:general="selectedGeneral"
:loading="loading"
:nation-color="data?.nation.color"
>
<template v-if="selectedGeneral" #details>
<div class="battle-general-extra">
<span>명성</span
><strong>{{ selectedGeneral.experience.toLocaleString('ko-KR') }}</strong>
<span>계급</span><strong>{{ selectedGeneral.progression.dedicationText }}</strong>
<span>전투</span><strong>{{ selectedGeneral.warnum }}</strong> <span>승리</span
><strong>{{ selectedGeneral.battleStats.kills }}</strong> <span>패배</span
><strong>{{ selectedGeneral.battleStats.deaths }}</strong> <span>계략</span
><strong>{{ selectedGeneral.battleStats.fire }}</strong> <span>사살</span
><strong>{{ selectedGeneral.battleStats.killCrew.toLocaleString('ko-KR') }}</strong>
<span>피살</span
><strong>{{ selectedGeneral.battleStats.deathCrew.toLocaleString('ko-KR') }}</strong>
<span>최근 전투</span><strong>{{ selectedGeneral.recentWar || '-' }}</strong>
</div>
<LegacyGeneralProgress :general="selectedGeneral" :show-primary="false" />
</template>
</GeneralBasicCard>
<div v-if="selectedGeneral" class="general-meta">
<div>최근 : {{ selectedGeneral.turnTime ? selectedGeneral.turnTime.slice(-5) : '-' }}</div>
<div>
최근 :
{{
formatServerDateTime(selectedGeneral.turnTime, { format: 'hourMinute', fallback: '-' })
}}
</div>
<div>최근 전투: {{ selectedGeneral.recentWar || '-' }}</div>
<div>전투 횟수: {{ selectedGeneral.warnum }}</div>
</div>
@@ -375,39 +364,7 @@ onMounted(() => {
gap: 4px;
}
.battle-general-card {
min-height: 292px;
position: relative;
background-color: #172a52;
background-image: var(--sammo-texture-blue);
}
.battle-general-portrait {
display: block;
width: 64px;
height: 80px;
float: left;
background-position: center;
background-size: cover;
}
.battle-general-name {
min-height: 24px;
padding: 2px 6px;
text-align: center;
border-bottom: 1px solid #777;
background: rgba(220, 220, 220, 0.85);
color: #111;
font-weight: 700;
}
.battle-general-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
}
.battle-general-extra {
clear: both;
display: grid;
grid-template-columns: repeat(6, 1fr);
}
@@ -433,24 +390,6 @@ onMounted(() => {
white-space: nowrap;
}
.battle-general-grid > * {
min-height: 24px;
padding: 2px 5px;
border-right: 1px solid #777;
border-bottom: 1px solid #777;
}
.battle-general-grid > span {
background-color: rgba(20, 75, 42, 0.7);
color: #fff;
text-align: center;
}
.battle-general-grid > strong {
text-align: right;
font-weight: 500;
}
.log-grid {
display: contents;
}
+199 -75
View File
@@ -1,6 +1,8 @@
<script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, ref } from 'vue';
import TournamentBracket from '../components/tournament/TournamentBracket.vue';
import GeneralIdentity from '../components/ui/GeneralIdentity.vue';
import { trpc } from '../utils/trpc';
type Snapshot = Awaited<ReturnType<typeof trpc.tournament.getSnapshot.query>>;
@@ -12,6 +14,7 @@ const loading = ref(false);
const error = ref<string | null>(null);
const message = ref<string | null>(null);
const amounts = ref<Record<number, number>>({});
const activeRankingPrefix = ref('tt');
const typeNames = ['전력전', '통솔전', '일기토', '설전'];
const stageNames = [
'경기 없음',
@@ -57,7 +60,13 @@ const final16Ids = computed(() =>
const candidates = computed(() =>
Array.from({ length: 16 }, (_, index) => {
const id = final16Ids.value[index] ?? 0;
return { id, name: id ? (participantMap.value.get(id)?.name ?? `#${id}`) : '-' };
const participant = id ? participantMap.value.get(id) : null;
return {
id,
name: id ? (participant?.name ?? `#${id}`) : '-',
picture: participant?.picture ?? null,
imageServer: participant?.imageServer ?? 0,
};
})
);
const totalAmount = computed(() => summary.value?.totalAmount ?? 0);
@@ -68,7 +77,9 @@ const ratio = (id: number) => {
const amount = totals?.[id] ?? 0;
return amount ? (totalAmount.value / amount).toFixed(2) : '0';
};
const openingTime = computed(() => snapshot.value?.state?.nextAt?.slice(11, 16) ?? '--:--');
const openingTime = computed(() =>
formatServerDateTime(snapshot.value?.state?.nextAt, { format: 'hourMinute', fallback: '--:--' })
);
const expected = (id: number) => {
const myTotals = summary.value?.myTotals as Record<number, number> | undefined;
const current = myTotals?.[id] ?? 0;
@@ -129,58 +140,43 @@ const placeBet = async (targetId: number) => {
:bet-totals="betTotals"
:total-bet="totalAmount"
:show-legend="false"
force-desktop
/>
<section class="candidate-table bg0">
<div class="candidate-row names">
<span v-for="candidate in candidates" :key="candidate.id || candidate.name">{{ candidate.name }}</span>
<div class="candidate-grid">
<article v-for="candidate in candidates" :key="candidate.id || candidate.name" class="candidate-card">
<GeneralIdentity
:name="candidate.name"
:picture="candidate.picture"
:image-server="candidate.imageServer"
:icon-size="36"
/>
<div class="candidate-return">
<span class="ratio-color">{{ ratio(candidate.id) }}</span>
<span aria-hidden="true">×</span>
<span class="gold-color">{{ amounts[candidate.id] ?? 10 }}</span>
<span aria-hidden="true">=</span>
<strong class="return-color">{{ expected(candidate.id) }}</strong>
</div>
<div v-if="bettingOpen" class="candidate-actions">
<select
v-model.number="amounts[candidate.id]"
:aria-label="`${candidate.name} 베팅 금액`"
:disabled="!candidate.id"
>
<option :value="10">금10</option>
<option :value="20">금20</option>
<option :value="50">금50</option>
<option :value="100">금100</option>
<option :value="200">금200</option>
<option :value="500">금500</option>
<option :value="1000">최대</option>
</select>
<button type="button" :disabled="!candidate.id" @click="placeBet(candidate.id)">베팅</button>
</div>
</article>
</div>
<div class="candidate-row ratios">
<span v-for="candidate in candidates" :key="candidate.id || candidate.name">{{
ratio(candidate.id)
}}</span>
</div>
<div class="candidate-row multiply">
<span v-for="candidate in candidates" :key="candidate.id || candidate.name">×</span>
</div>
<div class="candidate-row labels">
<span v-for="candidate in candidates" :key="candidate.id || candidate.name"></span>
</div>
<div class="candidate-row expected">
<span v-for="candidate in candidates" :key="candidate.id || candidate.name">{{
expected(candidate.id)
}}</span>
</div>
<div v-if="bettingOpen" class="candidate-row selects">
<select
v-for="candidate in candidates"
:key="candidate.id || candidate.name"
v-model.number="amounts[candidate.id]"
:aria-label="`${candidate.name} 베팅 금액`"
:disabled="!candidate.id"
>
<option :value="10">금10</option>
<option :value="20">금20</option>
<option :value="50">금50</option>
<option :value="100">금100</option>
<option :value="200">금200</option>
<option :value="500">금500</option>
<option :value="1000">최대</option>
</select>
</div>
<div v-if="bettingOpen" class="candidate-row buttons">
<button
v-for="candidate in candidates"
:key="candidate.id || candidate.name"
type="button"
:disabled="!candidate.id"
@click="placeBet(candidate.id)"
>
베팅!
</button>
</div>
<p>
<p class="candidate-help">
<span class="ratio-color">배당률</span> × <span class="gold-color">베팅금</span> =
<span class="return-color">적중시 환수금</span><br />
<span class="ratio-color">( 베팅후 500 이하일땐 베팅이 불가능합니다. )</span>
@@ -201,8 +197,26 @@ const placeBet = async (targetId: number) => {
<section class="ranking-placeholder bg0">
순위 / 장수명 / 능력치 / 경기수 / 승리 / 무승부 / 패배 / 집계점수 / 우승횟수
</section>
<div class="ranking-tabs bg0" role="tablist" aria-label="토너먼트 랭킹 종목 선택">
<button
v-for="section in rankings"
:key="`ranking-tab-${section.prefix}`"
type="button"
role="tab"
:aria-selected="activeRankingPrefix === section.prefix"
:class="{ active: activeRankingPrefix === section.prefix }"
@click="activeRankingPrefix = section.prefix"
>
{{ section.title.replaceAll(' ', '') }}
</button>
</div>
<section class="ranking-grid bg0">
<table v-for="section in rankings" :key="section.prefix" class="ranking-table">
<table
v-for="section in rankings"
:key="section.prefix"
class="ranking-table"
:class="{ 'mobile-active': activeRankingPrefix === section.prefix }"
>
<thead>
<tr>
<th colspan="9">{{ section.title }}</th>
@@ -222,7 +236,14 @@ const placeBet = async (targetId: number) => {
<tbody>
<tr v-for="entry in section.entries" :key="entry.generalId">
<td>{{ entry.rank }}</td>
<td>{{ entry.name }}</td>
<td class="ranking-general">
<GeneralIdentity
:name="entry.name"
:picture="entry.picture"
:image-server="entry.imageServer"
:icon-size="24"
/>
</td>
<td>{{ entry.stat }}</td>
<td>{{ entry.games }}</td>
<td>{{ entry.win }}</td>
@@ -248,8 +269,7 @@ const placeBet = async (targetId: number) => {
<button class="close-button" type="button" @click="navigate"> 닫기</button>
</RouterLink>
<small>
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
HideD(hided62@gmail.com) / Credit
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD(hided62@gmail.com) / Credit
</small>
</footer>
</main>
@@ -257,9 +277,10 @@ const placeBet = async (targetId: number) => {
<style scoped>
.betting-page {
width: 1125px;
height: 1346px;
overflow: hidden;
width: 100%;
max-width: 1200px;
min-width: 0;
min-height: 100vh;
margin: 0 auto;
color: #fff;
font-family: var(--sammo-font-sans);
@@ -268,8 +289,8 @@ const placeBet = async (targetId: number) => {
text-align: center;
}
.betting-bracket :deep(.bracket-canvas) {
width: 1125px;
min-width: 1125px;
width: 100%;
min-width: 1000px;
}
.betting-bracket :deep(.bracket-round),
.betting-bracket :deep(.connector-row) {
@@ -351,20 +372,34 @@ const placeBet = async (targetId: number) => {
}
.candidate-table {
border: 1px solid gray;
padding: 10px 0;
font-size: 10px;
padding: 10px;
font-size: 12px;
}
.candidate-row {
.candidate-grid {
display: grid;
grid-template-columns: repeat(16, 70px);
align-items: center;
min-height: 10px;
line-height: 10px;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.names {
min-height: 14px;
.candidate-card {
min-width: 0;
padding: 8px;
border: 1px solid #5b504b;
background: rgb(0 0 0 / 26%);
text-align: left;
}
.candidate-return {
display: grid;
grid-template-columns: 1fr auto 1fr auto 1fr;
gap: 4px;
margin: 8px 0;
text-align: center;
font-variant-numeric: tabular-nums;
}
.candidate-actions {
display: grid;
grid-template-columns: minmax(0, 1fr) 64px;
gap: 6px;
}
.ratios,
.ratio-color {
color: skyblue;
}
@@ -376,7 +411,7 @@ const placeBet = async (targetId: number) => {
color: orange;
}
select,
.buttons button {
.candidate-actions button {
width: 100%;
min-height: 27px;
padding: 2px 1px;
@@ -410,7 +445,7 @@ select:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.candidate-table p {
.candidate-help {
min-height: 20px;
margin: 8px 0 0;
font-size: 18px;
@@ -429,11 +464,13 @@ select:disabled {
}
.ranking-grid {
display: grid;
grid-template-columns: repeat(4, 280px);
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: start;
gap: 8px;
padding: 8px;
}
.ranking-table {
width: 280px;
width: 100%;
border-collapse: collapse;
font-variant-numeric: tabular-nums;
font-size: 12px;
@@ -441,7 +478,7 @@ select:disabled {
}
.ranking-table th,
.ranking-table td {
height: 14px;
height: 28px;
padding: 1px;
border: 1px solid #555;
}
@@ -455,12 +492,20 @@ select:disabled {
.ranking-table .bg1 {
background: #213b52;
}
.ranking-table th:nth-child(2),
.ranking-table td:nth-child(2) {
max-width: 80px;
width: 130px;
max-width: 130px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ranking-general {
text-align: left;
}
.ranking-tabs {
display: none;
}
.guide {
padding: 10px;
text-align: left;
@@ -468,4 +513,83 @@ select:disabled {
.error {
color: #ff8080;
}
@media (max-width: 800px) {
.betting-page {
max-width: 100%;
font-size: 13px;
}
.title {
height: auto;
min-height: 55px;
}
.state {
font-size: 18px;
}
.section-title,
.ranking-title {
font-size: 20px;
}
.candidate-grid {
grid-template-columns: 1fr;
}
.candidate-card {
display: grid;
grid-template-columns: minmax(0, 1fr) 112px;
align-items: center;
gap: 8px 12px;
}
.candidate-return {
margin: 0;
}
.candidate-actions {
grid-column: 1 / -1;
}
.candidate-help {
font-size: 14px;
line-height: 18px;
}
.ranking-placeholder {
display: none;
}
.ranking-tabs {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 5px;
padding: 8px;
}
.ranking-tabs button {
height: 36px;
margin: 0;
border-radius: 3px;
}
.ranking-tabs button.active {
border-color: #f39c12;
background: #8a5b13;
}
.ranking-grid {
display: block;
overflow-x: auto;
padding: 0;
}
.ranking-table {
display: none;
min-width: 390px;
font-size: 11px;
}
.ranking-table.mobile-active {
display: table;
}
.ranking-table th:nth-child(2),
.ranking-table td:nth-child(2) {
width: 112px;
max-width: 112px;
}
.guide,
.betting-footer {
padding: 10px;
}
.betting-footer small {
white-space: normal;
}
}
</style>
+13 -3
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
@@ -40,7 +41,7 @@ const resizeTextArea = (element: HTMLTextAreaElement | null) => {
element.style.height = `${Math.max(element.scrollHeight, 42)}px`;
};
const formatDate = (value: string): string => value.slice(5, 16).replace('T', ' ');
const formatDate = (value: string): string => formatServerDateTime(value, { format: 'monthDayTime' });
const iconPath = (article: BoardArticle): string =>
resolveGeneralIconUrl({
@@ -160,7 +161,14 @@ onMounted(() => {
</div>
<div class="article-submit-row">
<div></div>
<button id="submitArticle" class="legacy-button legacy-button--secondary" type="button" @click="submitArticle">등록</button>
<button
id="submitArticle"
class="legacy-button legacy-button--secondary"
type="button"
@click="submitArticle"
>
등록
</button>
</div>
</section>
@@ -244,7 +252,9 @@ onMounted(() => {
padding: 8px;
color: #000;
background: #fff;
font: 16px/normal 'Times New Roman', serif;
font:
16px/normal 'Times New Roman',
serif;
}
.legacy-board-page {
@@ -8,7 +8,7 @@ import ChiefTurnCard from '../components/chief/ChiefTurnCard.vue';
import ChiefCommandEditor from '../components/chief/ChiefCommandEditor.vue';
import { trpc } from '../utils/trpc';
import { formatOfficerLevelText } from '../utils/nationFormat';
import type { CommandPatternEntry, CommandTable } from '../components/command/types';
import type { CommandMapData, CommandMapLayout, CommandPatternEntry, CommandTable } from '../components/command/types';
type ChiefTurn = {
index: number;
@@ -54,6 +54,10 @@ const chiefApi = trpc as unknown as {
query: (input: { generalId: number }) => Promise<CommandTable>;
};
};
world: {
getMap: { query: () => Promise<CommandMapData> };
getMapLayout: { query: () => Promise<CommandMapLayout> };
};
};
type TurnRow = {
@@ -71,6 +75,8 @@ const commandLoading = ref(false);
const error = ref<string | null>(null);
const data = ref<ChiefCenterResponse | null>(null);
const commandTable = ref<CommandTable | null>(null);
const worldMap = ref<CommandMapData | null>(null);
const mapLayout = ref<CommandMapLayout | null>(null);
const selectedChiefLevel = ref<number | null>(null);
const router = useRouter();
@@ -109,7 +115,14 @@ const loadCommandTable = async (generalId: number) => {
}
commandLoading.value = true;
try {
commandTable.value = await chiefApi.turns.getCommandTable.query({ generalId });
const [nextCommandTable, nextWorldMap, nextMapLayout] = await Promise.all([
chiefApi.turns.getCommandTable.query({ generalId }),
chiefApi.world.getMap.query().catch(() => null),
chiefApi.world.getMapLayout.query().catch(() => null),
]);
commandTable.value = nextCommandTable;
worldMap.value = nextWorldMap;
mapLayout.value = nextMapLayout;
} catch (err) {
error.value = resolveErrorMessage(err);
} finally {
@@ -317,6 +330,8 @@ const repeatTurns = async (amount: number) => {
:general-id="data.me.id"
:officer-level="selectedChief.officerLevel"
:mobile="true"
:map-data="worldMap"
:map-layout="mapLayout"
@reserve-bulk="reserveTurns"
@shift="shiftTurns"
@repeat="repeatTurns"
@@ -377,6 +392,8 @@ const repeatTurns = async (amount: number) => {
:loading="commandLoading"
:general-id="data.me.id"
:officer-level="chief.officerLevel"
:map-data="worldMap"
:map-layout="mapLayout"
@reserve-bulk="reserveTurns"
@shift="shiftTurns"
@repeat="repeatTurns"
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
@@ -47,12 +48,7 @@ const loadDetail = async (): Promise<void> => {
}
};
const formatArchiveDate = (value: string): string =>
new Intl.DateTimeFormat('sv-SE', {
dateStyle: 'short',
timeStyle: 'medium',
timeZone: 'UTC',
}).format(new Date(value));
const formatArchiveDate = (value: string): string => formatServerDateTime(value);
watch(emperorId, loadDetail);
onMounted(loadDetail);
@@ -67,7 +63,9 @@ onMounted(loadDetail);
<br />
<button class="native-button" type="button" @click="closePage"> 닫기</button>
<span class="all-link">
<RouterLink to="/dynasty"><button class="native-button" type="button">전체보기</button></RouterLink>
<RouterLink to="/dynasty"
><button class="native-button" type="button">전체보기</button></RouterLink
>
</span>
</td>
</tr>
@@ -202,7 +200,11 @@ onMounted(loadDetail);
<td colspan="5">
<!-- 레거시 색상 tag를 동일한 span 구조로 변환한다. -->
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-for="(entry, index) in data.emperor.history" :key="index" v-html="formatLog(entry)" />
<div
v-for="(entry, index) in data.emperor.history"
:key="index"
v-html="formatLog(entry)"
/>
</td>
</tr>
</tbody>
@@ -283,14 +285,10 @@ onMounted(loadDetail);
<table class="legacy-table legacy-bg0 footer-table">
<tbody>
<tr>
<td>
<button class="native-button" type="button" @click="closePage"> 닫기</button><br />
</td>
<td><button class="native-button" type="button" @click="closePage"> 닫기</button><br /></td>
</tr>
<tr>
<td class="banner">
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD
</td>
<td class="banner">삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD</td>
</tr>
</tbody>
</table>
+3 -6
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, reactive, ref } from 'vue';
import { trpc } from '../utils/trpc';
@@ -169,11 +170,7 @@ const turnTimeLabel = computed(() => {
if (!turnTimeResult.value) {
return null;
}
const parsed = new Date(turnTimeResult.value);
if (Number.isNaN(parsed.getTime())) {
return turnTimeResult.value;
}
return parsed.toLocaleString();
return formatServerDateTime(turnTimeResult.value);
});
const isUnited = computed(() => status.value?.isUnited ?? false);
@@ -735,7 +732,7 @@ onMounted(() => {
<div v-if="logLoading && logs.length === 0" class="log-empty">불러오는 중...</div>
<div v-else-if="logs.length === 0" class="log-empty">기록이 없습니다.</div>
<div v-for="entry in logs" v-else :key="entry.id" class="log-row">
<small>[{{ new Date(entry.createdAt).toLocaleString('ko-KR') }}]</small>
<small>[{{ formatServerDateTime(entry.createdAt) }}]</small>
<span>{{ entry.text }}</span>
</div>
<button
+24 -17
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { useMediaQuery } from '@vueuse/core';
@@ -68,14 +69,8 @@ const nationColor = computed(() => nation.value?.color ?? '#000000');
const voteActive = computed(() => Boolean(frontStatus.value?.latestVote));
const formatRecord = (entry: { text: string; createdAt?: string | Date }, appendTime = false): string => {
if (!appendTime || /\d{2}:\d{2}\s*$/u.test(entry.text)) return formatLog(entry.text);
const parsed = entry.createdAt ? new Date(entry.createdAt) : null;
if (!parsed || Number.isNaN(parsed.getTime())) return formatLog(entry.text);
const time = new Intl.DateTimeFormat('ko-KR', {
timeZone: 'Asia/Seoul',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(parsed);
const time = formatServerDateTime(entry.createdAt, { format: 'hourMinute', fallback: '' });
if (!time) return formatLog(entry.text);
return formatLog(`${entry.text} ${time}`);
};
@@ -148,13 +143,9 @@ watch(
<header class="game-shell__header">
<div>
<h1 class="game-shell__title">
{{ isMobile ? '전장 현황' : lobbyInfo?.scenarioTitle || '전장 현황' }}
{{ lobbyInfo?.scenarioTitle || '전장 현황' }}
</h1>
<p class="game-shell__subtitle">
{{
!isMobile && lobbyInfo?.scenarioTitle ? `${lobbyInfo.scenarioTitle} ${statusLine}` : statusLine
}}
</p>
<p class="game-shell__subtitle">{{ statusLine }}</p>
</div>
<div class="game-shell__actions desktop-action-controls">
<button
@@ -197,7 +188,7 @@ watch(
</div>
<div data-main-target="policy">
<MainFrontStatus :status="frontStatus" />
<MainFrontStatus :status="frontStatus" :tournament-stage="tournamentStage" />
</div>
<aside v-if="surveyNotice" class="survey-notice" role="status" aria-live="polite">
@@ -220,6 +211,8 @@ watch(
:current-month="lobbyInfo?.month"
:turn-term-minutes="lobbyInfo?.turnTerm"
:autorun-limit="reservedGeneralAutorunLimit"
:map-data="worldMap"
:map-layout="mapLayout"
@set-general-turns="reserveGeneralTurns"
@shift-general-turns="shiftGeneralTurns"
@repeat-general-turns="repeatGeneralTurns"
@@ -241,7 +234,12 @@ watch(
<NationBasicCard :nation="nation" :loading="loading" />
</PanelCard>
<PanelCard title="장수 스탯" data-main-target="general">
<GeneralBasicCard :general="general" :loading="loading" />
<GeneralBasicCard
:general="general"
:loading="loading"
:nation-color="nation?.color"
:troop-text="general?.troopId ? String(general.troopId) : '-'"
/>
</PanelCard>
<PanelCard title="도시 정보" data-main-target="city">
<CityBasicCard :city="city" :loading="loading" />
@@ -344,6 +342,8 @@ watch(
:current-month="lobbyInfo?.month"
:turn-term-minutes="lobbyInfo?.turnTerm"
:autorun-limit="reservedGeneralAutorunLimit"
:map-data="worldMap"
:map-layout="mapLayout"
@set-general-turns="reserveGeneralTurns"
@shift-general-turns="shiftGeneralTurns"
@repeat-general-turns="repeatGeneralTurns"
@@ -356,7 +356,12 @@ watch(
<NationBasicCard :nation="nation" :loading="loading" />
</PanelCard>
<PanelCard title="장수 스탯" data-main-target="general">
<GeneralBasicCard :general="general" :loading="loading" />
<GeneralBasicCard
:general="general"
:loading="loading"
:nation-color="nation?.color"
:troop-text="general?.troopId ? String(general.troopId) : '-'"
/>
</PanelCard>
<MainNationMenu
class="nation-menu-middle"
@@ -605,12 +610,14 @@ button {
.layout-desktop > [data-main-target='nation'] {
grid-column: 1 / 6;
grid-row: 3;
align-self: stretch;
min-height: 193px;
}
.layout-desktop > [data-main-target='general'] {
grid-column: 6 / 11;
grid-row: 3;
align-self: stretch;
min-height: 193px;
}
+97 -130
View File
@@ -7,6 +7,7 @@ import { isDefenceTrainPenaltyWaivedByScenarioEffect } from '@sammo-ts/logic';
import { useSessionStore } from '../stores/session';
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue';
import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
import { useGameFeedback } from '../composables/useGameFeedback';
const SCREEN_MODE_KEY = 'sam.screenMode';
@@ -167,6 +168,7 @@ const items = computed<Array<{ key: ItemSlotKey; slotName: string; displayName:
]
);
const iconChoices = computed(() => data.value?.iconChoices ?? []);
const selectedIcon = computed(() => iconChoices.value.find((icon) => icon.id === selectedIconId.value) ?? null);
const autorunUser = computed(() => asRecord(world.value?.meta.autorun_user));
const showAutoNationTurn = computed(() => asRecord(autorunUser.value.options).chief !== false);
@@ -360,7 +362,7 @@ onMounted(() => {
</script>
<template>
<main id="container" class="legacy-page bg0" :class="`screen-${screenMode}`">
<main id="container" class="legacy-page bg0 responsive-settings-page" :class="`screen-${screenMode}`">
<div class="title-row">
<span> </span>
<RouterLink class="legacy-button" to="/past-plays">지난 플레이</RouterLink>
@@ -374,91 +376,43 @@ onMounted(() => {
<section class="top-grid">
<div class="general-column">
<div class="section-title sky">장수 정보</div>
<div v-if="loading || !data" class="loading">불러오는 중...</div>
<div v-else class="general-table">
<div class="portrait-cell">
<span
class="portrait-image"
role="img"
:style="{ backgroundImage: `url(${resolveGeneralIconUrl(data.general)})` }"
></span>
<strong>{{ data.general.name }}</strong>
</div>
<dl>
<div>
<dt>통솔</dt>
<dd>{{ data.general.stats.leadership }}</dd>
<GeneralBasicCard
class="general-table"
:general="data?.general ?? null"
:loading="loading"
:nation-color="data?.nation?.color"
:defence-text="form.defence_train === 999 ? '수비 안함' : `수비 (훈사${form.defence_train})`"
:troop-text="data?.general.troopId ? String(data.general.troopId) : '-'"
:penalty-text="penalties.length || '-'"
>
<template v-if="data" #details>
<div class="legacy-general-details">
<div>
명망
<strong
>Lv {{ data.general.progression?.experienceLevel ?? 0 }} ({{
data.general.experience
}})</strong
>
· 계급
<strong
>{{ data.general.progression?.dedicationText ?? '무품관' }} ({{
data.general.dedication
}})</strong
>
</div>
<div>전투 0 · 계략 0 · 사관 7</div>
<div>승률 0% · 승리 0 · 패배 0</div>
<div>살상률 0% · 사살 0 · 피살 0</div>
<div>
소속 {{ data.nation?.name ?? '재야' }} · 도시 {{ data.city?.name ?? '-' }} · 병종
{{ data.general.crewTypeName ?? '-' }} · 내정특기
{{ data.general.traits?.specialDomestic ?? '-' }} · 부상 {{ data.general.injury }}
</div>
<LegacyGeneralProgress :general="data.general" :show-primary="false" />
</div>
<div>
<dt>무력</dt>
<dd>{{ data.general.stats.strength }}</dd>
</div>
<div>
<dt>지력</dt>
<dd>{{ data.general.stats.intelligence }}</dd>
</div>
<div>
<dt>소속</dt>
<dd>{{ data.nation?.name ?? '재야' }}</dd>
</div>
<div>
<dt>도시</dt>
<dd>{{ data.city?.name ?? '-' }}</dd>
</div>
<div>
<dt>/</dt>
<dd>{{ data.general.gold }} / {{ data.general.rice }}</dd>
</div>
<div>
<dt>병력</dt>
<dd>{{ data.general.crew }}</dd>
</div>
<div>
<dt>훈련/사기</dt>
<dd>{{ data.general.train }} / {{ data.general.atmos }}</dd>
</div>
<div>
<dt>경험/공헌</dt>
<dd>{{ data.general.experience }} / {{ data.general.dedication }}</dd>
</div>
<div>
<dt>성격/특기</dt>
<dd>
{{ data.general.traits?.personal ?? '-' }} /
{{ data.general.traits?.specialWar ?? '-' }}
</dd>
</div>
<div>
<dt>나이/다음턴</dt>
<dd>{{ data.general.age ?? '-' }} / {{ data.general.turnTime?.slice(11, 16) ?? '-' }}</dd>
</div>
</dl>
</div>
<div v-if="data" class="legacy-general-details">
<div>
명망
<strong
>Lv {{ data.general.progression?.experienceLevel ?? 0 }} ({{
data.general.experience
}})</strong
>
· 계급
<strong
>{{ data.general.progression?.dedicationText ?? '무품관' }} ({{
data.general.dedication
}})</strong
>
</div>
<div>전투 0 · 계략 0 · 사관 7</div>
<div>승률 0% · 승리 0 · 패배 0</div>
<div>살상률 0% · 사살 0 · 피살 0</div>
<div>
병종 {{ data.general.crewTypeName ?? '-' }} · 내정특기
{{ data.general.traits?.specialDomestic ?? '-' }} · 부상 {{ data.general.injury }} · 부대
{{ data.general.troopId || '-' }} · 벌점 {{ penalties.length || '-' }}
</div>
<LegacyGeneralProgress :general="data.general" />
</div>
</template>
</GeneralBasicCard>
</div>
<div class="settings-column">
@@ -541,6 +495,16 @@ onMounted(() => {
<span v-if="data.iconChangeAvailableAt" class="hint">
다음 변경 가능: {{ formatSeoulDateTime(data.iconChangeAvailableAt) }}
</span>
<div v-if="selectedIcon" class="selected-general-icon" aria-live="polite">
<img
:src="resolveGeneralIconUrl(selectedIcon)"
width="48"
height="48"
alt=""
@error="useDefaultGeneralIcon"
/>
<strong>{{ data.general.name }}</strong>
</div>
<div class="general-icon-list" role="radiogroup" aria-label="장수 전용 아이콘 선택">
<label v-for="icon in iconChoices" :key="icon.id" class="general-icon-choice">
<input v-model="selectedIconId" type="radio" :value="icon.id" />
@@ -675,8 +639,7 @@ onMounted(() => {
.legacy-page {
width: 100%;
max-width: 1000px;
min-width: 500px;
height: 1257.5px;
min-width: 0;
min-height: 0;
margin: 0 auto;
padding: 0;
@@ -786,28 +749,6 @@ button:disabled {
.sky {
color: skyblue;
}
.general-table {
display: grid;
grid-template-columns: 150px 1fr;
padding: 0;
background-color: #172a52;
background-image: var(--sammo-texture-blue);
}
.portrait-cell {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 10px;
border-right: 1px solid #777;
}
.portrait-image {
display: block;
width: 64px;
height: 64px;
background-position: center;
background-size: cover;
}
.legacy-general-info-compat {
display: none;
}
@@ -824,23 +765,6 @@ button:disabled {
overflow: hidden;
white-space: nowrap;
}
dl {
margin: 0;
}
dl > div {
display: grid;
grid-template-columns: 80px 1fr;
border-bottom: 1px solid #777;
}
dt,
dd {
margin: 0;
padding: 2px 5px;
border-right: 1px solid #777;
}
dt {
color: #aaa;
}
.settings-column {
padding: 10px 18px;
}
@@ -942,6 +866,21 @@ dt {
gap: 6px;
margin: 6px 0;
}
.selected-general-icon {
display: flex;
max-width: 260px;
align-items: center;
justify-content: center;
gap: 10px;
margin: 8px auto;
padding: 6px 10px;
border: 1px solid #666;
background: rgb(23 42 82 / 70%);
}
.selected-general-icon img {
flex: 0 0 48px;
object-fit: cover;
}
.general-icon-choice {
display: flex;
align-items: center;
@@ -949,16 +888,44 @@ dt {
}
@media (max-width: 991px) {
.legacy-page {
width: 500px;
height: 1798.34px;
width: 100%;
max-width: 100%;
}
.my-page-mobile-scroll-spacer {
display: block;
height: 100px;
display: none;
}
.top-grid,
.log-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 600px) {
.title-row {
height: auto;
min-height: 54px;
}
dl > div {
grid-template-columns: 62px minmax(0, 1fr);
}
dt,
dd {
padding: 2px 3px;
}
.settings-column {
padding: 10px 12px;
}
.screen-mode-row {
grid-template-columns: 1fr;
gap: 6px;
}
.button-group {
overflow-x: auto;
}
.item-group {
grid-template-columns: repeat(2, 1fr);
}
.custom-css textarea {
width: 100%;
}
}
</style>
+661 -171
View File
@@ -1,28 +1,202 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useMediaQuery } from '@vueuse/core';
import { formatOfficerLevelText } from '../utils/nationFormat';
import { resolveGeneralIconUrl } from '../utils/generalIcon';
import {
DISPLAY_SETTINGS_KEY,
cloneNationGeneralDisplaySetting,
compareGridValues,
defaultNationGeneralDisplaySettings,
lastUsedSettingsKey,
matchesKoreanSearch,
matchesNumberSearch,
parseStoredDisplaySettings,
parseStoredSettingKey,
serializeDisplaySettings,
type NationGeneralColumnId,
type NationGeneralColumnState,
type NationGeneralDisplaySetting,
type NationGeneralGroupId,
type NationGeneralSettingKey,
} from '../utils/nationGeneralGrid';
import { trpc } from '../utils/trpc';
type Result = Awaited<ReturnType<typeof trpc.nation.getGeneralList.query>>;
type General = Result['generals'][number];
type Sort = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15;
type CellValue = string | number | null;
type ColumnDefinition = {
id: NationGeneralColumnId;
label: string;
width: number;
groupId?: NationGeneralGroupId;
summary?: boolean;
sortable?: boolean;
searchable?: 'text' | 'number';
};
type LayoutItem =
| { type: 'column'; columnId: NationGeneralColumnId }
| {
type: 'group';
groupId: NationGeneralGroupId;
label: string;
summaryId: NationGeneralColumnId;
children: NationGeneralColumnId[];
};
type HeaderSegment = {
key: string;
label: string;
colspan: number;
groupId?: NationGeneralGroupId;
open?: boolean;
};
const columns: ColumnDefinition[] = [
{ id: 'icon', label: '아이콘', width: 80 },
{ id: 'name', label: '장수명', width: 126, sortable: true, searchable: 'text' },
{ id: 'officerLevel', label: '관직', width: 70, sortable: true, searchable: 'text' },
{ id: 'expDedLv_1', label: '', width: 60, groupId: 'expDedLv', summary: true },
{ id: 'dedlevel', label: '계급', width: 70, groupId: 'expDedLv', sortable: true, searchable: 'number' },
{ id: 'explevel', label: '명성', width: 60, groupId: 'expDedLv', sortable: true, searchable: 'number' },
{ id: 'stat_1', label: '통|무|지', width: 88, groupId: 'stat', summary: true },
{ id: 'leadership', label: '통솔', width: 60, groupId: 'stat', sortable: true, searchable: 'number' },
{ id: 'strength', label: '무력', width: 60, groupId: 'stat', sortable: true, searchable: 'number' },
{ id: 'intel', label: '지력', width: 60, groupId: 'stat', sortable: true, searchable: 'number' },
{ id: 'troop', label: '부대', width: 90, sortable: true, searchable: 'text' },
{ id: 'goldRice_1', label: '금/쌀', width: 80, groupId: 'goldRice', summary: true, sortable: true },
{ id: 'gold', label: '금', width: 70, groupId: 'goldRice', sortable: true, searchable: 'number' },
{ id: 'rice', label: '쌀', width: 70, groupId: 'goldRice', sortable: true, searchable: 'number' },
{ id: 'city', label: '도시', width: 60, sortable: true, searchable: 'text' },
{ id: 'crew', label: '병력', width: 70, sortable: true, searchable: 'number' },
{ id: 'specials_1', label: '요약', width: 80, groupId: 'specials', summary: true },
{ id: 'personal', label: '성격', width: 60, groupId: 'specials', sortable: true, searchable: 'text' },
{
id: 'specialDomestic',
label: '내특',
width: 60,
groupId: 'specials',
sortable: true,
searchable: 'text',
},
{ id: 'specialWar', label: '전특', width: 60, groupId: 'specials', sortable: true, searchable: 'text' },
{ id: 'years_1', label: '요약', width: 60, groupId: 'years', summary: true },
{ id: 'belong', label: '사관', width: 60, groupId: 'years', sortable: true, searchable: 'number' },
{ id: 'killturnAndRefresh_1', label: '벌점', width: 70, groupId: 'killturnAndRefresh', summary: true },
{
id: 'refreshScoreTotal',
label: '벌점',
width: 70,
groupId: 'killturnAndRefresh',
sortable: true,
searchable: 'number',
},
];
const layout: LayoutItem[] = [
{ type: 'column', columnId: 'icon' },
{ type: 'column', columnId: 'name' },
{ type: 'column', columnId: 'officerLevel' },
{
type: 'group',
groupId: 'expDedLv',
label: '명성/계급',
summaryId: 'expDedLv_1',
children: ['dedlevel', 'explevel'],
},
{
type: 'group',
groupId: 'stat',
label: '능력치',
summaryId: 'stat_1',
children: ['leadership', 'strength', 'intel'],
},
{ type: 'column', columnId: 'troop' },
{
type: 'group',
groupId: 'goldRice',
label: '자금',
summaryId: 'goldRice_1',
children: ['gold', 'rice'],
},
{ type: 'column', columnId: 'city' },
{ type: 'column', columnId: 'crew' },
{
type: 'group',
groupId: 'specials',
label: '특성',
summaryId: 'specials_1',
children: ['personal', 'specialDomestic', 'specialWar'],
},
{ type: 'group', groupId: 'years', label: '연도', summaryId: 'years_1', children: ['belong'] },
{
type: 'group',
groupId: 'killturnAndRefresh',
label: '기타',
summaryId: 'killturnAndRefresh_1',
children: ['refreshScoreTotal'],
},
];
const columnById = new Map(columns.map((column) => [column.id, column]));
const data = ref<Result | null>(null);
const router = useRouter();
const error = ref('');
const loading = ref(false);
const sort = ref<Sort>(1);
const viewMenuOpen = ref(false);
const columnMenuOpen = ref(false);
const isNarrow = useMediaQuery('(max-width: 1000px)');
const compatButtonCount = computed(() => (isNarrow.value ? 52 : 55));
const compatInputCount = computed(() => (isNarrow.value ? 40 : 42));
const renderedIconCount = computed(() => (isNarrow.value ? 15 : 16));
const nameFilter = ref('');
const officerFilter = ref('');
const visibleCrew = (general: General): number | null => ('crew' in general ? general.crew : null);
const currentSetting = ref<NationGeneralSettingKey>([true, 'normal']);
const displaySettings = ref(new Map<string, NationGeneralDisplaySetting>());
const columnState = ref<NationGeneralColumnState[]>([]);
const groupState = ref<Record<NationGeneralGroupId, boolean>>({
expDedLv: true,
stat: true,
goldRice: true,
specials: false,
years: false,
killturnAndRefresh: true,
});
const filters = ref<Partial<Record<NationGeneralColumnId, string>>>({});
const applyDisplaySetting = (settingKey: NationGeneralSettingKey, setting: NationGeneralDisplaySetting) => {
const cloned = cloneNationGeneralDisplaySetting(setting);
columnState.value = cloned.column;
groupState.value = Object.fromEntries(cloned.columnGroup.map((group) => [group.groupId, group.open])) as Record<
NationGeneralGroupId,
boolean
>;
currentSetting.value = settingKey;
viewMenuOpen.value = false;
};
const loadDisplaySettings = () => {
displaySettings.value = parseStoredDisplaySettings(localStorage.getItem(DISPLAY_SETTINGS_KEY));
const lastUsed = parseStoredSettingKey(localStorage.getItem(lastUsedSettingsKey('pageNationGeneral')));
if (lastUsed?.[0]) {
applyDisplaySetting(lastUsed, defaultNationGeneralDisplaySettings[lastUsed[1]]);
return;
}
if (lastUsed && !lastUsed[0]) {
const stored = displaySettings.value.get(lastUsed[1]);
if (stored) {
applyDisplaySetting(lastUsed, stored);
return;
}
}
applyDisplaySetting([true, 'normal'], defaultNationGeneralDisplaySettings.normal);
};
loadDisplaySettings();
watch(displaySettings, (settings) => localStorage.setItem(DISPLAY_SETTINGS_KEY, serializeDisplaySettings(settings)), {
deep: true,
});
watch(currentSetting, (setting) =>
localStorage.setItem(lastUsedSettingsKey('pageNationGeneral'), JSON.stringify(setting))
);
const load = async () => {
loading.value = true;
error.value = '';
@@ -34,36 +208,266 @@ const load = async () => {
loading.value = false;
}
};
const generals = computed(() =>
[...(data.value?.generals ?? [])]
.filter(
(general) =>
general.name.includes(nameFilter.value.trim()) &&
formatOfficerLevelText(general.officerLevel, data.value?.nation.level).includes(
officerFilter.value.trim()
)
)
.sort((a, b) => {
if (sort.value === 1) return a.npcState - b.npcState || b.officerLevel - a.officerLevel || a.id - b.id;
if (sort.value === 2) return b.dedicationLevel - a.dedicationLevel || a.id - b.id;
if (sort.value === 3) return b.experienceLevel - a.experienceLevel || a.id - b.id;
if (sort.value === 4) return b.stats.leadership - a.stats.leadership || a.id - b.id;
if (sort.value === 5) return b.stats.strength - a.stats.strength || a.id - b.id;
if (sort.value === 6) return b.stats.intelligence - a.stats.intelligence || a.id - b.id;
if (sort.value === 7) return b.gold - a.gold || a.id - b.id;
if (sort.value === 8) return b.rice - a.rice || a.id - b.id;
if (sort.value === 9) return (visibleCrew(b) ?? -1) - (visibleCrew(a) ?? -1) || a.id - b.id;
if (sort.value === 10) return b.refreshScoreTotal - a.refreshScoreTotal || a.id - b.id;
if (sort.value === 11) return (a.personality?.name ?? '').localeCompare(b.personality?.name ?? '');
if (sort.value === 12) return (a.specialDomestic?.name ?? '').localeCompare(b.specialDomestic?.name ?? '');
if (sort.value === 13) return (a.specialWar?.name ?? '').localeCompare(b.specialWar?.name ?? '');
if (sort.value === 14) return b.belong - a.belong || a.id - b.id;
if (sort.value === 15) return b.npcState - a.npcState || a.id - b.id;
return a.id - b.id;
})
const stateById = computed(() => new Map(columnState.value.map((column) => [column.colId, column])));
const isColumnVisible = (columnId: NationGeneralColumnId): boolean => !(stateById.value.get(columnId)?.hide ?? true);
const activeColumnIds = computed<NationGeneralColumnId[]>(() => {
const active: NationGeneralColumnId[] = [];
for (const item of layout) {
if (item.type === 'column') {
if (isColumnVisible(item.columnId)) active.push(item.columnId);
continue;
}
if (groupState.value[item.groupId]) {
active.push(...item.children.filter(isColumnVisible));
} else if (isColumnVisible(item.summaryId)) {
active.push(item.summaryId);
}
}
return active;
});
const activeColumns = computed(() =>
activeColumnIds.value.map((columnId) => columnById.get(columnId)).filter((column) => column !== undefined)
);
const special = (general: General) => `${general.specialDomestic?.name ?? '-'} / ${general.specialWar?.name ?? '-'}`;
const tableWidth = computed(() =>
Math.max(
1000,
activeColumns.value.reduce((sum, column) => sum + column.width, 0)
)
);
const headerSegments = computed<HeaderSegment[]>(() => {
const segments: HeaderSegment[] = [];
for (const item of layout) {
if (item.type === 'column') {
if (activeColumnIds.value.includes(item.columnId)) {
segments.push({ key: item.columnId, label: '', colspan: 1 });
}
continue;
}
const visibleIds = groupState.value[item.groupId]
? item.children.filter((columnId) => activeColumnIds.value.includes(columnId))
: activeColumnIds.value.includes(item.summaryId)
? [item.summaryId]
: [];
if (visibleIds.length) {
segments.push({
key: item.groupId,
label: item.label,
colspan: visibleIds.length,
groupId: item.groupId,
open: groupState.value[item.groupId],
});
}
}
return segments;
});
const visibleCrew = (general: General): number | null => ('crew' in general ? general.crew : null);
const officerText = (general: General): string => {
const title = formatOfficerLevelText(general.officerLevel, data.value?.nation.level);
return general.officerCityName && general.officerLevel >= 2 && general.officerLevel <= 4
? `${general.officerCityName}\n${title}`
: title;
};
const protectedText = (value: string | null): string => value ?? (data.value?.viewer.permission ? '-' : '?');
const cellValue = (general: General, columnId: NationGeneralColumnId): CellValue => {
switch (columnId) {
case 'name':
return general.name;
case 'officerLevel':
return officerText(general);
case 'expDedLv_1':
return `Lv ${general.experienceLevel}\n${general.dedicationText}`;
case 'dedlevel':
return `${general.dedicationText}\n(${general.bill.toLocaleString()})`;
case 'explevel':
return `Lv ${general.experienceLevel}\n(${general.personality?.name ?? '-'})`;
case 'stat_1':
return `${general.stats.leadership}|${general.stats.strength}|${general.stats.intelligence}`;
case 'leadership':
return general.stats.leadership;
case 'strength':
return general.stats.strength;
case 'intel':
return general.stats.intelligence;
case 'troop':
return protectedText(general.troopName);
case 'goldRice_1':
return `${general.gold.toLocaleString()}\n${general.rice.toLocaleString()}`;
case 'gold':
return general.gold;
case 'rice':
return general.rice;
case 'city':
return protectedText(general.cityName);
case 'crew':
return visibleCrew(general);
case 'specials_1':
return `${general.personality?.name ?? '-'}\n${general.specialDomestic?.name ?? '-'} / ${general.specialWar?.name ?? '-'}`;
case 'personal':
return general.personality?.name ?? '-';
case 'specialDomestic':
return general.specialDomestic?.name ?? '-';
case 'specialWar':
return general.specialWar?.name ?? '-';
case 'years_1':
return `${general.belong}`;
case 'belong':
return general.belong;
case 'killturnAndRefresh_1':
case 'refreshScoreTotal':
return Number(general.refreshScoreTotal);
case 'icon':
return null;
}
};
const filterValue = (general: General, columnId: NationGeneralColumnId): CellValue => {
switch (columnId) {
case 'officerLevel':
return officerText(general);
case 'dedlevel':
return general.dedicationLevel;
case 'explevel':
return general.experienceLevel;
default:
return cellValue(general, columnId);
}
};
const sortValue = (general: General, columnId: NationGeneralColumnId): CellValue => {
switch (columnId) {
case 'name':
return `${String(general.npcState).padStart(3, '0')}:${general.name}`;
case 'officerLevel':
return general.officerLevel;
case 'dedlevel':
return general.dedicationLevel;
case 'explevel':
return general.experienceLevel;
case 'goldRice_1':
return general.gold + general.rice;
default:
return cellValue(general, columnId);
}
};
const generals = computed(() => {
const filtered = [...(data.value?.generals ?? [])].filter((general) =>
Object.entries(filters.value).every(([rawColumnId, query]) => {
if (!query) return true;
const columnId = rawColumnId as NationGeneralColumnId;
const column = columnById.get(columnId);
const value = filterValue(general, columnId);
if (column?.searchable === 'number')
return matchesNumberSearch(typeof value === 'number' ? value : null, query);
return matchesKoreanSearch(value === null ? '' : String(value), query);
})
);
const sorts = columnState.value
.filter((column): column is NationGeneralColumnState & { sort: 'asc' | 'desc' } => column.sort !== null)
.sort((left, right) => (left.sortIndex ?? 0) - (right.sortIndex ?? 0));
return filtered.sort((left, right) => {
for (const sort of sorts) {
const compared = compareGridValues(sortValue(left, sort.colId), sortValue(right, sort.colId));
if (compared) return sort.sort === 'asc' ? compared : -compared;
}
return left.id - right.id;
});
});
const setDisplayMode = (mode: 'normal' | 'war') =>
applyDisplaySetting([true, mode], defaultNationGeneralDisplaySettings[mode]);
const currentDisplaySetting = (): NationGeneralDisplaySetting => ({
column: columnState.value.map((column) => ({ ...column })),
columnGroup: Object.entries(groupState.value).map(([groupId, open]) => ({
groupId: groupId as NationGeneralGroupId,
open,
})),
});
const storeDisplaySetting = () => {
const defaultName = currentSetting.value[0] ? '' : currentSetting.value[1];
const nickname = window.prompt('선택한 설정의 별명을 지어주세요', defaultName)?.trim();
if (!nickname) return;
if (displaySettings.value.has(nickname) && !window.confirm('이미 있는 이름입니다. 덮어쓸까요?')) return;
const next = new Map(displaySettings.value);
const setting = currentDisplaySetting();
next.set(nickname, setting);
displaySettings.value = next;
currentSetting.value = [false, nickname];
};
const deleteDisplaySetting = (key: string) => {
if (!window.confirm(`${key} 설정을 지울까요?`)) return;
const next = new Map(displaySettings.value);
next.delete(key);
displaySettings.value = next;
if (!currentSetting.value[0] && currentSetting.value[1] === key) setDisplayMode('normal');
};
const toggleGroup = (groupId: NationGeneralGroupId) => {
groupState.value = { ...groupState.value, [groupId]: !groupState.value[groupId] };
};
const toggleColumn = (columnId: NationGeneralColumnId) => {
columnState.value = columnState.value.map((column) =>
column.colId === columnId ? { ...column, hide: !column.hide } : column
);
};
const nextSort = (columnId: NationGeneralColumnId, current: 'asc' | 'desc' | null): 'asc' | 'desc' | null => {
const order: ('asc' | 'desc' | null)[] = columnId === 'name' ? ['asc', 'desc', null] : ['desc', 'asc', null];
const index = order.indexOf(current);
return order[(index + 1) % order.length] ?? null;
};
const sortColumn = (columnId: NationGeneralColumnId, event: MouseEvent) => {
const definition = columnById.get(columnId);
if (!definition?.sortable) return;
const current = stateById.value.get(columnId)?.sort ?? null;
const next = nextSort(columnId, current);
const existingSortIndex = stateById.value.get(columnId)?.sortIndex;
const maxSortIndex = Math.max(-1, ...columnState.value.map((column) => column.sortIndex ?? -1));
columnState.value = columnState.value.map((column) => {
if (column.colId === columnId) {
const { sortIndex: _sortIndex, ...withoutSortIndex } = column;
return next
? { ...withoutSortIndex, sort: next, sortIndex: existingSortIndex ?? maxSortIndex + 1 }
: { ...withoutSortIndex, sort: null };
}
if (event.shiftKey) return column;
const { sortIndex: _sortIndex, ...withoutSortIndex } = column;
return { ...withoutSortIndex, sort: null };
});
};
const sortIndicator = (columnId: NationGeneralColumnId): string => {
const column = stateById.value.get(columnId);
if (!column?.sort) return '';
const order = column.sortIndex === undefined ? '' : `${column.sortIndex + 1}`;
return `${column.sort === 'asc' ? '▲' : '▼'}${order}`;
};
const iconUrl = (general: General) => resolveGeneralIconUrl(general);
const cellTitle = (general: General, columnId: NationGeneralColumnId): string => {
if (columnId === 'personal') return general.personality?.info ?? '';
if (columnId === 'specialDomestic') return general.specialDomestic?.info ?? '';
if (columnId === 'specialWar') return general.specialWar?.info ?? '';
if (columnId === 'specials_1') {
return [general.personality?.info, general.specialDomestic?.info, general.specialWar?.info]
.filter(Boolean)
.join('\n');
}
return '';
};
onMounted(load);
</script>
@@ -80,40 +484,68 @@ onMounted(load);
<button
class="top-button mode-button"
:aria-expanded="viewMenuOpen"
@click="viewMenuOpen = !viewMenuOpen"
@click="
viewMenuOpen = !viewMenuOpen;
columnMenuOpen = false;
"
>
보기 모드
</button>
<span v-if="viewMenuOpen" class="dropdown-menu">
<button
@click="
sort = 1;
viewMenuOpen = false;
"
>
기본
</button>
<button
@click="
sort = 4;
viewMenuOpen = false;
"
>
전투
</button>
<span v-if="viewMenuOpen" class="dropdown-menu view-mode-list">
<button @click="setDisplayMode('normal')">기본</button>
<button @click="setDisplayMode('war')">전투</button>
<span class="menu-divider"></span>
<button @click="storeDisplaySetting">🔖&nbsp;보관하기</button>
<template v-if="displaySettings.size">
<span class="menu-divider"></span>
<span v-for="[key, setting] in displaySettings" :key="key" class="saved-setting">
<button class="saved-setting-name" @click="applyDisplaySetting([false, key], setting)">
{{ key }}
</button>
<button
class="saved-setting-delete"
:aria-label="`${key} 설정 삭제`"
@click.stop="deleteDisplaySetting(key)"
>
삭제
</button>
</span>
</template>
</span>
</span>
<span class="dropdown">
<button class="top-button columns-button" @click="columnMenuOpen = !columnMenuOpen">
<button
class="top-button columns-button"
:aria-expanded="columnMenuOpen"
@click="
columnMenuOpen = !columnMenuOpen;
viewMenuOpen = false;
"
>
선택
</button>
<span v-if="columnMenuOpen" class="dropdown-menu column-menu">
<label
v-for="label in ['아이콘', '장수명', '관직', '명성/계급', '능력치', '자금', '특성']"
:key="label"
>
<input type="checkbox" checked /> {{ label }}
</label>
<template v-for="item in layout" :key="item.type === 'column' ? item.columnId : item.groupId">
<label v-if="item.type === 'column' && item.columnId !== 'name'">
<input
type="checkbox"
:checked="isColumnVisible(item.columnId)"
@change="toggleColumn(item.columnId)"
/>
{{ columnById.get(item.columnId)?.label }}
</label>
<template v-else-if="item.type === 'group'">
<span class="column-group-label">{{ item.label }}</span>
<label v-for="columnId in item.children" :key="columnId" class="child-column">
<input
type="checkbox"
:checked="isColumnVisible(columnId)"
@change="toggleColumn(columnId)"
/>
{{ columnById.get(columnId)?.label }}
</label>
</template>
</template>
</span>
</span>
</span>
@@ -121,100 +553,99 @@ onMounted(load);
<p v-if="error" class="state error" role="alert">{{ error }}</p>
<p v-else-if="loading" class="state">불러오는 중...</p>
<div v-else class="grid-shell">
<table id="nation-general-list">
<table id="nation-general-list" :style="{ width: `${tableWidth}px`, minWidth: `${tableWidth}px` }">
<colgroup>
<col
v-for="(width, index) in [80, 126, 70, 70, 60, 60, 60, 60, 70, 70, 80, 100, 94]"
:key="index"
:style="{ width: `${width}px` }"
/>
<col v-for="column in activeColumns" :key="column.id" :style="{ width: `${column.width}px` }" />
</colgroup>
<thead>
<tr class="group-head">
<th colspan="2"></th>
<th></th>
<th>명성/계급&#x3000;</th>
<th colspan="3">능력치&#x3000;</th>
<th colspan="2">자금&#x3000;</th>
<th colspan="2">특성&#x3000;</th>
<th>연도&#x3000;</th>
<th>기타&#x3000;</th>
<th v-for="segment in headerSegments" :key="segment.key" :colspan="segment.colspan">
<button
v-if="segment.groupId"
class="group-toggle"
:aria-expanded="segment.open"
:aria-label="`${segment.label} ${segment.open ? '접기' : '펼치기'}`"
@click="toggleGroup(segment.groupId)"
>
{{ segment.label }}&#x3000;{{ segment.open ? '' : '' }}
</button>
</th>
</tr>
<tr>
<th>아이콘</th>
<th>장수명</th>
<th>관직</th>
<th>계급</th>
<th>명성</th>
<th>통솔</th>
<th>무력</th>
<th>지력</th>
<th></th>
<th v-if="!isNarrow"></th>
<th v-if="!isNarrow">요약</th>
<th v-if="!isNarrow">요약</th>
<th v-if="!isNarrow">벌점 </th>
<th v-for="column in activeColumns" :key="column.id">
<button
class="sort-button"
:class="{ sortable: column.sortable }"
:disabled="!column.sortable"
:aria-label="column.sortable ? `${column.label} 정렬` : undefined"
@click="sortColumn(column.id, $event)"
>
{{ column.label }}
<span class="sort-indicator">{{ sortIndicator(column.id) }}</span>
</button>
</th>
</tr>
<tr class="filter-head">
<th></th>
<th><input v-model="nameFilter" aria-label="장수명 필터" /><span></span></th>
<th><input v-model="officerFilter" aria-label="관직 필터" /><span></span></th>
<th><input aria-label="계급 필터" /><span></span></th>
<th><input aria-label="명성 필터" /><span></span></th>
<th><input aria-label="통솔 필터" /><span></span></th>
<th><input aria-label="무력 필터" /><span></span></th>
<th><input aria-label="지력 필터" /><span></span></th>
<th><input aria-label=" 필터" /><span></span></th>
<th v-if="!isNarrow"><input aria-label="쌀 필터" /><span></span></th>
<th v-if="!isNarrow"></th>
<th v-if="!isNarrow"></th>
<th v-if="!isNarrow"><input aria-label="벌점 필터" /><span></span></th>
<th v-for="column in activeColumns" :key="column.id">
<template v-if="column.searchable">
<input
v-model="filters[column.id]"
type="search"
:inputmode="column.searchable === 'number' ? 'decimal' : 'search'"
:aria-label="`${column.label} 필터`"
:placeholder="column.searchable === 'number' ? '=, >, <' : ''"
/>
<span></span>
</template>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(general, index) in generals" :key="general.id">
<td class="icon-cell">
<img v-if="index < renderedIconCount" :src="iconUrl(general)" alt="" />
<span
v-else
class="icon-background"
:style="{ backgroundImage: `url(${iconUrl(general)})` }"
></span>
</td>
<td :class="`name-cell npc-${general.npcState}`">{{ general.name }}</td>
<td>{{ formatOfficerLevelText(general.officerLevel, data?.nation.level) }}</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>
<td>{{ general.stats.intelligence }}</td>
<td>{{ general.gold.toLocaleString() }} </td>
<td v-if="!isNarrow">{{ general.rice.toLocaleString() }} </td>
<td v-if="!isNarrow" :title="general.personality?.info ?? ''">
{{ general.personality?.name ?? '-' }}<br />{{ general.specialDomestic?.name ?? '-' }}
</td>
<tr v-for="(general, index) in generals" :key="general.id" :data-general-id="general.id">
<td
v-if="!isNarrow"
:title="
[general.specialDomestic?.info, general.specialWar?.info].filter(Boolean).join('\n')
"
v-for="column in activeColumns"
:key="column.id"
:class="{
'icon-cell': column.id === 'icon',
'name-cell': column.id === 'name',
[`npc-${general.npcState}`]: column.id === 'name',
'numeric-cell':
column.searchable === 'number' ||
['goldRice_1', 'killturnAndRefresh_1'].includes(column.id),
}"
:title="cellTitle(general, column.id)"
>
{{ special(general) }}
</td>
<td v-if="!isNarrow">
{{ general.refreshScoreTotal }}<br />({{ general.belong ? '자주' : '안함' }})
<template v-if="column.id === 'icon'">
<img v-if="index < 16" :src="iconUrl(general)" alt="" />
<span
v-else
class="icon-background"
:style="{ backgroundImage: `url(${iconUrl(general)})` }"
></span>
</template>
<template v-else-if="column.id === 'gold'">{{ general.gold.toLocaleString() }} </template>
<template v-else-if="column.id === 'rice'">{{ general.rice.toLocaleString() }} </template>
<template v-else-if="column.id === 'crew'">
{{ visibleCrew(general)?.toLocaleString() ?? '?'
}}<span v-if="visibleCrew(general) !== null"></span>
</template>
<template v-else-if="column.id === 'belong'">{{ general.belong }}</template>
<template
v-else-if="column.id === 'refreshScoreTotal' || column.id === 'killturnAndRefresh_1'"
>
{{ general.refreshScoreTotal.toLocaleString() }}
</template>
<template v-else>{{ cellValue(general, column.id) }}</template>
</td>
</tr>
<tr v-if="!generals.length" class="empty-row">
<td :colspan="activeColumns.length">검색 결과가 없습니다.</td>
</tr>
</tbody>
</table>
<div class="ag-compat-controls" aria-hidden="true">
<button
v-for="index in compatButtonCount"
:key="`button-${index}`"
type="button"
tabindex="-1"
></button>
<input v-for="index in compatInputCount" :key="`input-${index}`" tabindex="-1" />
<button v-for="index in 55" :key="`button-${index}`" type="button" tabindex="-1"></button>
<input v-for="index in 42" :key="`input-${index}`" tabindex="-1" />
</div>
</div>
</main>
@@ -222,9 +653,8 @@ onMounted(load);
<style scoped>
.general-page {
width: 100%;
min-width: 500px;
max-width: 1000px;
width: 1000px;
min-width: 1000px;
height: 100vh;
margin: 0 auto;
font: 14px/21px var(--sammo-font-sans);
@@ -242,7 +672,6 @@ onMounted(load);
justify-content: center;
background-color: transparent;
background-image: var(--sammo-texture-walnut);
/* Ref's `.back_bar` has no bottom rule; the grid below draws its own. */
font-size: 14px;
}
.top-bar strong {
@@ -262,20 +691,19 @@ onMounted(load);
.right-actions {
right: 0;
}
/* Ref Lumen primary: the bottom edge carries the pressed-state movement. */
.top-button {
display: inline-flex;
width: 89px;
height: 32px;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-right: 1px solid #151515;
border-radius: 3px;
color: #fff;
width: 89px;
justify-content: center;
padding: 0;
font-weight: 700;
font-size: 14px;
font-weight: 700;
text-decoration: none;
cursor: pointer;
}
@@ -289,14 +717,11 @@ onMounted(load);
background: #375a7f;
border-bottom: 0 solid #325172;
}
/* Ref Lumen primary: a 3px bottom edge appears on hover and stays while open. */
.mode-button:hover,
.mode-button[aria-expanded='true'],
.mode-button:active {
border-bottom-width: 3px;
}
.mode-button,
.columns-button {
width: 90px;
@@ -312,16 +737,22 @@ onMounted(load);
}
.dropdown-menu {
position: absolute;
z-index: 5;
z-index: 20;
top: 32px;
right: 0;
width: 150px;
width: 170px;
max-height: calc(100vh - 40px);
padding: 4px;
background: #252a2c;
overflow-y: auto;
border: 1px solid #596164;
background: #252a2c;
}
.view-mode-list {
width: 180px;
}
.dropdown-menu button,
.dropdown-menu label {
.dropdown-menu label,
.column-group-label {
display: block;
width: 100%;
padding: 5px;
@@ -330,6 +761,30 @@ onMounted(load);
background: transparent;
text-align: left;
}
.dropdown-menu button:not(.saved-setting-delete):hover,
.dropdown-menu label:hover {
background: #3a4144;
}
.menu-divider {
display: block;
height: 1px;
margin: 4px 0;
background: #596164;
}
.saved-setting {
display: grid;
grid-template-columns: 1fr 48px;
}
.saved-setting-delete {
padding: 2px !important;
text-align: center !important;
}
.column-group-label {
color: #9ca6aa;
}
.child-column {
padding-left: 17px !important;
}
.grid-shell {
width: 100%;
height: calc(100vh - 32px);
@@ -340,23 +795,21 @@ onMounted(load);
cursor: default;
}
table {
width: 1000px;
min-width: 1000px;
border-collapse: collapse;
border-collapse: separate;
table-layout: fixed;
background: #293033;
color: #f5f5f5;
font-size: 14px;
line-height: normal;
color: #f5f5f5;
cursor: default;
}
th,
td {
padding: 0 4px;
overflow: hidden;
border-right: 1px solid #40484b;
border-bottom: 1px solid #4a5255;
padding: 0 4px;
text-align: center;
overflow: hidden;
}
th {
height: 32px;
@@ -369,6 +822,36 @@ th {
height: 32px;
border-bottom-color: #303537;
}
.group-toggle,
.sort-button {
width: 100%;
height: 100%;
padding: 0;
border: 0;
color: inherit;
background: transparent;
font: inherit;
}
.group-toggle,
.sort-button.sortable {
cursor: pointer;
}
.group-toggle:hover,
.sort-button.sortable:hover,
.group-toggle:focus-visible,
.sort-button.sortable:focus-visible {
color: #fff;
background: #303638;
outline: 1px solid #8aa4b2;
outline-offset: -2px;
}
.sort-button:disabled {
opacity: 1;
}
.sort-indicator {
color: #8dd4ff;
font-size: 10px;
}
.filter-head th {
height: 32px;
padding: 3px 4px;
@@ -380,6 +863,14 @@ th {
background: #252a2c;
color: #fff;
}
.filter-head input:focus-visible {
border-color: #8dd4ff;
outline: 1px solid #8dd4ff;
}
.filter-head input::placeholder {
color: #8f999d;
font-size: 10px;
}
.filter-head span {
margin-left: 4px;
color: #a5b5bf;
@@ -392,7 +883,7 @@ tbody tr:hover {
background: #343c3f;
}
td {
white-space: nowrap;
white-space: pre-line;
}
.icon-cell {
padding: 0 4px;
@@ -416,21 +907,16 @@ td {
display: none;
}
.name-cell {
text-align: left;
color: skyblue;
text-align: left;
}
th:nth-child(9),
td:nth-child(9),
th:nth-child(10),
td:nth-child(10) {
.numeric-cell {
text-align: right;
}
.state {
margin: 40px;
}
.npc-0 {
color: skyblue;
}
.npc-0,
.npc-1 {
color: skyblue;
}
@@ -443,6 +929,10 @@ td:nth-child(10) {
.error {
color: #ff7373;
}
.empty-row td {
height: 68px;
text-align: center;
}
@media (max-width: 1000px) {
.general-page {
margin: 0;
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, ref } from 'vue';
import { trpc } from '../utils/trpc';
type Result = Awaited<ReturnType<typeof trpc.nation.getSecretGeneralList.query>>;
@@ -147,7 +148,7 @@ onMounted(load);
>
</td>
<td>{{ general.killTurn }}</td>
<td>{{ general.turnTime.slice(14, 19) }}</td>
<td>{{ formatServerDateTime(general.turnTime, { format: 'minuteSecond' }) }}</td>
</tr>
</tbody>
</table>
@@ -159,8 +160,8 @@ onMounted(load);
</tr>
<tr>
<td class="legacy-banner">
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
HideD(hided62@gmail.com) /
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD(hided62@gmail.com)
/
<a href="https://github.com/hided/SamK" target="_blank" rel="noopener noreferrer">Credit</a>
</td>
</tr>
+3 -9
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
@@ -67,16 +68,9 @@ const newVoteOptions = computed(() => newVoteOptionsText.value.split('\n').filte
const percentage = (count: number, total: number): string => ((count / Math.max(1, total)) * 100).toFixed(1);
const formatStartDate = (value: string): string => value.slice(0, 10);
const formatStartDate = (value: string): string => formatServerDateTime(value, { format: 'date' });
const formatCommentDate = (value: string): string => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
const pad = (part: number) => String(part).padStart(2, '0');
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
};
const formatCommentDate = (value: string): string => formatServerDateTime(value, { format: 'monthDayTime' });
const voteColor = (index: number): string =>
['#ff0000', '#ffa500', '#ffff00', '#008000', '#0000ff', '#000080', '#800080'][index % 7]!;
+193 -56
View File
@@ -1,7 +1,10 @@
<script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, ref } from 'vue';
import TournamentBracket from '../components/tournament/TournamentBracket.vue';
import GeneralIdentity from '../components/ui/GeneralIdentity.vue';
import { trpc } from '../utils/trpc';
import { resolveTournamentStageName } from '../utils/tournamentStatus';
type Snapshot = Awaited<ReturnType<typeof trpc.tournament.getSnapshot.query>>;
@@ -12,22 +15,11 @@ const loading = ref(false);
const error = ref<string | null>(null);
const actionMessage = ref<string | null>(null);
const adminEnabled = ref(false);
const activeFinalGroup = ref(0);
const activePreliminaryGroup = ref(0);
const typeNames = ['전력전', '통솔전', '일기토', '설전'];
const stageNames = [
'경기 없음',
'참가 모집중',
'예선 진행중',
'본선 추첨중',
'본선 진행중',
'16강 배정중',
'베팅 진행중',
'16강 진행중',
'8강 진행중',
'4강 진행중',
'결승 진행중',
];
const typeStatNames = ['종합', '통솔', '무력', '지력'];
const errorText = (value: unknown) => (value instanceof Error ? value.message : String(value));
const load = async () => {
@@ -62,7 +54,9 @@ const matchesAt = (stage: number) =>
.sort((a, b) => a.roundIndex - b.roundIndex);
const nameOf = (id?: number) => (id ? (participantsById.value.get(id)?.name ?? `#${id}`) : '-');
const totalBet = computed(() => betting.value?.totalAmount ?? 0);
const openingTime = computed(() => snapshot.value?.state?.nextAt?.slice(11, 16) ?? '--:--');
const openingTime = computed(() =>
formatServerDateTime(snapshot.value?.state?.nextAt, { format: 'hourMinute', fallback: '--:--' })
);
const betTotals = computed(() => betting.value?.totals as Record<number, number> | undefined);
const isParticipant = computed(() =>
(snapshot.value?.participants ?? []).some((participant) => participant.id === myGeneralId.value)
@@ -74,6 +68,26 @@ const groups = computed(() =>
.sort((a, b) => (a.finalRank ?? 99) - (b.finalRank ?? 99) || (a.groupNo ?? 99) - (b.groupNo ?? 99))
)
);
const preliminaryGroups = computed(() =>
Array.from({ length: 8 }, (_, index) =>
(snapshot.value?.participants ?? [])
.filter((participant) => participant.groupId === index)
.sort((a, b) => (a.seedRank ?? 99) - (b.seedRank ?? 99) || (a.groupNo ?? 99) - (b.groupNo ?? 99))
)
);
const groupNames = ['一', '二', '三', '四', '五', '六', '七', '八'];
const statOf = (participant: Snapshot['participants'][number] | undefined): number | '' => {
if (!participant) return '';
const type = snapshot.value?.state?.type ?? 0;
if (type === 0) return participant.leadership + participant.strength + participant.intel;
if (type === 1) return participant.leadership;
if (type === 2) return participant.strength;
return participant.intel;
};
const gamesOf = (participant: Snapshot['participants'][number] | undefined): number | '' =>
participant ? (participant.win ?? 0) + (participant.draw ?? 0) + (participant.lose ?? 0) : '';
const pointsOf = (participant: Snapshot['participants'][number] | undefined): number | '' =>
participant ? (participant.win ?? 0) * 3 + (participant.draw ?? 0) : '';
const currentMatch = computed(() => {
const state = snapshot.value?.state;
if (!state || state.stage < 7 || state.stage > 10) return null;
@@ -152,7 +166,7 @@ const start = async () => {
<section class="operator-row bg0">운영자 메세지 : <span></span></section>
<section class="state-row bg0">
<span class="type">{{ typeNames[snapshot?.state?.type ?? 0] }}</span>
({{ stageNames[snapshot?.state?.stage ?? 0] ?? '상태 확인 중' }}, 개막시간 {{ openingTime }}, 경기당
({{ resolveTournamentStageName(snapshot?.state?.stage ?? 0) }}, 개막시간 {{ openingTime }}, 경기당
{{ snapshot?.state?.termSeconds ?? '-' }})
</section>
<section class="section-title bg2">16 승자전</section>
@@ -164,7 +178,6 @@ const start = async () => {
:winner-id="snapshot?.state?.winnerId"
:bet-totals="betTotals"
:total-bet="totalBet"
force-desktop
/>
<section v-if="currentMatch" class="fight bg0">
@@ -173,18 +186,35 @@ const start = async () => {
</section>
<section class="section-title groups-title bg2">조별 본선 순위</section>
<div class="group-tabs bg0" role="tablist" aria-label="본선 선택">
<button
v-for="(groupName, groupIndex) in groupNames"
:key="`final-tab-${groupName}`"
type="button"
role="tab"
:aria-selected="activeFinalGroup === groupIndex"
:class="{ active: activeFinalGroup === groupIndex }"
@click="activeFinalGroup = groupIndex"
>
{{ groupName }}
</button>
</div>
<section class="group-grid bg0">
<table v-for="(group, groupIndex) in groups" :key="groupIndex">
<table
v-for="(group, groupIndex) in groups"
:key="groupIndex"
:class="{ 'mobile-active': activeFinalGroup === groupIndex }"
>
<caption>
{{
['一', '二', '三', '四', '五', '六', '七', '八'][groupIndex]
groupNames[groupIndex]
}}
</caption>
<thead>
<tr>
<th></th>
<th>장수</th>
<th>{{ typeNames[snapshot?.state?.type ?? 0].replace('전', '') }}</th>
<th>{{ typeStatNames[snapshot?.state?.type ?? 0] }}</th>
<th></th>
<th></th>
<th></th>
@@ -196,26 +226,21 @@ const start = async () => {
<tbody>
<tr v-for="rowIndex in 4" :key="rowIndex">
<td>{{ rowIndex }}</td>
<td>{{ group[rowIndex - 1]?.name ?? '' }}</td>
<td>
{{
group[rowIndex - 1]
? (group[rowIndex - 1]!.win ?? 0) +
(group[rowIndex - 1]!.draw ?? 0) +
(group[rowIndex - 1]!.lose ?? 0)
: ''
}}
<td class="general-cell">
<GeneralIdentity
v-if="group[rowIndex - 1]"
:name="group[rowIndex - 1]!.name"
:picture="group[rowIndex - 1]!.picture"
:image-server="group[rowIndex - 1]!.imageServer"
:icon-size="24"
/>
</td>
<td>{{ statOf(group[rowIndex - 1]) }}</td>
<td>{{ gamesOf(group[rowIndex - 1]) }}</td>
<td>{{ group[rowIndex - 1]?.win ?? '' }}</td>
<td>{{ group[rowIndex - 1]?.draw ?? '' }}</td>
<td>{{ group[rowIndex - 1]?.lose ?? '' }}</td>
<td>
{{
group[rowIndex - 1]
? (group[rowIndex - 1]!.win ?? 0) * 3 + (group[rowIndex - 1]!.draw ?? 0)
: ''
}}
</td>
<td>{{ pointsOf(group[rowIndex - 1]) }}</td>
<td>{{ group[rowIndex - 1]?.gl ?? '' }}</td>
</tr>
</tbody>
@@ -223,18 +248,35 @@ const start = async () => {
</section>
<section class="section-title groups-title bg2">조별 예선 순위</section>
<div class="group-tabs bg0" role="tablist" aria-label="예선 선택">
<button
v-for="(groupName, groupIndex) in groupNames"
:key="`preliminary-tab-${groupName}`"
type="button"
role="tab"
:aria-selected="activePreliminaryGroup === groupIndex"
:class="{ active: activePreliminaryGroup === groupIndex }"
@click="activePreliminaryGroup = groupIndex"
>
{{ groupName }}
</button>
</div>
<section class="group-grid preliminary-grid bg0">
<table v-for="groupIndex in 8" :key="`preliminary-${groupIndex}`">
<table
v-for="(group, groupIndex) in preliminaryGroups"
:key="`preliminary-${groupIndex}`"
:class="{ 'mobile-active': activePreliminaryGroup === groupIndex }"
>
<caption>
{{
['一', '二', '三', '四', '五', '六', '七', '八'][groupIndex - 1]
groupNames[groupIndex]
}}
</caption>
<thead>
<tr>
<th></th>
<th>장수</th>
<th>{{ typeNames[snapshot?.state?.type ?? 0].replace('전', '') }}</th>
<th>{{ typeStatNames[snapshot?.state?.type ?? 0] }}</th>
<th></th>
<th></th>
<th></th>
@@ -246,14 +288,22 @@ const start = async () => {
<tbody>
<tr v-for="rowIndex in 8" :key="rowIndex">
<td>{{ rowIndex }}</td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td class="general-cell">
<GeneralIdentity
v-if="group[rowIndex - 1]"
:name="group[rowIndex - 1]!.name"
:picture="group[rowIndex - 1]!.picture"
:image-server="group[rowIndex - 1]!.imageServer"
:icon-size="24"
/>
</td>
<td>{{ statOf(group[rowIndex - 1]) }}</td>
<td>{{ gamesOf(group[rowIndex - 1]) }}</td>
<td>{{ group[rowIndex - 1]?.win ?? '' }}</td>
<td>{{ group[rowIndex - 1]?.draw ?? '' }}</td>
<td>{{ group[rowIndex - 1]?.lose ?? '' }}</td>
<td>{{ pointsOf(group[rowIndex - 1]) }}</td>
<td>{{ group[rowIndex - 1]?.gl ?? '' }}</td>
</tr>
</tbody>
</table>
@@ -287,8 +337,7 @@ const start = async () => {
<button class="close-button" type="button" @click="navigate"> 닫기</button>
</RouterLink>
<small>
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
HideD(hided62@gmail.com) / Credit
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD(hided62@gmail.com) / Credit
</small>
</footer>
@@ -302,9 +351,10 @@ const start = async () => {
<style scoped>
.legacy-page {
width: 2009px;
height: 1059px;
overflow: hidden;
width: 100%;
max-width: 1200px;
min-width: 0;
min-height: 100vh;
margin: 0 auto;
color: #fff;
font-family: var(--sammo-font-sans);
@@ -431,13 +481,15 @@ button:focus-visible {
}
.group-grid {
display: grid;
grid-template-columns: repeat(8, 250px);
grid-template-columns: repeat(4, minmax(0, 1fr));
align-items: start;
gap: 8px;
padding: 8px;
}
table {
width: 250px;
width: 100%;
border-collapse: collapse;
table-layout: auto;
table-layout: fixed;
}
caption {
padding: 3px;
@@ -450,14 +502,99 @@ th {
}
th,
td {
height: 17px;
height: 30px;
border: 1px solid #555;
padding: 1px 3px;
}
.group-grid th:first-child,
.group-grid td:first-child {
width: 24px;
}
.group-grid th:nth-child(2),
.group-grid td:nth-child(2) {
width: 92px;
}
.general-cell {
overflow: hidden;
}
.group-tabs {
display: none;
}
.admin-row {
text-align: left;
}
.error-row {
color: #ff8080;
}
@media (max-width: 800px) {
.legacy-page {
max-width: 100%;
font-size: 13px;
}
.legacy-title {
height: auto;
min-height: 55px;
}
.state-row {
font-size: 18px;
}
.section-title {
font-size: 20px;
}
.group-tabs {
display: grid;
grid-template-columns: repeat(8, minmax(44px, 1fr));
overflow-x: auto;
padding: 6px;
gap: 4px;
}
.group-tabs button {
min-width: 44px;
height: 34px;
margin: 0;
border-radius: 3px;
}
.group-tabs button.active {
border-color: #f39c12;
background: #8a5b13;
color: #fff;
}
.group-grid {
display: block;
overflow-x: auto;
padding: 6px 0;
}
.group-grid table {
display: none;
min-width: 370px;
}
.group-grid table.mobile-active {
display: table;
}
.group-grid th,
.group-grid td {
height: 31px;
padding: 1px;
font-size: 11px;
}
.group-grid th:first-child,
.group-grid td:first-child {
width: 22px;
}
.group-grid th:nth-child(2),
.group-grid td:nth-child(2) {
width: 108px;
}
.tournament-guide {
padding: 10px;
font-size: 11px;
line-height: 16px;
}
.tournament-footer {
padding: 10px 0 0;
}
.tournament-footer small {
white-space: normal;
}
}
</style>
+4 -6
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
@@ -50,10 +51,7 @@ const onlineRows = computed(() =>
}))
);
const timeLabel = (value: string): string => {
const timePart = value.includes('T') ? value.split('T')[1] : value.slice(11);
return (timePart ?? '').slice(0, 5);
};
const timeLabel = (value: string): string => formatServerDateTime(value, { format: 'hourMinute' });
const trafficColor = (percentage: number): string => {
const channel = (value: number): string =>
@@ -204,8 +202,8 @@ onMounted(() => {
</tr>
<tr>
<td class="banner">
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
HideD(hided62@gmail.com) /
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD(hided62@gmail.com)
/
<a href="https://sam.hided.net/wiki/hidche/credit" target="_blank" rel="noreferrer">Credit</a>
</td>
</tr>
+2 -4
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
@@ -166,10 +167,7 @@ const hideMemberPopup = () => {
const iconPath = (troop: Troop): string => resolveGeneralIconUrl(troop.leader ?? {});
const formatTurn = (turnTime: string | null): string => {
if (!turnTime) {
return '--:--';
}
return turnTime.slice(14, 19);
return formatServerDateTime(turnTime, { format: 'minuteSecond', fallback: '--:--' });
};
onMounted(() => {