Merge branch 'main' into feature/dynasty-list-parity

# Conflicts:
#	tools/frontend-legacy-parity/playwright.config.mjs
This commit is contained in:
2026-07-26 05:41:13 +00:00
105 changed files with 13336 additions and 3227 deletions
@@ -7,6 +7,7 @@ interface Props {
options: BattleSimOptions;
mode: 'attacker' | 'defender';
title: string;
canImportServer: boolean;
}
const props = defineProps<Props>();
@@ -59,7 +60,19 @@ const officerLevelOptions = [
<div class="general-subtitle">No {{ general.no }}</div>
</div>
<div class="general-actions">
<button class="action" type="button" @click="emit('import')">서버에서 가져오기</button>
<button
class="action"
type="button"
:disabled="!canImportServer"
:title="
canImportServer
? '게임 서버의 장수 정보를 불러옵니다.'
: '게임 장수를 보유해야 사용할 수 있습니다.'
"
@click="emit('import')"
>
서버에서 가져오기
</button>
<button class="action" type="button" @click="emit('save')">저장</button>
<input ref="fileInput" type="file" accept=".json" hidden @change="handleFileChange" />
<button class="action" type="button" @click="triggerLoad">불러오기</button>
@@ -187,21 +200,11 @@ const officerLevelOptions = [
<div class="form-row">
<label class="field">
<span>훈련</span>
<input
v-model.number="general.train"
type="number"
min="40"
:max="options.config.maxTrainByWar"
/>
<input v-model.number="general.train" type="number" min="40" :max="options.config.maxTrainByWar" />
</label>
<label class="field">
<span>사기</span>
<input
v-model.number="general.atmos"
type="number"
min="40"
:max="options.config.maxAtmosByWar"
/>
<input v-model.number="general.atmos" type="number" min="40" :max="options.config.maxAtmosByWar" />
</label>
<label class="field">
<span>전특</span>
@@ -308,30 +311,15 @@ const officerLevelOptions = [
<div class="form-row buff-row">
<label class="field">
<span>상대 회피</span>
<input
v-model.number="general.inheritBuff.warAvoidRatioOppose"
type="number"
min="0"
max="5"
/>
<input v-model.number="general.inheritBuff.warAvoidRatioOppose" type="number" min="0" max="5" />
</label>
<label class="field">
<span>상대 필살</span>
<input
v-model.number="general.inheritBuff.warCriticalRatioOppose"
type="number"
min="0"
max="5"
/>
<input v-model.number="general.inheritBuff.warCriticalRatioOppose" type="number" min="0" max="5" />
</label>
<label class="field">
<span>상대 계략</span>
<input
v-model.number="general.inheritBuff.warMagicTrialProbOppose"
type="number"
min="0"
max="5"
/>
<input v-model.number="general.inheritBuff.warMagicTrialProbOppose" type="number" min="0" max="5" />
</label>
</div>
</div>
@@ -391,6 +379,11 @@ const officerLevelOptions = [
color: #f0b6b6;
}
.action:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.form-block {
display: flex;
flex-direction: column;
@@ -34,8 +34,9 @@ const stateOffset = computed(() => -6 * props.mapScale);
</script>
<template>
<div
<RouterLink
class="map-city"
:to="{ 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 },
@@ -45,20 +46,22 @@ const stateOffset = computed(() => -6 * props.mapScale);
@mouseleave="emit('leave')"
@click.stop="emit('select', props.city.id)"
>
<div
class="city-dot"
:style="{ backgroundColor: props.city.color, width: `${size}px`, height: `${size}px` }"
>
<div class="city-dot" :style="{ backgroundColor: props.city.color, width: `${size}px`, height: `${size}px` }">
<span v-if="props.city.isCapital" class="capital" />
</div>
<div
v-if="props.city.state > 0"
class="city-state"
:class="`state-${props.city.stateClass}`"
:style="{ width: `${stateSize}px`, height: `${stateSize}px`, left: `${stateOffset}px`, top: `${stateOffset}px` }"
:style="{
width: `${stateSize}px`,
height: `${stateSize}px`,
left: `${stateOffset}px`,
top: `${stateOffset}px`,
}"
/>
<div v-if="props.showName" class="city-name">{{ props.city.name }}</div>
</div>
</RouterLink>
</template>
<style scoped>
@@ -71,6 +74,8 @@ const stateOffset = computed(() => -6 * props.mapScale);
transform: translate(-50%, -50%);
font-size: 0.65rem;
color: rgba(232, 221, 196, 0.8);
cursor: pointer;
text-decoration: none;
}
.city-dot {
@@ -149,8 +149,9 @@ const cityStateStyle = computed(() => ({
</script>
<template>
<div
<RouterLink
class="city-base"
:to="{ 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)"
@@ -172,7 +173,7 @@ const cityStateStyle = computed(() => ({
<div v-if="stateIcon" class="city-state" :style="cityStateStyle">
<img :src="stateIcon" />
</div>
</div>
</RouterLink>
</template>
<style scoped>
@@ -181,6 +182,8 @@ const cityStateStyle = computed(() => ({
transform: translate(-50%, -50%);
font-size: 0.65rem;
color: rgba(232, 221, 196, 0.9);
cursor: pointer;
text-decoration: none;
}
.city-bg {
@@ -1,13 +1,25 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import SkeletonLines from '../ui/SkeletonLines.vue';
import { computed, reactive } from 'vue';
import type { MessageType } from '@sammo-ts/logic';
import SkeletonLines from '../ui/SkeletonLines.vue';
import MessagePlate from './MessagePlate.vue';
interface MessageTarget {
generalId: number;
generalName: string;
nationId: number;
nationName: string;
color: string;
icon: string;
}
interface MessageEntry {
id: number;
text: string;
time: string;
msgType: MessageType;
src: MessageTarget;
dest: MessageTarget | null;
option?: Record<string, unknown> | null;
}
@@ -16,6 +28,22 @@ interface MessageBucket {
public: MessageEntry[];
national: MessageEntry[];
diplomacy: MessageEntry[];
permission: number;
latestRead: {
private: number;
diplomacy: number;
};
}
interface MailboxGroup {
label: string;
color?: string;
options: Array<{
label: string;
value: number;
disabled?: boolean;
color?: string;
}>;
}
const props = defineProps<{
@@ -23,7 +51,10 @@ const props = defineProps<{
loading: boolean;
targetMailbox: number;
draftText: string;
mailboxOptions: Array<{ label: string; value: number; disabled?: boolean }>;
mailboxGroups: MailboxGroup[];
generalId: number;
generalName: string;
nationId: number;
canRespondDiplomacy: boolean;
}>();
@@ -34,213 +65,390 @@ const emit = defineEmits<{
(event: 'refresh'): void;
(event: 'load-older', type: MessageType): void;
(event: 'respond', messageId: number, response: boolean): void;
(event: 'read-latest', type: 'private' | 'diplomacy', messageId: number): void;
(event: 'delete', messageId: number): void;
}>();
const messageTabs: Array<{ key: MessageType; label: string }> = [
{ key: 'public', label: '전체' },
{ key: 'national', label: '국가' },
{ key: 'private', label: '개인' },
{ key: 'diplomacy', label: '외교' },
const sections: Array<{ type: MessageType; label: string; className: string }> = [
{ type: 'public', label: '전체 메시지', className: 'PublicTalk' },
{ type: 'national', label: '국가 메시지', className: 'NationalTalk' },
{ type: 'private', label: '개인 메시지', className: 'PrivateTalk' },
{ type: 'diplomacy', label: '외교 메시지', className: 'DiplomacyTalk' },
];
const activeTab = ref<MessageType>('public');
const activeMessages = computed(() => {
if (!props.messages) {
return [] as MessageEntry[];
}
return props.messages[activeTab.value] ?? [];
const visibleLimits = reactive<Record<MessageType, number>>({
public: Number.POSITIVE_INFINITY,
national: Number.POSITIVE_INFINITY,
private: Number.POSITIVE_INFINITY,
diplomacy: Number.POSITIVE_INFINITY,
});
const bucket = (type: MessageType): MessageEntry[] => props.messages?.[type] ?? [];
const visibleMessages = (type: MessageType): MessageEntry[] => bucket(type).slice(0, visibleLimits[type]);
const permission = computed(() => props.messages?.permission ?? -1);
const setMailbox = (value: string) => {
const parsed = Number(value);
emit('update:targetMailbox', Number.isFinite(parsed) ? parsed : 0);
};
const isDiplomacyPrompt = (message: MessageEntry): boolean =>
message.msgType === 'diplomacy' &&
(message.option?.action === 'noAggression' ||
message.option?.action === 'cancelNA' ||
message.option?.action === 'stopWar');
const respond = (messageId: number, response: boolean) => {
if (!window.confirm(response ? '수락하시겠습니까?' : '거절하시겠습니까?')) {
const submit = () => {
if (!props.draftText.trim()) {
emit('refresh');
return;
}
emit('send');
};
const newestIncomingId = (type: 'private' | 'diplomacy'): number =>
bucket(type)
.filter((message) => message.src.generalId !== props.generalId)
.reduce((latest, message) => Math.max(latest, message.id), 0);
const canMarkRead = (type: 'private' | 'diplomacy'): boolean => {
if (!props.messages) {
return false;
}
const newest = newestIncomingId(type);
return newest > props.messages.latestRead[type];
};
const markRead = (type: 'private' | 'diplomacy') => {
const messageId = newestIncomingId(type);
if (messageId > 0) {
emit('read-latest', type, messageId);
}
};
const setSectionMailbox = (type: MessageType) => {
if (type === 'public') {
emit('update:targetMailbox', 9999);
} else if (type === 'national') {
emit('update:targetMailbox', 9000 + props.nationId);
}
};
const setReplyTarget = (type: MessageType, target: MessageTarget) => {
const mailbox =
(type === 'diplomacy' || type === 'national') && target.nationId !== props.nationId
? 9000 + target.nationId
: target.generalId;
if (mailbox > 0) {
emit('update:targetMailbox', mailbox);
}
};
const fold = (type: MessageType) => {
if (bucket(type).length >= 10) {
visibleLimits[type] = 10;
}
};
const forwardResponse = (messageId: number, response: boolean) => {
emit('respond', messageId, response);
};
</script>
<template>
<div class="message-panel">
<div class="message-input">
<select
class="message-select"
:value="targetMailbox"
@change="setMailbox(($event.target as HTMLSelectElement).value)"
>
<option
v-for="option in mailboxOptions"
:key="option.label"
:value="option.value"
:disabled="option.disabled"
<div class="MessagePanel">
<div class="MessageInputForm">
<div id="mailbox_list-col">
<select
id="mailbox_list"
class="message-select"
:value="targetMailbox"
aria-label="메시지 수신 대상"
@change="setMailbox(($event.target as HTMLSelectElement).value)"
>
{{ option.label }}
</option>
</select>
<input
class="message-text"
type="text"
maxlength="99"
:value="draftText"
placeholder="메시지 입력"
@input="emit('update:draftText', ($event.target as HTMLInputElement).value)"
@keydown.enter="emit('send')"
/>
<button class="message-send" @click="emit('send')">전송</button>
</div>
<div class="message-tabs">
<button
v-for="tab in messageTabs"
:key="tab.key"
:class="{ active: activeTab === tab.key }"
@click="activeTab = tab.key"
>
{{ tab.label }}
</button>
<button class="refresh" @click="emit('refresh')">갱신</button>
</div>
<div v-if="props.loading">
<SkeletonLines :lines="4" />
</div>
<div v-else-if="!props.messages" class="empty">메시지를 불러오지 못했습니다.</div>
<div v-else class="message-list">
<div v-if="activeMessages.length === 0" class="empty">메시지가 없습니다.</div>
<div v-else>
<div v-for="message in activeMessages" :key="message.id" class="message-item">
<div class="text">{{ message.text }}</div>
<div v-if="isDiplomacyPrompt(message)" class="message-response">
<button class="accept" :disabled="!canRespondDiplomacy" @click="respond(message.id, true)">
수락
</button>
<button class="decline" :disabled="!canRespondDiplomacy" @click="respond(message.id, false)">
거절
</button>
</div>
<div class="time">{{ message.time }}</div>
</div>
<button class="load-older" @click="emit('load-older', activeTab)">이전 메시지</button>
<optgroup
v-for="group in mailboxGroups"
:key="group.label"
:label="group.label"
:style="{ backgroundColor: group.color ?? '#000000', color: '#ffffff' }"
>
<option
v-for="option in group.options"
:key="`${group.label}-${option.value}`"
:value="option.value"
:disabled="option.disabled"
:style="{ backgroundColor: option.color ?? '#000000', color: '#ffffff' }"
>
{{ option.label }}
</option>
</optgroup>
</select>
</div>
<div id="msg_input-col">
<input
class="message-text"
type="text"
maxlength="99"
:value="draftText"
aria-label="메시지 입력"
@input="emit('update:draftText', ($event.target as HTMLInputElement).value)"
@keydown.enter="submit"
/>
</div>
<div id="msg_submit-col">
<button class="message-send" type="button" @click="submit">서신전달&amp;갱신</button>
</div>
</div>
<div v-if="loading && !messages" class="message-loading">
<SkeletonLines :lines="4" />
</div>
<template v-else>
<section
v-for="section in sections"
:key="section.type"
:class="['message-section', section.className]"
:data-message-type="section.type"
>
<div class="stickyAnchor"></div>
<header class="BoardHeader">
<div class="header-label">{{ section.label }}</div>
<button
v-if="section.type === 'public' || section.type === 'national'"
class="btn-more-small action-primary"
type="button"
@click="setSectionMailbox(section.type)"
>
여기로
</button>
<button
v-else
class="btn-more-small action-secondary"
type="button"
:disabled="!canMarkRead(section.type)"
@click="markRead(section.type)"
>
모두 읽음
</button>
</header>
<div v-if="bucket(section.type).length === 0" class="empty-message">메시지가 없습니다.</div>
<div v-else class="MessageList">
<MessagePlate
v-for="message in visibleMessages(section.type)"
:key="message.id"
:message="message"
:general-id="generalId"
:general-name="generalName"
:nation-id="nationId"
:permission="permission"
:can-respond-diplomacy="canRespondDiplomacy"
@set-target="setReplyTarget"
@delete="emit('delete', $event)"
@respond="forwardResponse"
/>
<div class="Actions">
<button class="fold-message" type="button" @click="fold(section.type)">접기</button>
<button class="load-older" type="button" @click="emit('load-older', section.type)">
이전 메시지 불러오기
</button>
</div>
</div>
</section>
</template>
</div>
</template>
<style scoped>
.message-panel {
display: flex;
flex-direction: column;
gap: 12px;
.MessagePanel {
color: #fff;
font-size: 14px;
}
.message-input {
.MessageInputForm {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 4fr) minmax(0, 1fr);
grid-template-areas: 'mailbox input submit';
background-color: #302016;
background-image: url('/image/game/back_walnut.jpg');
}
#mailbox_list-col {
grid-area: mailbox;
}
#msg_input-col {
grid-area: input;
}
#msg_submit-col {
grid-area: submit;
}
#mailbox_list-col,
#msg_input-col,
#msg_submit-col {
display: grid;
grid-template-columns: minmax(90px, 120px) 1fr auto;
gap: 6px;
}
.message-select,
.message-text,
.message-send {
height: 35.5px;
border: 1px solid #6c757d;
border-radius: 4px;
font: inherit;
}
.message-select {
width: 100%;
background-color: #212529;
padding: 4px 30px 4px 12px;
color: #fff;
font-weight: 700;
}
.message-text {
background: rgba(16, 16, 16, 0.8);
border: 1px solid rgba(201, 164, 90, 0.4);
color: inherit;
padding: 6px;
font-size: 0.75rem;
width: 100%;
background-color: #fff;
padding: 4px 8px;
color: #212529;
}
.message-send,
.action-primary {
background-color: #337ab7;
color: #fff;
}
.message-send {
border: 1px solid rgba(201, 164, 90, 0.4);
padding: 6px 10px;
font-size: 0.75rem;
cursor: pointer;
}
.message-tabs {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.message-tabs button {
border: 1px solid rgba(201, 164, 90, 0.4);
padding: 4px 8px;
font-size: 0.7rem;
cursor: pointer;
}
.message-tabs button.active {
background: rgba(201, 164, 90, 0.2);
.message-send:hover {
background-color: #375a7f;
}
.message-tabs .refresh {
margin-left: auto;
.message-send:focus,
.message-send:focus-visible {
outline: none !important;
outline-width: 0 !important;
box-shadow: none !important;
}
.message-list {
.message-loading {
padding: 8px;
}
.message-section {
min-width: 0;
}
.BoardHeader {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 25px;
align-items: center;
outline: 1px solid gray;
background-color: #302016;
background-image: url('/image/game/back_walnut.jpg');
color: #fff;
}
.message-item {
border: 1px solid rgba(201, 164, 90, 0.2);
padding: 6px;
font-size: 0.75rem;
.header-label {
flex: 1;
}
.message-item .time {
margin-top: 4px;
font-size: 0.65rem;
color: rgba(232, 221, 196, 0.6);
}
.message-response {
display: flex;
justify-content: flex-end;
gap: 4px;
margin-top: 5px;
margin-right: 5px;
}
.message-response button {
border: 1px solid rgba(201, 164, 90, 0.4);
padding: 3px 10px;
font-size: 0.7rem;
.btn-more-small {
margin: 1px;
border: 1px solid transparent;
border-radius: 3px;
padding: 2px 6px;
font-size: 11.2px;
line-height: 1.5;
cursor: pointer;
}
.message-response .accept {
color: #8fd18f;
.action-secondary {
border-color: #6c757d;
background-color: #6c757d;
color: #fff;
}
.message-response .decline {
color: #e09a9a;
.btn-more-small:disabled {
cursor: default;
opacity: 0.65;
}
.message-response button:disabled {
cursor: not-allowed;
opacity: 0.5;
.empty-message {
min-height: 22px;
}
.MessageList {
overflow-x: hidden;
}
.Actions {
display: grid;
}
.fold-message,
.load-older {
border: 1px solid transparent;
padding: 6px 12px;
color: #fff;
font: inherit;
cursor: pointer;
}
.fold-message {
background-color: #212529;
}
.load-older {
border: 1px dashed rgba(201, 164, 90, 0.3);
padding: 6px;
font-size: 0.7rem;
cursor: pointer;
background-color: #6c757d;
}
.empty {
color: rgba(232, 221, 196, 0.6);
@media (min-width: 940px) {
.MessagePanel {
display: grid;
grid-template-columns: 1fr 1fr;
}
.MessageInputForm,
.message-loading {
grid-column: 1 / 3;
}
.PublicTalk,
.PrivateTalk {
border-right: 1px solid gray;
}
.fold-message {
display: none;
}
.MessageList {
overflow-y: auto;
}
}
@media (max-width: 939.98px) {
.MessageInputForm {
position: sticky;
z-index: 5;
top: 0;
grid-template-columns: 1fr 1fr;
grid-template-areas:
'mailbox submit'
'input input';
}
.message-text {
height: 33.5px;
}
.BoardHeader {
position: sticky;
z-index: 4;
top: 62px;
}
}
</style>
@@ -0,0 +1,431 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import type { MessageType } from '@sammo-ts/logic';
interface MessageTarget {
generalId: number;
generalName: string;
nationId: number;
nationName: string;
color: string;
icon: string;
}
interface MessageEntry {
id: number;
text: string;
time: string;
msgType: MessageType;
src: MessageTarget;
dest: MessageTarget | null;
option?: Record<string, unknown> | null;
}
const props = defineProps<{
message: MessageEntry;
generalId: number;
generalName: string;
nationId: number;
permission: number;
canRespondDiplomacy: boolean;
}>();
const emit = defineEmits<{
(event: 'set-target', type: MessageType, target: MessageTarget): void;
(event: 'delete', messageId: number): void;
(event: 'respond', messageId: number, response: boolean): void;
}>();
const now = ref(Date.now());
let deleteTimer: number | null = null;
const destination = computed<MessageTarget>(
() =>
props.message.dest ?? {
generalId: 0,
generalName: '',
nationId: 0,
nationName: '재야',
color: '#000000',
icon: '/image/icons/default.jpg',
}
);
const invalid = computed(() => props.message.option?.invalid === true);
const hasAction = computed(() => typeof props.message.option?.action === 'string');
const nationDirection = computed(() => {
if (props.message.src.nationId === destination.value.nationId) {
return 'local';
}
return props.message.src.nationId === props.nationId ? 'src' : 'dest';
});
const parseMessageTime = (): number => {
const normalized = props.message.time.includes('T')
? props.message.time
: `${props.message.time.replace(' ', 'T')}Z`;
return Date.parse(normalized);
};
const deletable = computed(() => {
if (invalid.value || hasAction.value || props.message.src.generalId !== props.generalId) {
return false;
}
if (props.message.option?.deletable === false) {
return false;
}
const sentAt = parseMessageTime();
return Number.isFinite(sentAt) && sentAt + 5 * 60 * 1000 > now.value;
});
const scheduleDeleteExpiry = () => {
const sentAt = parseMessageTime();
if (!Number.isFinite(sentAt)) {
return;
}
const delay = sentAt + 5 * 60 * 1000 - Date.now();
if (delay <= 0) {
now.value = Date.now();
return;
}
deleteTimer = window.setTimeout(() => {
now.value = Date.now();
}, delay);
};
const isBright = (color: string): boolean => {
const match = /^#([0-9a-f]{6})$/i.exec(color);
if (!match) {
return false;
}
const value = Number.parseInt(match[1]!, 16);
const red = (value >> 16) & 0xff;
const green = (value >> 8) & 0xff;
const blue = value & 0xff;
return red * 0.299 + green * 0.587 + blue * 0.114 > 160;
};
const iconUrl = computed(() => {
const icon = props.message.src.icon?.trim();
if (!icon) {
return '/image/icons/default.jpg';
}
if (icon.startsWith('/') || /^https?:\/\//i.test(icon)) {
return icon;
}
return `${import.meta.env.BASE_URL}${icon.replace(/^\/+/, '')}`;
});
const targetClass = (target: MessageTarget) => ({
'msg-target': true,
'msg-bright': isBright(target.color),
'msg-dark': !isBright(target.color),
});
const setTarget = (target: MessageTarget) => {
emit('set-target', props.message.msgType, target);
};
const requestDelete = () => {
if (!window.confirm('삭제하시겠습니까?')) {
return;
}
emit('delete', props.message.id);
};
const respond = (response: boolean) => {
if (!window.confirm(response ? '수락하시겠습니까?' : '거절하시겠습니까?')) {
return;
}
emit('respond', props.message.id, response);
};
onMounted(scheduleDeleteExpiry);
onBeforeUnmount(() => {
if (deleteTimer !== null) {
window.clearTimeout(deleteTimer);
}
});
</script>
<template>
<article
:id="`msg_${message.id}`"
:class="['msg-plate', `msg-plate-${message.msgType}`, `msg-plate-${nationDirection}`]"
:data-id="message.id"
>
<div class="msg-icon">
<img class="general-icon" width="64" height="64" :src="iconUrl" :alt="message.src.generalName" />
</div>
<div class="msg-body">
<div class="msg-header">
<button v-if="deletable" class="delete-message" type="button" @click="requestDelete"></button>
<template v-if="message.msgType === 'private'">
<template v-if="message.src.generalId === generalId">
<span :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }"
></span
>
<span class="msg-from-to"></span>
<button
:class="targetClass(destination)"
:style="{ backgroundColor: destination.color }"
type="button"
@click="setTarget(destination)"
>
{{ destination.generalName }}:{{ destination.nationName }} |
</button>
</template>
<template v-else>
<button
:class="targetClass(message.src)"
:style="{ backgroundColor: message.src.color }"
type="button"
@click="setTarget(message.src)"
>
{{ message.src.generalName }}:{{ message.src.nationName }} |
</button>
<span class="msg-from-to"></span>
<span :class="targetClass(destination)" :style="{ backgroundColor: destination.color }"
></span
>
</template>
</template>
<template v-else-if="message.msgType === 'national' && message.src.nationId === destination.nationId">
<span :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }">
{{ message.src.generalName }}
</span>
</template>
<template
v-else-if="(message.msgType === 'national' || message.msgType === 'diplomacy') && permission >= 4"
>
<template v-if="message.src.nationId === nationId">
<span :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }">
{{ message.src.generalName }}
</span>
<span class="msg-from-to"></span>
<button
:class="targetClass(destination)"
:style="{ backgroundColor: destination.color }"
type="button"
@click="setTarget(destination)"
>
{{ destination.nationName }} |
</button>
</template>
<button
v-else
:class="targetClass(message.src)"
:style="{ backgroundColor: message.src.color }"
type="button"
@click="setTarget(message.src)"
>
{{ message.src.generalName }}:{{ message.src.nationName }} |
</button>
</template>
<template v-else-if="message.msgType === 'national' || message.msgType === 'diplomacy'">
<template v-if="message.src.nationId === nationId">
<span :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }">
{{ message.src.generalName }}
</span>
<span class="msg-from-to"></span>
<span :class="targetClass(destination)" :style="{ backgroundColor: destination.color }">
{{ destination.nationName }}
</span>
</template>
<span v-else :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }">
{{ message.src.generalName }}:{{ message.src.nationName }}
</span>
</template>
<button
v-else-if="message.src.generalId !== generalId"
:class="targetClass(message.src)"
:style="{ backgroundColor: message.src.color }"
type="button"
@click="setTarget(message.src)"
>
{{ message.src.generalName }}:{{ message.src.nationName }} |
</button>
<span v-else :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }">
{{ message.src.generalName }}
</span>
<span class="msg-time">&lt;{{ message.time }}&gt;</span>
</div>
<div :class="['msg-content', invalid ? 'msg-invalid' : 'msg-valid']">
{{ invalid ? '삭제된 메시지입니다' : message.text }}
</div>
<div v-if="hasAction" class="message-response">
<button
class="prompt-yes"
type="button"
:disabled="message.msgType === 'diplomacy' && !canRespondDiplomacy"
@click="respond(true)"
>
수락
</button>
<button
class="prompt-no"
type="button"
:disabled="message.msgType === 'diplomacy' && !canRespondDiplomacy"
@click="respond(false)"
>
거절
</button>
</div>
</div>
</article>
</template>
<style scoped>
.msg-plate {
display: grid;
grid-template-columns: 64px minmax(0, 1fr);
width: 100%;
min-height: 64px;
outline: 1px solid gray;
color: #fff;
font-size: 12.5px;
word-break: break-all;
}
.msg-plate-private {
background-color: #5d1e1a;
}
.msg-plate-private.msg-plate-dest {
background-color: #5d461a;
}
.msg-plate-public {
background-color: #141c65;
}
.msg-plate-national,
.msg-plate-diplomacy {
background-color: #00582c;
}
.msg-plate-national.msg-plate-dest,
.msg-plate-diplomacy.msg-plate-dest {
background-color: #704615;
}
.msg-plate-national.msg-plate-src,
.msg-plate-diplomacy.msg-plate-src {
background-color: #70153b;
}
.msg-icon {
width: 64px;
height: 64px;
border-right: 1px solid gray;
}
.general-icon {
display: block;
width: 64px;
max-width: none;
height: 64px;
object-fit: fill;
}
.msg-body {
min-width: 0;
padding-left: 0;
}
.msg-header {
position: relative;
margin-bottom: 3px;
color: #fff;
font-weight: 700;
}
.msg-target {
display: inline-block;
margin: 2px 2px 0;
border: 0;
border-radius: 3px;
padding: 2px 3px;
box-shadow: 2px 2px #000;
font: inherit;
font-weight: inherit;
}
button.msg-target {
cursor: pointer;
}
.msg-bright {
color: #000;
}
.msg-dark {
color: #fff;
}
.msg-from-to {
display: inline-block;
}
.msg-time {
font-size: 0.75em;
font-weight: 400;
}
.delete-message {
position: absolute;
z-index: 1;
top: 0;
right: 0;
margin: 2px 2px 0;
border: 1px solid #ffc107;
border-radius: 3px;
background: transparent;
padding: 2px 4px;
color: #ffc107;
font-size: 8px;
cursor: pointer;
}
.msg-content {
overflow: hidden;
margin-right: 5px;
margin-left: 10px;
white-space: pre-wrap;
}
.msg-invalid {
color: rgba(255, 255, 255, 0.5);
}
.message-response {
display: flex;
justify-content: flex-end;
gap: 0;
margin-top: 5px;
margin-right: 5px;
}
.message-response button {
min-width: 42px;
border: 1px outset buttonborder;
background: buttonface;
padding: 1px 6px;
color: buttontext;
font-size: 12.5px;
cursor: pointer;
}
.message-response button:disabled {
cursor: not-allowed;
opacity: 0.65;
}
</style>
+14 -4
View File
@@ -10,6 +10,7 @@ import NationInfoView from '../views/NationInfoView.vue';
import GlobalInfoView from '../views/GlobalInfoView.vue';
import CurrentCityView from '../views/CurrentCityView.vue';
import NationGeneralsView from '../views/NationGeneralsView.vue';
import NationSecretView from '../views/NationSecretView.vue';
import NationPersonnelView from '../views/NationPersonnelView.vue';
import NationStratFinanView from '../views/NationStratFinanView.vue';
import ChiefCenterView from '../views/ChiefCenterView.vue';
@@ -20,7 +21,6 @@ import NotFoundView from '../views/NotFoundView.vue';
import TournamentView from '../views/TournamentView.vue';
import BettingView from '../views/BettingView.vue';
import MyPageView from '../views/MyPageView.vue';
import MySettingsView from '../views/MySettingsView.vue';
import BoardView from '../views/BoardView.vue';
import DiplomacyView from '../views/DiplomacyView.vue';
import BestGeneralView from '../views/BestGeneralView.vue';
@@ -32,6 +32,7 @@ import TroopView from '../views/TroopView.vue';
import YearbookView from '../views/YearbookView.vue';
import NationBettingView from '../views/NationBettingView.vue';
import NpcListView from '../views/NpcListView.vue';
import TrafficView from '../views/TrafficView.vue';
import { useSessionStore } from '../stores/session';
const routes = [
@@ -148,6 +149,12 @@ const routes = [
requiresGeneral: true,
},
},
{
path: '/nation/secret',
name: 'nation-secret',
component: NationSecretView,
meta: { requiresAuth: true, requiresGeneral: true },
},
{
path: '/nation/personnel',
name: 'nation-personnel',
@@ -190,7 +197,6 @@ const routes = [
component: BattleSimulatorView,
meta: {
requiresAuth: true,
requiresGeneral: true,
},
},
{
@@ -252,6 +258,11 @@ const routes = [
requiresGeneral: true,
},
},
{
path: '/traffic',
name: 'traffic',
component: TrafficView,
},
{
path: '/npc-list',
name: 'npc-list',
@@ -277,8 +288,7 @@ const routes = [
},
{
path: '/my-settings',
name: 'my-settings',
component: MySettingsView,
redirect: '/my-page',
meta: {
requiresAuth: true,
requiresGeneral: true,
+140 -22
View File
@@ -23,6 +23,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
type MapLayout = Awaited<ReturnType<typeof trpc.world.getMapLayout.query>>;
type CommandTable = Awaited<ReturnType<typeof trpc.turns.getCommandTable.query>>;
type MessageBundle = Awaited<ReturnType<typeof trpc.messages.getRecent.query>>;
type MessageContacts = Awaited<ReturnType<typeof trpc.messages.getContacts.query>>;
type BoardAccess = Awaited<ReturnType<typeof trpc.board.getAccess.query>>;
type ReservedTurnView = Awaited<ReturnType<typeof trpc.turns.reserved.getGeneral.query>>[number];
@@ -37,12 +38,14 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
const mapLayout = ref<MapLayout | null>(null);
const commandTable = ref<CommandTable | null>(null);
const messages = ref<MessageBundle | null>(null);
const messageContacts = ref<MessageContacts | null>(null);
const boardAccess = ref<BoardAccess | null>(null);
const reservedGeneralTurns = ref<ReservedTurnView[] | null>(null);
const reservedNationTurns = ref<ReservedTurnView[] | null>(null);
const messageDraftText = ref('');
const targetMailbox = ref<number>(MESSAGE_MAILBOX_PUBLIC);
let initializedMailboxGeneralId: number | null = null;
const general = computed(() => generalContext.value?.general ?? null);
const city = computed(() => generalContext.value?.city ?? null);
@@ -86,18 +89,85 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
} as const;
});
const mailboxOptions = computed(() => {
const options: Array<{ label: string; value: number; disabled?: boolean }> = [
{ label: '공공', value: MESSAGE_MAILBOX_PUBLIC },
const mailboxGroups = computed(() => {
type MailboxOption = {
label: string;
value: number;
disabled?: boolean;
color?: string;
};
type MailboxGroup = {
label: string;
color?: string;
options: MailboxOption[];
};
const ownNationId = general.value?.nationId ?? 0;
const ownMailbox = MESSAGE_MAILBOX_NATIONAL_BASE + ownNationId;
const permission = messages.value?.permission ?? -1;
const contacts = messageContacts.value?.nation ?? [];
const ownNation = contacts.find((nation) => nation.mailbox === ownMailbox);
const groups: MailboxGroup[] = [
{
label: '즐겨찾기',
color: '#000000',
options: [
{
label: '【 아국 메세지 】',
value: ownMailbox,
color: ownNation?.color ?? '#000000',
},
{
label: '【 전체 메세지 】',
value: MESSAGE_MAILBOX_PUBLIC,
color: '#000000',
},
],
},
];
if (nationId.value) {
options.push({ label: '국가', value: MESSAGE_MAILBOX_NATIONAL_BASE + nationId.value });
} else {
options.push({ label: '국가', value: -1, disabled: true });
if (permission >= 4) {
groups.push({
label: '외교메시지',
color: '#000000',
options: contacts
.filter((nation) => nation.mailbox !== ownMailbox && nation.nationId > 0)
.map((nation) => ({
label: nation.name,
value: nation.mailbox,
color: nation.color,
})),
});
}
options.push({ label: '외교', value: -2, disabled: true });
options.push({ label: '개인', value: -3, disabled: true });
return options;
const sortedContacts = [...contacts].sort((left, right) => {
if (left.mailbox === ownMailbox) return -1;
if (right.mailbox === ownMailbox) return 1;
return left.mailbox - right.mailbox;
});
for (const nation of sortedContacts) {
const options = [...nation.general]
.filter(([id]) => id !== generalId.value)
.sort((left, right) => left[1].localeCompare(right[1], 'ko'))
.map(([id, name, flags]) => {
const ruler = Boolean(flags & 1);
const ambassador = Boolean(flags & 4);
return {
label: ruler ? `*${name}*` : ambassador ? `#${name}#` : name,
value: id,
disabled: permission === 4 && ambassador && nation.mailbox !== ownMailbox,
color: nation.color,
};
});
if (options.length > 0) {
groups.push({
label: nation.name,
color: nation.color,
options,
});
}
}
return groups;
});
const statusLine = computed(() => {
@@ -147,25 +217,32 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
context.general.nationId > 0 && context.general.officerLevel >= 5
? trpc.turns.reserved.getNation.query({ generalId: id })
: Promise.resolve(null);
const [layout, lobby, map, commands, messageData, access, generalTurns, nationTurns] = await Promise.all([
layoutPromise,
trpc.lobby.info.query(),
trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true }),
trpc.turns.getCommandTable.query({ generalId: id }),
trpc.messages.getRecent.query({ generalId: id }),
trpc.board.getAccess.query(),
generalTurnsPromise,
nationTurnsPromise,
]);
const [layout, lobby, map, commands, messageData, contacts, access, generalTurns, nationTurns] =
await Promise.all([
layoutPromise,
trpc.lobby.info.query(),
trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true }),
trpc.turns.getCommandTable.query({ generalId: id }),
trpc.messages.getRecent.query({ generalId: id }),
trpc.messages.getContacts.query({ generalId: id }),
trpc.board.getAccess.query(),
generalTurnsPromise,
nationTurnsPromise,
]);
mapLayout.value = layout;
lobbyInfo.value = lobby;
worldMap.value = map;
commandTable.value = commands;
messages.value = messageData;
messageContacts.value = contacts;
boardAccess.value = access;
reservedGeneralTurns.value = generalTurns;
reservedNationTurns.value = nationTurns;
if (initializedMailboxGeneralId !== id) {
targetMailbox.value = MESSAGE_MAILBOX_NATIONAL_BASE + context.general.nationId;
initializedMailboxGeneralId = id;
}
} catch (err) {
error.value = resolveErrorMessage(err);
} finally {
@@ -200,12 +277,12 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
}
try {
messageDraftText.value = '';
await trpc.messages.send.mutate({
generalId: id,
mailbox,
text,
});
messageDraftText.value = '';
await refreshMessages();
} catch (err) {
error.value = resolveErrorMessage(err);
@@ -260,6 +337,44 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
}
};
const readLatestMessage = async (type: 'private' | 'diplomacy', messageId: number) => {
const id = generalId.value;
if (!id || messageId <= 0) {
return;
}
try {
await trpc.messages.readLatest.mutate({
generalId: id,
type,
messageId,
});
if (messages.value) {
messages.value = {
...messages.value,
latestRead: {
...messages.value.latestRead,
[type]: Math.max(messages.value.latestRead[type], messageId),
},
};
}
} catch (err) {
error.value = resolveErrorMessage(err);
}
};
const deleteMessage = async (messageId: number) => {
const id = generalId.value;
if (!id) {
return;
}
try {
await trpc.messages.delete.mutate({ generalId: id, messageId });
await refreshMessages();
} catch (err) {
error.value = resolveErrorMessage(err);
}
};
const setGeneralTurn = async (turnIndex: number, action: string) => {
const id = generalId.value;
if (!id) {
@@ -484,12 +599,13 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
selectedCity,
commandTable,
messages,
messageContacts,
boardAccess,
reservedGeneralTurns,
reservedNationTurns,
messageDraftText,
targetMailbox,
mailboxOptions,
mailboxGroups,
statusLine,
realtimeLabel,
setRealtimeEnabled,
@@ -498,6 +614,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
sendMessage,
loadOlderMessages,
respondToMessage,
readLatestMessage,
deleteMessage,
setGeneralTurn,
shiftGeneralTurns,
setNationTurn,
+180 -56
View File
@@ -3,7 +3,6 @@ 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 GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
import { trpc } from '../utils/trpc';
import { getNpcColor } from '../utils/npcColor';
import { formatLog } from '../utils/formatLog';
@@ -269,7 +268,26 @@ onMounted(() => {
</PanelCard>
<PanelCard title="장수 정보">
<GeneralBasicCard :general="selectedGeneral" :loading="loading" />
<SkeletonLines v-if="loading" :lines="5" />
<div v-else-if="selectedGeneral" class="battle-general-card">
<div class="battle-general-name">
{{ selectedGeneral.name }} (관직 {{ selectedGeneral.officerLevel }})
</div>
<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>
<div v-if="selectedGeneral" class="general-meta">
<div>최근 : {{ selectedGeneral.turnTime ? selectedGeneral.turnTime.slice(-5) : '-' }}</div>
<div>최근 전투: {{ selectedGeneral.recentWar || '-' }}</div>
@@ -286,12 +304,7 @@ onMounted(() => {
<SkeletonLines v-if="loading || logLoading" :lines="3" />
<template v-else>
<div v-if="logs[type].length === 0" class="empty">기록이 없습니다.</div>
<div
v-for="entry in logs[type]"
:key="entry.id"
class="log-line"
v-html="entry.html"
/>
<div v-for="entry in logs[type]" :key="entry.id" class="log-line" v-html="entry.html" />
</template>
</div>
</div>
@@ -303,126 +316,237 @@ onMounted(() => {
<style scoped>
.battle-page {
width: 100%;
min-width: 500px;
max-width: 1000px;
min-height: 100vh;
padding: 24px;
margin: 0 auto;
padding: 0;
display: flex;
flex-direction: column;
gap: 24px;
gap: 0;
color: #fff;
background-color: #302016;
background-image: url('/image/game/back_walnut.jpg');
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
font-size: 14px;
line-height: 1.5;
}
.page-header {
position: relative;
min-height: 32px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
justify-content: center;
gap: 10px;
flex-wrap: wrap;
padding: 0 8px;
border: 1px solid #666;
background-color: #302016;
background-image: url('/image/game/back_walnut.jpg');
}
.page-title {
font-size: 1.6rem;
font-weight: 700;
font-size: 17px;
font-weight: 500;
}
.page-subtitle {
color: rgba(232, 221, 196, 0.7);
margin-top: 6px;
display: none;
}
.header-actions {
position: absolute;
left: 0;
top: 0;
display: flex;
flex-wrap: wrap;
gap: 10px;
gap: 4px;
}
.layout-grid {
display: grid;
grid-template-columns: minmax(0, 320px) minmax(0, 1fr);
gap: 18px;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0;
}
.stack {
display: flex;
flex-direction: column;
gap: 18px;
display: contents;
}
.selector-row {
display: grid;
grid-template-columns: auto minmax(140px, 1fr) minmax(180px, 2fr) auto;
gap: 8px;
grid-template-columns: 8.333% 33.333% 50% 8.333%;
gap: 0;
align-items: center;
}
.select-input {
min-width: 0;
padding: 6px 8px;
border: 1px solid rgba(201, 164, 90, 0.4);
background: rgba(12, 12, 12, 0.7);
height: 36px;
padding: 4px 6px;
border: 1px solid #777;
border-radius: 0;
background: #303030;
color: inherit;
font-size: 0.85rem;
font: inherit;
}
.ghost {
border: 1px solid rgba(201, 164, 90, 0.5);
background: transparent;
min-height: 32px;
border: 1px solid #777;
border-radius: 0;
background: #303030;
color: inherit;
padding: 6px 10px;
font-size: 0.8rem;
padding: 4px 8px;
font: inherit;
cursor: pointer;
}
.general-meta {
margin-top: 10px;
font-size: 0.85rem;
color: rgba(232, 221, 196, 0.75);
margin: 0;
padding: 6px 8px;
color: #ccc;
display: grid;
gap: 4px;
}
.log-grid {
.battle-general-card {
min-height: 292px;
background-color: #172a52;
background-image: url('/image/game/back_blue.jpg');
}
.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(auto-fit, minmax(220px, 1fr));
gap: 12px;
grid-template-columns: repeat(6, 1fr);
}
.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;
}
.log-block {
border: 1px solid rgba(201, 164, 90, 0.3);
padding: 8px;
background: rgba(12, 12, 12, 0.6);
min-height: 160px;
border: 1px solid #666;
padding: 0;
background: #111;
min-height: 180px;
}
.log-title {
font-weight: 600;
margin-bottom: 6px;
font-size: 0.9rem;
min-height: 34px;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
border-bottom: 1px solid #666;
color: orange;
background: #252525;
font-size: 1.3em;
font-weight: 500;
}
.log-line {
padding: 4px 0;
border-bottom: 1px dashed rgba(201, 164, 90, 0.2);
}
.log-line:last-child {
border-bottom: none;
padding: 2px 8px;
border-bottom: 0;
}
.empty {
color: rgba(232, 221, 196, 0.6);
font-size: 0.85rem;
padding: 2px 8px;
color: #999;
}
.error {
color: #f08a5d;
font-size: 0.9rem;
padding: 5px 8px;
color: #ff7777;
border: 1px solid #a33;
text-align: center;
}
@media (max-width: 1024px) {
/* PanelCard is retained as a data wrapper, but its presentation follows the
flat bootstrap rows used by the reference page. */
:deep(.panel-card) {
height: 100%;
border: 1px solid #666;
border-radius: 0;
background-color: #302016;
background-image: url('/image/game/back_walnut.jpg');
box-shadow: none;
}
.stack:first-child :deep(.panel-card:first-child) {
grid-column: 1 / -1;
border: 0;
}
.stack:first-child :deep(.panel-card:first-child .panel-header) {
display: none;
}
.stack:first-child :deep(.panel-card:first-child .panel-body) {
padding: 0;
}
.stack:nth-child(2) :deep(.panel-card),
.stack:nth-child(2) :deep(.panel-body) {
display: contents;
}
.stack:nth-child(2) :deep(.panel-header) {
display: none;
}
:deep(.panel-header) {
min-height: 29px;
justify-content: center;
padding: 0;
}
:deep(.panel-title) {
color: skyblue;
font-size: 18px;
font-weight: 500;
}
:deep(.panel-header),
.log-title {
background-image: url('/image/game/back_green.jpg');
}
@media (max-width: 991px) {
.battle-page {
width: 500px;
}
.layout-grid {
grid-template-columns: 1fr;
}
.selector-row {
grid-template-columns: 16.666% 25% 41.666% 16.666%;
}
.log-grid {
grid-template-columns: 1fr;
}
}
@@ -26,6 +26,7 @@ type BattleExport = {
type ExportedInfo = { objType: 'general'; data: GeneralExport } | { objType: 'battle'; data: BattleExport };
type GeneralListResponse = Awaited<ReturnType<typeof trpc.battle.getGeneralList.query>>;
type GeneralMeResponse = Awaited<ReturnType<typeof trpc.general.me.query>>;
const loading = ref(true);
const error = ref<string | null>(null);
@@ -72,6 +73,7 @@ const importTarget = ref<GeneralDraft | null>(null);
const generalList = ref<GeneralListResponse | null>(null);
const generalListLoading = ref(false);
const selectedGeneralId = ref<number | null>(null);
const gameDefaults = ref<GeneralMeResponse>(null);
let generalIdSeed = 0;
@@ -250,6 +252,7 @@ const initializeDefaults = async () => {
try {
const [context, me] = await Promise.all([trpc.battle.getSimulatorContext.query(), trpc.general.me.query()]);
options.value = context;
gameDefaults.value = me;
year.value = context.world.currentYear;
month.value = context.world.currentMonth;
repeatCnt.value = 1;
@@ -283,6 +286,47 @@ const initializeDefaults = async () => {
}
};
const hasGameGeneral = computed(() => !!gameDefaults.value?.general?.id);
const applyGameEnvironment = () => {
if (!options.value) {
return;
}
const me = gameDefaults.value;
const nationTypeDefault = options.value.nationTypes[0]?.key ?? 'che_중립';
year.value = options.value.world.currentYear;
month.value = options.value.world.currentMonth;
attackerNation.type = me?.nation?.typeCode ?? nationTypeDefault;
defenderNation.type = attackerNation.type;
attackerNation.level = me?.nation?.level ?? 0;
defenderNation.level = attackerNation.level;
attackerNation.tech = me?.nation?.tech ? Math.floor(me.nation.tech / 1000) : 1;
defenderNation.tech = attackerNation.tech;
attackerCity.level = me?.city?.level ?? 5;
defenderCity.level = attackerCity.level;
defenderCity.def = me?.city?.defence ?? 1000;
defenderCity.wall = me?.city?.wall ?? 1000;
attackerNation.isCapital = !!me?.city && me.nation?.capitalCityId === me.city.id;
defenderNation.isCapital = attackerNation.isCapital;
error.value = null;
};
const applyIndependentEnvironment = () => {
if (!options.value) {
return;
}
const nationTypeDefault = options.value.nationTypes[0]?.key ?? 'che_중립';
year.value = options.value.world.startYear;
month.value = 1;
seed.value = '';
repeatCnt.value = 1;
Object.assign(attackerNation, { type: nationTypeDefault, tech: 1, level: 0, isCapital: false });
Object.assign(defenderNation, { type: nationTypeDefault, tech: 1, level: 0, isCapital: false });
attackerCity.level = 5;
Object.assign(defenderCity, { level: 5, def: 1000, wall: 1000 });
error.value = null;
};
onMounted(() => {
void initializeDefaults();
});
@@ -536,13 +580,19 @@ const runSimulation = async (action: BattleSimRequestPayload['action']) => {
}
isSimulating.value = true;
error.value = null;
if (action === 'battle') {
battleResult.value = null;
}
statusMessage.value = action === 'battle' ? '전투를 진행 중입니다.' : '수비자 순서를 계산 중입니다.';
try {
const payload = buildBattlePayload(action);
const response = await trpc.battle.simulate.mutate(payload);
const result =
'payload' in response && response.payload ? response.payload : await waitForSimulationResult(response.jobId);
'payload' in response && response.payload
? response.payload
: await waitForSimulationResult(response.jobId);
if (!result.result) {
error.value = result.reason || 'battle_failed';
@@ -786,6 +836,10 @@ const loadGeneralList = async () => {
};
const openImportModal = async (target: GeneralDraft) => {
if (!hasGameGeneral.value) {
error.value = '게임 장수를 보유한 사용자만 서버 장수 정보를 가져올 수 있습니다.';
return;
}
importTarget.value = target;
importOpen.value = true;
if (!generalList.value) {
@@ -801,47 +855,62 @@ const closeImportModal = () => {
importTarget.value = null;
};
const applyServerGeneral = async (target: GeneralDraft, generalId: number) => {
const response = await trpc.battle.getGeneralDetail.query({ generalId });
applyGeneralExport(target, {
no: response.general.no,
name: response.general.name,
officerLevel: response.general.officer_level,
expLevel: response.general.explevel,
leadership: response.general.leadership,
strength: response.general.strength,
intel: response.general.intel,
horse: response.general.horse,
weapon: response.general.weapon,
book: response.general.book,
item: response.general.item,
injury: response.general.injury,
rice: response.general.rice,
personal: response.general.personal,
special2: response.general.special2,
crew: response.general.crew,
crewtype: response.general.crewtype,
atmos: response.general.atmos,
train: response.general.train,
dex1: response.general.dex1,
dex2: response.general.dex2,
dex3: response.general.dex3,
dex4: response.general.dex4,
dex5: response.general.dex5,
defenceTrain: response.general.defence_train,
warnum: response.general.warnum,
killnum: response.general.killnum,
killcrew: response.general.killcrew,
inheritBuff: createInheritBuff(),
});
target.no = target === attackerGeneral.value ? 1 : resolveGeneralNo(response.general.no, target.id);
};
const applyMyGeneralToAttacker = async () => {
const generalId = gameDefaults.value?.general?.id;
if (!attackerGeneral.value || !generalId) {
error.value = '불러올 내 장수가 없습니다.';
return;
}
try {
error.value = null;
await applyServerGeneral(attackerGeneral.value, generalId);
} catch (err) {
error.value = resolveErrorMessage(err);
}
};
const confirmImport = async () => {
if (!importTarget.value || !selectedGeneralId.value) {
return;
}
try {
const response = await trpc.battle.getGeneralDetail.query({ generalId: selectedGeneralId.value });
applyGeneralExport(importTarget.value, {
no: response.general.no,
name: response.general.name,
officerLevel: response.general.officer_level,
expLevel: response.general.explevel,
leadership: response.general.leadership,
strength: response.general.strength,
intel: response.general.intel,
horse: response.general.horse,
weapon: response.general.weapon,
book: response.general.book,
item: response.general.item,
injury: response.general.injury,
rice: response.general.rice,
personal: response.general.personal,
special2: response.general.special2,
crew: response.general.crew,
crewtype: response.general.crewtype,
atmos: response.general.atmos,
train: response.general.train,
dex1: response.general.dex1,
dex2: response.general.dex2,
dex3: response.general.dex3,
dex4: response.general.dex4,
dex5: response.general.dex5,
defenceTrain: response.general.defence_train,
warnum: response.general.warnum,
killnum: response.general.killnum,
killcrew: response.general.killcrew,
inheritBuff: createInheritBuff(),
});
importTarget.value.no =
importTarget.value === attackerGeneral.value
? 1
: resolveGeneralNo(response.general.no, importTarget.value.id);
await applyServerGeneral(importTarget.value, selectedGeneralId.value);
} catch (err) {
error.value = resolveErrorMessage(err);
} finally {
@@ -882,19 +951,21 @@ const summaryRows = computed(() => {
{ label: '전투 페이즈', value: formatNumber(battleResult.value.phase) },
{
label: '준 피해',
value: battleResult.value.minKilled !== battleResult.value.maxKilled
? `${formatNumber(battleResult.value.killed)} (${formatNumber(battleResult.value.minKilled)} ~ ${formatNumber(
battleResult.value.maxKilled
)})`
: formatNumber(battleResult.value.killed),
value:
battleResult.value.minKilled !== battleResult.value.maxKilled
? `${formatNumber(battleResult.value.killed)} (${formatNumber(battleResult.value.minKilled)} ~ ${formatNumber(
battleResult.value.maxKilled
)})`
: formatNumber(battleResult.value.killed),
},
{
label: '받은 피해',
value: battleResult.value.minDead !== battleResult.value.maxDead
? `${formatNumber(battleResult.value.dead)} (${formatNumber(battleResult.value.minDead)} ~ ${formatNumber(
battleResult.value.maxDead
)})`
: formatNumber(battleResult.value.dead),
value:
battleResult.value.minDead !== battleResult.value.maxDead
? `${formatNumber(battleResult.value.dead)} (${formatNumber(battleResult.value.minDead)} ~ ${formatNumber(
battleResult.value.maxDead
)})`
: formatNumber(battleResult.value.dead),
},
{ label: '출병자 군량 소모', value: formatNumber(battleResult.value.attackerRice) },
{ label: '수비자 군량 소모', value: formatNumber(battleResult.value.defenderRice) },
@@ -937,6 +1008,32 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
</div>
</header>
<section class="independence-notice" aria-label="시뮬레이터 데이터 안내">
<div>
<strong>게임 상태와 분리된 모의 계산</strong>
<p>
현재 연도·국가·도시는 시작값으로만 읽으며, 아래 편집과 전투 결과는 ·DB·장수 상태를 변경하지
않습니다.
</p>
</div>
<div class="notice-actions">
<button class="ghost" type="button" :disabled="!options" @click="applyGameEnvironment">
현재 게임 환경 적용
</button>
<button class="ghost" type="button" :disabled="!options" @click="applyIndependentEnvironment">
독립 기본값
</button>
<button
class="ghost"
type="button"
:disabled="!hasGameGeneral || !attackerGeneral"
@click="applyMyGeneralToAttacker"
>
장수를 출병자로
</button>
</div>
</section>
<div v-if="error" class="error">{{ error }}</div>
<div v-if="statusMessage" class="status">{{ statusMessage }}</div>
@@ -1032,6 +1129,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
:options="options!"
mode="attacker"
title="출병자 설정"
:can-import-server="hasGameGeneral"
@import="openImportModal(attackerGeneral!)"
@save="saveGeneral(attackerGeneral!)"
@load="(payload) => handleGeneralLoad({ target: attackerGeneral!, file: payload.file })"
@@ -1099,6 +1197,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
:options="options!"
mode="defender"
:title="`수비자 설정 ${index + 1}`"
:can-import-server="hasGameGeneral"
@import="openImportModal(defender)"
@save="saveGeneral(defender)"
@load="(payload) => handleGeneralLoad({ target: defender, file: payload.file })"
@@ -1146,11 +1245,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
</div>
<div v-else class="select-wrap">
<select v-model.number="selectedGeneralId">
<optgroup
v-for="group in generalGroups"
:key="group.nation.id"
:label="group.nation.name"
>
<optgroup v-for="group in generalGroups" :key="group.nation.id" :label="group.nation.name">
<option
v-for="general in group.generals"
:key="general.id"
@@ -1176,6 +1271,8 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
display: flex;
flex-direction: column;
gap: 18px;
width: min(100%, 1000px);
margin: 0 auto;
padding-bottom: 30px;
background:
radial-gradient(circle at top left, rgba(201, 164, 90, 0.15), transparent 45%),
@@ -1207,6 +1304,34 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
gap: 8px;
}
.independence-notice {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 10px 12px;
border: 1px solid rgba(112, 170, 141, 0.45);
background: rgba(18, 52, 40, 0.35);
}
.independence-notice strong {
color: #bfe2cd;
font-size: 0.85rem;
}
.independence-notice p {
margin: 4px 0 0;
color: rgba(221, 239, 228, 0.75);
font-size: 0.75rem;
}
.notice-actions {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
gap: 6px;
}
button {
font-family: inherit;
background: none;
@@ -1230,6 +1355,11 @@ button {
background: rgba(16, 16, 16, 0.6);
}
button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.error {
color: #f5b7b1;
font-size: 0.85rem;
@@ -1369,5 +1499,10 @@ button {
flex-direction: column;
align-items: flex-start;
}
.independence-notice {
align-items: flex-start;
flex-direction: column;
}
}
</style>
+292 -61
View File
@@ -1,5 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { trpc } from '../utils/trpc';
type RankEntry = {
@@ -11,7 +13,6 @@ type RankEntry = {
fgColor: string;
picture: string | null;
imageServer: number;
value: number;
printValue: string;
};
@@ -21,18 +22,17 @@ type RankSection = {
entries: RankEntry[];
};
type UniqueOwner = {
id: number;
name: string;
nationName: string;
bgColor: string;
fgColor: string;
type UniqueItemEntry = {
itemKey: string;
itemName: string;
itemInfo: string;
owner: Omit<RankEntry, 'ownerName' | 'printValue'>;
};
type UniqueItemSection = {
title: string;
slot: string;
owners: UniqueOwner[];
entries: UniqueItemEntry[];
};
type BestGeneralPayload = {
@@ -41,12 +41,26 @@ type BestGeneralPayload = {
uniqueItems: UniqueItemSection[];
};
const router = useRouter();
const viewMode = ref<'user' | 'npc'>('user');
const loading = ref(false);
const errorMessage = ref('');
const data = ref<BestGeneralPayload | null>(null);
const refresh = async () => {
const imageUrl = (entry: { picture: string | null; imageServer: number }): string => {
const picture = entry.picture?.trim() || 'default.jpg';
return entry.imageServer ? `${import.meta.env.BASE_URL}d_pic/${picture}` : `/image/icons/${picture}`;
};
const closePage = async (): Promise<void> => {
if (window.opener) {
window.close();
return;
}
await router.push('/');
};
const refresh = async (): Promise<void> => {
loading.value = true;
errorMessage.value = '';
try {
@@ -58,8 +72,6 @@ const refresh = async () => {
}
};
const emptyLabel = computed(() => (loading.value ? '불러오는 중...' : '표시할 데이터가 없습니다.'));
onMounted(() => {
void refresh();
});
@@ -70,61 +82,280 @@ watch(viewMode, () => {
</script>
<template>
<main class="main-page">
<header class="page-header">
<div>
<h1 class="page-title">명장일람</h1>
<p class="page-subtitle">전장 기록을 기준으로 장수 순위를 확인합니다.</p>
</div>
<div class="header-actions">
<button class="ghost" :class="{ active: viewMode === 'user' }" @click="viewMode = 'user'">
유저 보기
</button>
<button class="ghost" :class="{ active: viewMode === 'npc' }" @click="viewMode = 'npc'">
NPC 보기
</button>
<button class="ghost" @click="refresh">새로고침</button>
</div>
</header>
<main id="best-general-container" class="legacy-ranking-page legacy-bg0">
<div class="legacy-ranking-title">
<br />
<button class="legacy-button" type="button" @click="closePage"> 닫기</button>
</div>
<div v-if="errorMessage" class="error">{{ errorMessage }}</div>
<div v-else-if="!data" class="placeholder">{{ emptyLabel }}</div>
<div class="view-selector" role="group" aria-label="장수 유형">
<button
class="legacy-button"
type="button"
:aria-pressed="viewMode === 'user'"
@click="viewMode = 'user'"
>
유저 보기
</button>
<button
class="legacy-button"
type="button"
:aria-pressed="viewMode === 'npc'"
@click="viewMode = 'npc'"
>
NPC 보기
</button>
</div>
<section v-if="data" class="grid gap-4">
<div v-for="section in data.sections" :key="section.title" class="bg-zinc-900 border border-zinc-800 rounded p-4">
<h2 class="text-base font-semibold mb-3">{{ section.title }}</h2>
<div v-if="section.entries.length === 0" class="text-xs text-zinc-500">{{ emptyLabel }}</div>
<ul v-else class="space-y-2">
<li
v-for="entry in section.entries"
:key="entry.id"
class="flex items-center justify-between bg-zinc-950 border border-zinc-800 rounded px-3 py-2 text-sm"
>
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full" :style="{ backgroundColor: entry.bgColor }" />
<span class="font-semibold">{{ entry.name }}</span>
<span class="text-xs text-zinc-400">{{ entry.nationName }}</span>
<div v-if="errorMessage" class="legacy-message error" role="alert">{{ errorMessage }}</div>
<div v-else-if="loading && !data" class="legacy-message">불러오는 중...</div>
<section v-if="data" class="ranking-sections" :aria-busy="loading">
<article v-for="section in data.sections" :key="section.title" class="rankView legacy-bg0">
<h2 class="rankType legacy-bg1">{{ section.title }}</h2>
<ul>
<li v-for="(entry, rank) in section.entries" :key="`${section.title}:${entry.id}:${rank}`">
<div class="hall-rank legacy-bg2">{{ rank + 1 }}</div>
<div class="hall-img">
<img class="generalIcon" :src="imageUrl(entry)" width="64" height="64" :alt="entry.name" />
</div>
<div class="text-xs text-zinc-200">{{ entry.printValue }}</div>
<div class="hall-nation" :style="{ backgroundColor: entry.bgColor, color: entry.fgColor }">
{{ entry.nationName || '-' }}
</div>
<div class="hall-name" :style="{ backgroundColor: entry.bgColor, color: entry.fgColor }">
<span>{{ entry.name || '-' }}</span>
<small v-if="entry.ownerName">({{ entry.ownerName }})</small>
</div>
<div class="hall-value">{{ entry.printValue }}</div>
</li>
</ul>
</div>
</article>
<article v-for="section in data.uniqueItems" :key="section.slot" class="rankView legacy-bg0">
<h2 class="rankType legacy-bg1">{{ section.title }}</h2>
<ul>
<li
v-for="(entry, index) in section.entries"
:key="`${entry.itemKey}:${index}`"
class="no-value"
>
<div class="hall-rank legacy-bg2 item-name" :title="entry.itemInfo">{{ entry.itemName }}</div>
<div class="hall-img">
<img
class="generalIcon"
:src="imageUrl(entry.owner)"
width="64"
height="64"
:alt="entry.owner.name"
/>
</div>
<div
class="hall-nation"
:style="{ backgroundColor: entry.owner.bgColor, color: entry.owner.fgColor }"
>
{{ entry.owner.nationName || '-' }}
</div>
<div
class="hall-name"
:style="{ backgroundColor: entry.owner.bgColor, color: entry.owner.fgColor }"
>
<span>{{ entry.owner.name || '-' }}</span>
</div>
</li>
</ul>
</article>
</section>
<section v-if="data" class="mt-6 bg-zinc-900 border border-zinc-800 rounded p-4">
<h2 class="text-base font-semibold mb-3">유니크 아이템 소유자</h2>
<div class="grid md:grid-cols-2 gap-4">
<div v-for="item in data.uniqueItems" :key="item.title" class="bg-zinc-950 border border-zinc-800 rounded p-3">
<h3 class="text-sm font-semibold mb-2">{{ item.title }}</h3>
<ul class="space-y-1 text-xs">
<li v-for="owner in item.owners" :key="owner.id" class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full" :style="{ backgroundColor: owner.bgColor }" />
<span>{{ owner.name }}</span>
<span class="text-zinc-500">{{ owner.nationName }}</span>
</li>
</ul>
</div>
</div>
</section>
<div class="legacy-ranking-bottom">
<button class="legacy-button" type="button" @click="closePage"> 닫기</button>
</div>
<footer class="legacy-banner">
삼국지 모의전투 HiDCHe core2026 / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD /
<a href="https://sam.hided.net/wiki/hidche/credit" target="_blank" rel="noreferrer">Credit</a>
</footer>
</main>
</template>
<style scoped>
:global(body) {
min-width: 500px;
overflow-x: hidden;
}
.legacy-ranking-page {
width: 500px;
min-height: 100vh;
margin: 0 auto 100px;
color: #fff;
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
font-size: 14px;
}
.legacy-ranking-title,
.legacy-ranking-bottom {
text-align: left;
}
.view-selector {
text-align: center;
}
.legacy-ranking-title {
padding-top: 2px;
}
.view-selector {
padding: 2px 0;
}
.view-selector .legacy-button + .legacy-button {
margin-left: 4px;
}
.view-selector .legacy-button[aria-pressed='true'] {
border-style: inset;
}
.legacy-button {
border: 0;
border-radius: 5.25px;
background: #375a7f;
padding: 5.25px 10.5px;
font-weight: 700;
line-height: 21px;
}
.legacy-button:hover,
.legacy-button:focus,
.legacy-button:active {
background: #6b6b6b;
}
.legacy-button:focus-visible {
outline: revert;
outline-offset: 0;
}
.legacy-message {
border: 1px solid gray;
padding: 12px;
text-align: center;
}
.legacy-message.error {
color: #ff6b6b;
}
.legacy-banner {
font-size: 13px;
}
.legacy-banner a {
color: #fff;
text-decoration: underline;
}
.ranking-sections {
display: block;
}
.rankView {
position: relative;
margin: auto;
outline: 1px solid gray;
}
.rankType {
margin: 0;
border-bottom: 1px solid gray;
padding: 2px;
font-size: calc(19px + 0.784615vw);
font-weight: 500;
line-height: 1.2;
text-align: center;
}
.rankView ul {
display: flex;
flex-wrap: wrap;
box-sizing: border-box;
margin: -1px 0;
padding: 0;
list-style: none;
}
.rankView li {
box-sizing: border-box;
flex: 0 0 100px;
width: 100px;
min-height: 149px;
margin: 0;
border-top: 1px solid gray;
border-right: 1px solid gray;
text-align: center;
vertical-align: top;
}
.rankView li.no-value {
min-height: 128px;
}
.hall-rank,
.hall-nation,
.hall-value {
border-bottom: 1px solid gray;
}
.hall-rank.item-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hall-img {
height: 64px;
}
.generalIcon {
display: inline-block;
width: 64px;
height: 64px;
object-fit: fill;
}
.hall-nation,
.hall-name {
font-size: 11px;
}
.hall-name {
display: flex;
height: 28px;
flex-direction: column;
justify-content: center;
}
.hall-name small {
font-size: 95%;
}
.hall-value {
box-sizing: border-box;
padding: 3px 0;
line-height: 13px;
}
@media (min-width: 1000px) {
:global(body) {
min-width: 1000px;
}
.legacy-ranking-page {
width: 1000px;
}
.rankType {
font-size: 28px;
}
}
</style>
+309 -85
View File
@@ -1,32 +1,110 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { formatOfficerLevelText, cityLevelMap, regionMap } from '../utils/nationFormat';
import { computed, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { cityLevelMap, formatOfficerLevelText, regionMap } from '../utils/nationFormat';
import { getNpcColor } from '../utils/npcColor';
import { trpc } from '../utils/trpc';
type Result = Awaited<ReturnType<typeof trpc.world.getCurrentCity.query>>;
type General = Result['generals'][number];
const route = useRoute();
const router = useRouter();
const data = ref<Result | null>(null);
const error = ref('');
const selected = ref<number>();
const show = (value: number | null) => (value === null ? '?' : value.toLocaleString('ko-KR'));
let loadSequence = 0;
const parseCityId = (): number | undefined => {
const raw = route.query.cityId ?? route.query.citylist;
const value = Array.isArray(raw) ? raw[0] : raw;
if (typeof value !== 'string' || !/^\d+$/.test(value)) return undefined;
const cityId = Number(value);
return Number.isSafeInteger(cityId) && cityId > 0 ? cityId : undefined;
};
const load = async (cityId?: number) => {
const sequence = ++loadSequence;
try {
data.value = await trpc.world.getCurrentCity.query(cityId ? { cityId } : undefined);
selected.value = data.value.city.id;
const result = await trpc.world.getCurrentCity.query(cityId ? { cityId } : undefined);
if (sequence !== loadSequence) return;
data.value = result;
selected.value = result.city.id;
error.value = '';
} catch (cause) {
if (sequence !== loadSequence) return;
error.value = cause instanceof Error ? cause.message : '도시 정보를 불러오지 못했습니다.';
}
};
watch(
() => [route.query.cityId, route.query.citylist],
() => void load(parseCityId()),
{ immediate: true }
);
const selectCity = async () => {
if (!selected.value) return;
await router.push({ name: 'current-city', query: { cityId: selected.value } });
};
const city = computed(() => data.value?.city);
onMounted(() => void load());
const summary = computed(() => data.value?.forceSummary);
const show = (value: number | null) => (value === null ? '?' : value.toLocaleString('ko-KR'));
const showPair = (crew: number, generals: number) => `${show(crew)}/${show(generals)}`;
const populationRate = computed(() => {
if (!city.value || city.value.population === null) return '?';
return String(Math.round((city.value.population / city.value.populationMax) * 10_000) / 100);
});
const contrastColors = new Set([
'',
'#330000',
'#FF0000',
'#800000',
'#A0522D',
'#FF6347',
'#808000',
'#008000',
'#2E8B57',
'#008080',
'#6495ED',
'#0000FF',
'#000080',
'#483D8B',
'#7B68EE',
'#800080',
'#A9A9A9',
'#000000',
]);
const cityTitleStyle = computed(() => {
const backgroundColor = city.value?.nationColor.toUpperCase() ?? '#000000';
return {
backgroundColor,
color: contrastColors.has(backgroundColor) ? '#FFFFFF' : '#000000',
};
});
const woundedStat = (value: number, injury: number) =>
injury === 0 ? value : Math.floor((value * (100 - injury)) / 100);
const defenceTrainText = (value: number | null) => {
if (value === null) return '?';
if (value === 999) return '×';
if (value >= 90) return '☆';
if (value >= 80) return '◎';
if (value >= 60) return '○';
return '△';
};
const generalImage = (general: General) => {
const picture = general.picture ?? 'default.jpg';
return general.imageServer ? `${import.meta.env.BASE_URL}d_pic/${picture}` : `/image/icons/${picture}`;
};
</script>
<template>
<main class="city-page">
<table class="legacy-table legacy-bg0 center">
<table class="legacy-table legacy-bg0">
<tbody>
<tr>
<td> <br /><RouterLink to="/">돌아가기</RouterLink></td>
<td> <br /><RouterLink class="back-link" to="/">돌아가기</RouterLink></td>
</tr>
</tbody>
</table>
@@ -34,32 +112,57 @@ onMounted(() => void load());
<tbody>
<tr>
<td>
도시선택 :
<select v-model.number="selected" @change="load(selected)">
<option v-for="option in data?.options ?? []" :key="option.id" :value="option.id">
{{ option.name.padEnd(4, '_') }}{{
option.nationId === data?.me.nationId
? '본국'
: option.nationId === 0
? '공백지'
: '타국'
}}
</option>
</select>
<p>명령 화면에서 도시를 클릭하셔도 됩니다.</p>
<form @submit.prevent="selectCity">
<div>
도시선택 :
<select id="citySelector" v-model.number="selected" @change="selectCity">
<option v-for="option in data?.options ?? []" :key="option.id" :value="option.id">
{{ option.name.padEnd(4, '_') }}{{
option.nationId === data?.me.nationId
? '본국'
: option.nationId === 0
? '공백지'
: '타국'
}}
</option>
</select>
</div>
<p>명령 화면에서 도시를 클릭하셔도 됩니다.</p>
</form>
</td>
</tr>
</tbody>
</table>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="error" class="error" role="alert">{{ error }}</p>
<template v-if="data && city">
<table class="legacy-table legacy-bg2 stats">
<table class="legacy-table legacy-bg0 back-row">
<tbody>
<tr>
<td colspan="11" class="city-title">
<td><RouterLink class="back-link" to="/">돌아가기</RouterLink></td>
</tr>
</tbody>
</table>
<table class="legacy-table legacy-bg2 stats">
<colgroup>
<col class="label-col" />
<col class="first-value-col" />
<col class="label-col" />
<col class="value-col" />
<col class="label-col" />
<col class="value-col" />
<col class="label-col" />
<col class="value-col" />
<col class="label-col" />
<col class="value-col" />
<col class="label-col" />
<col class="value-col" />
</colgroup>
<tbody>
<tr>
<td colspan="11" class="city-title" :style="cityTitleStyle">
{{ regionMap[city.region] }} | {{ cityLevelMap[city.level] }} {{ city.name }}
</td>
<td class="city-title">{{ data.lastExecute }}</td>
<td class="city-title" :style="cityTitleStyle">{{ data.lastExecute }}</td>
</tr>
<tr>
<th>주민</th>
@@ -79,15 +182,9 @@ onMounted(() => void load());
<th>민심</th>
<td>{{ show(city.trust) }}</td>
<th>시세</th>
<td>{{ city.trade ?? '-' }}%</td>
<td>{{ city.trade ?? '- ' }}%</td>
<th>인구</th>
<td>
{{
city.population === null
? '?'
: ((city.population / city.populationMax) * 100).toFixed(2)
}}%
</td>
<td>{{ populationRate }}%</td>
<th>태수</th>
<td>{{ city.officers[4] }}</td>
<th>군사</th>
@@ -95,19 +192,60 @@ onMounted(() => void load());
<th>종사</th>
<td>{{ city.officers[2] }}</td>
</tr>
<tr v-if="summary">
<th>도시명</th>
<td>{{ city.name }}</td>
<th>적군</th>
<td>
{{ show(summary.enemyCrew) }}/{{ show(summary.enemyArmedGenerals) }}({{
show(summary.enemyGenerals)
}})
</td>
<th>병장()</th>
<td>
{{ show(summary.ownCrew) }}/{{ show(summary.ownArmedGenerals) }}({{
show(summary.ownGenerals)
}})
</td>
<th>90병장</th>
<td>{{ showPair(summary.ready90Crew, summary.ready90Generals) }}</td>
<th>60병장</th>
<td>{{ showPair(summary.ready60Crew, summary.ready60Generals) }}</td>
<th>수비</th>
<td>{{ showPair(summary.defenceReadyCrew, summary.defenceReadyGenerals) }}</td>
</tr>
<tr>
<th>장수</th>
<td colspan="11">
{{
data.visibility.detailed
? data.generals.map((g) => g.name).join(', ') || '-'
: '알 수 없음'
}}
<td colspan="11" class="general-names">
<template v-if="data.visibility.detailed">
<template v-if="data.generals.length">
<template v-for="(general, index) in data.generals" :key="general.id">
<span :style="{ color: getNpcColor(general.npcState) }">{{ general.name }}</span
><template v-if="index < data.generals.length - 1">, </template>
</template>
</template>
<template v-else>-</template>
</template>
<span v-else class="unknown"> 없음</span>
</td>
</tr>
</tbody>
</table>
<table v-if="data.visibility.detailed" class="legacy-table legacy-bg0 generals">
<table v-if="data.visibility.detailed" id="general_list" class="legacy-table legacy-bg0 generals">
<colgroup>
<col style="width: 64px" />
<col style="width: 128px" />
<col style="width: 48px" />
<col style="width: 48px" />
<col style="width: 48px" />
<col style="width: 78px" />
<col style="width: 28px" />
<col style="width: 78px" />
<col style="width: 78px" />
<col style="width: 48px" />
<col style="width: 48px" />
<col style="width: 280px" />
</colgroup>
<thead>
<tr>
<th> </th>
@@ -125,42 +263,53 @@ onMounted(() => void load());
</tr>
</thead>
<tbody>
<tr v-for="general in data.generals" :key="general.id">
<td>
<img
v-if="general.picture"
width="64"
height="64"
:src="`/image/general/${general.picture}`"
/>
<tr
v-for="general in data.generals"
:key="general.id"
:data-is-our-general="general.train !== null"
:data-general-wounded="general.injury"
>
<td class="icon-cell">
<img class="general-icon" width="64" height="64" :src="generalImage(general)" />
</td>
<td :style="{ color: getNpcColor(general.npcState) }">{{ general.name }}</td>
<td :class="{ wounded: general.injury !== 0 }">
{{ woundedStat(general.leadership, general.injury)
}}<span v-if="general.leadershipBonus" class="leadership-bonus"
>+{{ general.leadershipBonus }}</span
>
</td>
<td :class="{ wounded: general.injury !== 0 }">
{{ woundedStat(general.strength, general.injury) }}
</td>
<td :class="{ wounded: general.injury !== 0 }">
{{ woundedStat(general.intelligence, general.injury) }}
</td>
<td>{{ general.name }}</td>
<td>{{ general.leadership }}</td>
<td>{{ general.strength }}</td>
<td>{{ general.intelligence }}</td>
<td>{{ formatOfficerLevelText(general.officerLevel) }}</td>
<td>{{ general.defenceTrain ?? '?' }}</td>
<td>{{ general.crewTypeId ?? '?' }}</td>
<td>{{ defenceTrainText(general.defenceTrain) }}</td>
<td>{{ general.crewTypeName ?? '?' }}</td>
<td>{{ general.crew ?? '?' }}</td>
<td>{{ general.train ?? '?' }}</td>
<td>{{ general.atmos ?? '?' }}</td>
<td class="turns">
{{
general.turns.length
? general.turns.map((turn, index) => `${index + 1} : ${turn}`).join(' / ')
: general.npcState > 1
? 'NPC 장수'
: `${general.nationName}】 장수`
}}
<template v-if="general.turns.length">
<span v-for="(turn, index) in general.turns" :key="index" class="turn-line"
>{{ index + 1 }} : {{ turn }}</span
>
</template>
<template v-else-if="general.npcState > 1">NPC 장수</template>
<template v-else-if="general.nationId !== data.me.nationId">
{{ general.nationId === 0 ? ' ' : `${general.nationName} 장수` }}
</template>
</td>
</tr>
</tbody>
</table>
</template>
<table class="legacy-table legacy-bg0 center footer">
<table class="legacy-table legacy-bg0 footer">
<tbody>
<tr>
<td><RouterLink to="/">돌아가기</RouterLink></td>
<td><RouterLink class="back-link" to="/">돌아가기</RouterLink></td>
</tr>
</tbody>
</table>
@@ -170,60 +319,138 @@ onMounted(() => void load());
<style scoped>
.city-page {
width: 1000px;
margin: 0 auto;
font-size: 14px;
margin: 8px auto 0;
font-family: 'Times New Roman', serif;
font-size: 16px;
line-height: normal;
}
.legacy-table {
width: 100%;
border-collapse: collapse;
border-collapse: separate;
border-spacing: 2px;
}
.legacy-table td,
.legacy-table th {
border: 1px solid #777;
padding: 3px;
border: 0;
padding: 1px;
font-weight: 400;
}
.center {
.center,
.selector,
.stats td,
.stats th,
.generals th,
.generals td:not(:last-child) {
text-align: center;
}
.selector {
text-align: center;
margin-top: 0;
}
.selector select {
display: inline-block;
min-width: 400px;
height: 19px;
padding: 0;
border: 1px solid #767676;
background: #6b6b6b;
color: #fff;
font-family: Arial, sans-serif;
font-size: 13.3333px;
}
.selector {
transform: translateY(-2px);
}
.selector p {
margin: 1em 0;
}
.back-row {
margin-top: 14px;
}
.stats {
margin-top: 14px;
margin-top: 0;
table-layout: fixed;
}
.label-col {
width: 48px;
}
.value-col {
width: 108px;
}
.first-value-col {
width: 112px;
}
.stats th,
.generals th {
background-color: #14241b;
background-image: url('/image/game/back_green.jpg');
text-align: center;
}
.stats td {
text-align: center;
}
.city-title {
text-align: center;
}
.generals {
margin-top: 14px;
.stats {
height: 136px;
}
.generals td {
text-align: center;
.general-names {
text-align: left !important;
}
.unknown {
color: gray;
}
.generals {
width: 1024px;
margin: 18px 0 0 50%;
table-layout: fixed;
transform: translateX(-50%);
}
.generals td:last-child {
text-align: left;
padding-left: 1em;
}
.icon-cell {
height: 64px;
padding: 0 !important;
}
.generals tbody tr {
height: 72px;
}
.general-icon {
display: block;
width: 64px;
min-width: 64px;
height: 64px;
object-fit: fill;
}
.turns {
font-size: x-small;
}
.turn-line {
display: block;
}
.wounded {
color: red;
}
.leadership-bonus {
color: cyan;
}
.footer {
margin-top: 14px;
}
.back-link {
display: inline-block;
border: 1px solid #6c757d;
border-radius: 0.2rem;
background: #6c757d;
color: #fff;
padding: 0.25rem 0.5rem;
font-family: Arial, sans-serif;
font-size: 14px;
line-height: 1;
text-decoration: none;
}
.back-link:hover,
.back-link:focus,
.back-link:active {
border-color: #565e64;
background: #5c636a;
color: #fff;
}
.error {
text-align: center;
color: #ff7373;
@@ -233,8 +460,5 @@ onMounted(() => void load());
width: 1000px;
transform-origin: top left;
}
.selector select {
min-width: 300px;
}
}
</style>
+45 -5
View File
@@ -135,7 +135,7 @@ onMounted(async () => {
</select>
</label>
<div v-if="errorMessage" class="legacy-message error">{{ errorMessage }}</div>
<div v-if="errorMessage" class="legacy-message error" role="alert">{{ errorMessage }}</div>
<div v-else-if="loading" class="legacy-message">불러오는 중...</div>
<div v-else-if="!data" class="legacy-message">표시할 데이터가 없습니다.</div>
@@ -171,6 +171,10 @@ onMounted(async () => {
<div class="legacy-hall-bottom">
<button class="legacy-button" type="button" @click="closePage"> 닫기</button>
</div>
<footer class="legacy-banner">
삼국지 모의전투 HiDCHe core2026 / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD /
<a href="https://sam.hided.net/wiki/hidche/credit" target="_blank" rel="noreferrer">Credit</a>
</footer>
</main>
</template>
@@ -191,7 +195,7 @@ onMounted(async () => {
.legacy-hall-title,
.legacy-hall-bottom {
text-align: center;
text-align: left;
}
.legacy-hall-title {
@@ -205,12 +209,33 @@ onMounted(async () => {
}
.scenario-search select {
min-width: 220px;
width: 189px;
height: 20px;
border: 1px solid #555;
background: #ddd;
color: #303030;
}
.legacy-button {
border: 0;
border-radius: 5.25px;
background: #375a7f;
padding: 5.25px 10.5px;
font-weight: 700;
line-height: 21px;
}
.legacy-button:hover,
.legacy-button:focus,
.legacy-button:active {
background: #6b6b6b;
}
.legacy-button:focus-visible {
outline: revert;
outline-offset: 0;
}
.legacy-message {
border: 1px solid gray;
padding: 12px;
@@ -221,6 +246,15 @@ onMounted(async () => {
color: #ff6b6b;
}
.legacy-banner {
font-size: 13px;
}
.legacy-banner a {
color: #fff;
text-decoration: underline;
}
.hall-sections {
display: block;
}
@@ -235,7 +269,9 @@ onMounted(async () => {
margin: 0;
border-bottom: 1px solid gray;
padding: 2px;
font-size: 1.17em;
font-size: calc(19px + 0.784615vw);
font-weight: 500;
line-height: 1.2;
text-align: center;
}
@@ -275,7 +311,7 @@ onMounted(async () => {
display: inline-block;
width: 64px;
height: 64px;
object-fit: cover;
object-fit: fill;
}
.hall-server,
@@ -309,5 +345,9 @@ onMounted(async () => {
.legacy-hall-page {
width: 1000px;
}
.rankType {
font-size: 28px;
}
}
</style>
+545 -448
View File
@@ -1,7 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue';
import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import { computed, onMounted, reactive, ref } from 'vue';
import { trpc } from '../utils/trpc';
type InheritStatus = Awaited<ReturnType<typeof trpc.inherit.getStatus.query>>;
@@ -12,8 +10,8 @@ type BuffKey =
| 'warAvoidRatio'
| 'warCriticalRatio'
| 'warMagicTrialProb'
| 'success'
| 'fail'
| 'domesticSuccessProb'
| 'domesticFailProb'
| 'warAvoidRatioOppose'
| 'warCriticalRatioOppose'
| 'warMagicTrialProbOppose';
@@ -22,8 +20,8 @@ const buffKeys: BuffKey[] = [
'warAvoidRatio',
'warCriticalRatio',
'warMagicTrialProb',
'success',
'fail',
'domesticSuccessProb',
'domesticFailProb',
'warAvoidRatioOppose',
'warCriticalRatioOppose',
'warMagicTrialProbOppose',
@@ -33,8 +31,8 @@ const buffLabels: Record<BuffKey, string> = {
warAvoidRatio: '회피 확률 증가',
warCriticalRatio: '필살 확률 증가',
warMagicTrialProb: '전투계략 시도 확률 증가',
success: '내정 성공률 증가',
fail: '내정 실패 감소',
domesticSuccessProb: '내정 성공률 증가',
domesticFailProb: '내정 실패 확률 감소',
warAvoidRatioOppose: '상대 회피 확률 감소',
warCriticalRatioOppose: '상대 필살 확률 감소',
warMagicTrialProbOppose: '상대 전투계략 시도 확률 감소',
@@ -43,20 +41,21 @@ const buffLabels: Record<BuffKey, string> = {
const pointLabels: Record<string, string> = {
previous: '보유',
lived_month: '생존 턴',
max_domestic_critical: '내정 최고치',
active_action: '활동',
combat: '전투',
sabotage: '계략',
dex: '숙련',
unifier: '통일 보상',
max_domestic_critical: '최대 연속 내정 성공',
active_action: '능동 행동 수',
combat: '전투 횟수',
sabotage: '계략 성공 횟수',
dex: '숙련',
unifier: '천통 기여',
tournament: '토너먼트',
betting: '베팅',
max_belong: '최대 충성',
betting: '베팅 당첨',
max_belong: '최대 임관년 수',
};
const pointOrder = [
'previous',
'lived_month',
'max_belong',
'max_domestic_critical',
'active_action',
'combat',
@@ -65,9 +64,33 @@ const pointOrder = [
'unifier',
'tournament',
'betting',
'max_belong',
] as const;
const pointHelp: Record<string, string> = {
previous: '이전에 물려받은 포인트입니다.',
lived_month: '살아남은 기간입니다. (1개월 단위)',
max_belong: '가장 오래 임관했던 국가의 연도입니다.',
max_domestic_critical: '성공한 내정 중 최대 연속값입니다.',
active_action: '장수 동향에 본인의 이름이 직접 나타난 수입니다. 일부 사령턴은 제외됩니다.',
combat: '전투 횟수입니다.',
sabotage: '계략 성공 횟수입니다.',
unifier: '천통에 기여한 포인트입니다. 각 국의 군주, 천통 수뇌, 천통 군주가 받습니다.',
dex: '총 숙련도합입니다. 최대 숙련 이후에는 상승량이 1/3로 감소합니다.',
tournament: '토너먼트 입상 포인트입니다.',
betting: '성공적인 베팅을 했습니다. 수익율과 베팅 성공 횟수를 따릅니다.',
};
const buffHelp: Record<BuffKey, string> = {
warAvoidRatio: '전투 시 회피 확률이 1%p ~ 5%p 증가합니다.',
warCriticalRatio: '전투 시 필살 확률이 1%p ~ 5%p 증가합니다.',
warMagicTrialProb: '전투 시 계략을 시도할 확률이 1%p ~ 5%p 증가합니다. 무장도 계략을 시도합니다.',
domesticSuccessProb: '민심, 인구, 농업, 상업, 치안, 수비, 성벽, 기술 내정의 성공 확률이 증가합니다.',
domesticFailProb: '민심, 인구, 농업, 상업, 치안, 수비, 성벽, 기술 내정의 실패 확률이 감소합니다.',
warAvoidRatioOppose: '전투 시 상대의 회피 확률이 1%p ~ 5%p 감소합니다.',
warCriticalRatioOppose: '전투 시 상대의 필살 확률이 1%p ~ 5%p 감소합니다.',
warMagicTrialProbOppose: '전투 시 상대의 계략 시도 확률이 1%p ~ 5%p 감소합니다.',
};
const loading = ref(true);
const error = ref<string | null>(null);
const status = ref<InheritStatus | null>(null);
@@ -86,8 +109,8 @@ const buffTargets = reactive<Record<BuffKey, number>>({
warAvoidRatio: 1,
warCriticalRatio: 1,
warMagicTrialProb: 1,
success: 1,
fail: 1,
domesticSuccessProb: 1,
domesticFailProb: 1,
warAvoidRatioOppose: 1,
warCriticalRatioOppose: 1,
warMagicTrialProbOppose: 1,
@@ -115,7 +138,7 @@ const statRules = computed(() => joinConfig.value?.rules.stat ?? null);
const resetStatTotal = computed(() => resetStatForm.leadership + resetStatForm.strength + resetStatForm.intel);
const resetBonusSum = computed(() => resetStatForm.bonus.reduce((acc, value) => acc + value, 0));
const resetStatCost = computed(() =>
resetBonusSum.value > 0 ? status.value?.inheritConst.inheritBornStatPoint ?? 0 : 0
resetBonusSum.value > 0 ? (status.value?.inheritConst.inheritBornStatPoint ?? 0) : 0
);
const resetStatErrors = computed(() => {
@@ -166,6 +189,9 @@ const pointEntries = computed(() => {
}));
});
const previousPoint = computed(() => status.value?.items.previous ?? 0);
const newPoint = computed(() => (status.value?.totalPoint ?? 0) - previousPoint.value);
const specialNameMap = computed(() => {
const map = new Map<string, string>();
for (const entry of status.value?.availableSpecialWar ?? []) {
@@ -174,29 +200,12 @@ const specialNameMap = computed(() => {
return map;
});
const currentSpecialName = computed(() => {
if (!status.value) {
return '-';
}
return specialNameMap.value.get(status.value.currentSpecialWar) ?? status.value.currentSpecialWar ?? '-';
});
const buffCost = (key: BuffKey, target: number): number => {
const points = status.value?.inheritConst.inheritBuffPoints ?? [0, 0, 0, 0, 0, 0];
const current = status.value?.buffLevels[key] ?? 0;
return Math.max(0, (points[target] ?? 0) - (points[current] ?? 0));
};
const buffTargetOptions = (key: BuffKey): number[] => {
const current = status.value?.buffLevels[key] ?? 0;
const start = Math.min(5, Math.max(1, current + 1));
const result: number[] = [];
for (let level = start; level <= 5; level += 1) {
result.push(level);
}
return result.length > 0 ? result : [5];
};
const resolveErrorMessage = (value: unknown): string => {
if (value instanceof Error) {
return value.message;
@@ -207,17 +216,6 @@ const resolveErrorMessage = (value: unknown): string => {
return 'unknown_error';
};
const applyResetBalanced = () => {
const rules = statRules.value;
if (!rules) {
return;
}
const base = Math.floor(rules.total / 3);
resetStatForm.leadership = rules.total - base * 2;
resetStatForm.strength = base;
resetStatForm.intel = base;
};
const syncSelections = () => {
if (!status.value) {
return;
@@ -235,6 +233,14 @@ const syncSelections = () => {
if (!uniqueForm.amount) {
uniqueForm.amount = status.value.inheritConst.inheritItemUniqueMinPoint;
}
if (!uniqueForm.itemId) {
uniqueForm.itemId = status.value.availableUnique[0]?.key ?? '';
}
if (resetStatForm.leadership === 0 && resetStatForm.strength === 0 && resetStatForm.intel === 0) {
resetStatForm.leadership = status.value.currentStat.leadership;
resetStatForm.strength = status.value.currentStat.strength;
resetStatForm.intel = status.value.currentStat.intel;
}
};
const loadStatus = async () => {
@@ -377,7 +383,7 @@ const buyRandomUnique = async () => {
const openUniqueAuction = async () => {
if (!uniqueForm.itemId.trim()) {
actionError.value = '유니크 아이템 ID를 입력해주세요.';
actionError.value = '유니크를 선택해주세요.';
return;
}
const amount = Math.max(0, Math.floor(uniqueForm.amount));
@@ -412,12 +418,6 @@ const checkOwner = async () => {
});
};
watch(statRules, (rules) => {
if (rules) {
applyResetBalanced();
}
});
onMounted(() => {
void loadStatus();
void loadJoinConfig();
@@ -426,464 +426,561 @@ onMounted(() => {
</script>
<template>
<main class="inherit-page">
<header class="inherit-header">
<div>
<h1 class="inherit-title">유산 포인트</h1>
<p class="inherit-subtitle">숨김 강화와 유산 상점 기능을 관리합니다.</p>
</div>
<div class="inherit-actions">
<button class="ghost" @click="loadStatus">새로고침</button>
<button class="ghost" @click="loadLogs(true)">로그 갱신</button>
</div>
</header>
<header class="top-back-bar legacy-bg0">
<RouterLink class="top-button legacy-button" to="/">돌아가기</RouterLink>
<strong>유산 관리</strong>
<button class="top-button legacy-button" type="button" :disabled="loading" @click="loadStatus">갱신</button>
</header>
<div v-if="error" class="inherit-error">{{ error }}</div>
<div v-if="actionError" class="inherit-error">{{ actionError }}</div>
<div v-if="actionMessage" class="inherit-message">{{ actionMessage }}</div>
<main id="container" class="inherit-page legacy-bg0">
<div v-if="error || actionError" class="notice error" role="alert">{{ error ?? actionError }}</div>
<div v-if="actionMessage" class="notice success">{{ actionMessage }}</div>
<div v-if="loading" class="loading-state">불러오는 중...</div>
<div v-if="loading">
<SkeletonLines :lines="4" />
</div>
<template v-else-if="status">
<section id="inheritance_list" class="point-grid">
<article id="inherit_sum" class="inherit-item">
<label for="inherit_sum_value"> 포인트</label>
<input id="inherit_sum_value" :value="Math.floor(status.totalPoint).toLocaleString()" readonly />
<small>다음 플레이에서 사용할 있는 포인트입니다.</small>
</article>
<article id="inherit_previous" class="inherit-item">
<label for="inherit_previous_value">기존 포인트</label>
<input id="inherit_previous_value" :value="Math.floor(previousPoint).toLocaleString()" readonly />
<small>이전에 물려받은 포인트입니다.</small>
</article>
<article id="inherit_new" class="inherit-item">
<label for="inherit_new_value">신규 포인트</label>
<input id="inherit_new_value" :value="Math.floor(newPoint).toLocaleString()" readonly />
<small>이번 플레이에서 얻은 포인트입니다.</small>
</article>
<div class="divider"></div>
<article
v-for="entry in pointEntries.filter((item) => item.key !== 'previous')"
:id="`inherit_${entry.key}`"
:key="entry.key"
class="inherit-item"
>
<label :for="`inherit_${entry.key}_value`">{{ entry.label }}</label>
<input
:id="`inherit_${entry.key}_value`"
:value="Math.floor(entry.value).toLocaleString()"
readonly
/>
<small>{{ pointHelp[entry.key] }}</small>
</article>
</section>
<section v-else class="inherit-grid">
<PanelCard title="포인트 요약" subtitle="유산 포인트 구성 현황">
<div v-if="!status" class="muted">포인트 정보를 불러오지 못했습니다.</div>
<div v-else class="summary-panel">
<div class="summary-head">
<div class="summary-total"> {{ status.totalPoint }} 포인트</div>
<div class="summary-state" :class="{ united: status.isUnited }">
{{ status.isUnited ? '통일 완료' : '진행 중' }}
</div>
</div>
<div class="summary-list">
<div v-for="entry in pointEntries" :key="entry.key" class="summary-row">
<span>{{ entry.label }}</span>
<span>{{ entry.value }}</span>
</div>
</div>
<div class="summary-footer">
<div>현재 전투 특기: {{ currentSpecialName }}</div>
<div>특기 초기화 단계: {{ status.resetLevels.resetSpecialWar }}</div>
<div> 시간 초기화 단계: {{ status.resetLevels.resetTurnTime }}</div>
</div>
</div>
</PanelCard>
<section id="inheritance_store">
<h2 class="section-title legacy-bg1">유산 포인트 상점</h2>
<PanelCard title="숨김 강화" subtitle="숨김 강화 효과를 구입합니다.">
<div v-if="!status" class="muted">숨김 강화 정보를 불러오지 못했습니다.</div>
<div v-else class="buff-list">
<div v-for="key in buffKeys" :key="key" class="buff-row">
<div class="buff-info">
<div class="buff-name">{{ buffLabels[key] }}</div>
<div class="buff-level">현재 {{ status.buffLevels[key] ?? 0 }} 단계</div>
</div>
<div class="buff-action">
<select v-model.number="buffTargets[key]" class="form-input">
<option
v-for="level in buffTargetOptions(key)"
:key="level"
:value="level"
>
{{ level }} 단계
<div class="action-grid leading-actions">
<article class="shop-item">
<div class="control-row">
<label for="next-special">다음 전투 특기 선택</label>
<select id="next-special" v-model="nextSpecialKey">
<option v-for="entry in status.availableSpecialWar" :key="entry.key" :value="entry.key">
{{ entry.name }}
</option>
</select>
<div class="buff-cost">비용 {{ buffCost(key, buffTargets[key]) }}</div>
</div>
<small
>{{ specialNameMap.get(nextSpecialKey) }} 특기를 다음에 얻도록 지정합니다.<br /><b
>필요 포인트: {{ status.inheritConst.inheritSpecificSpecialPoint }}</b
></small
>
<button
class="legacy-button buy-button"
:disabled="isUnited || actionBusy"
@click="reserveSpecialWar"
>
구입
</button>
</article>
<article class="shop-item">
<div class="control-row">
<label for="specific-unique">유니크 경매</label>
<select id="specific-unique" v-model="uniqueForm.itemId">
<option disabled value="">유니크 선택</option>
<option v-for="item in status.availableUnique" :key="item.key" :value="item.key">
{{ item.name }}
</option>
</select>
</div>
<div class="control-row">
<label for="specific-unique-amount">입찰 포인트</label>
<input
id="specific-unique-amount"
v-model.number="uniqueForm.amount"
type="number"
:min="status.inheritConst.inheritItemUniqueMinPoint"
:max="previousPoint"
/>
</div>
<small
>얻고자 하는 유니크 아이템으로 경매를 시작합니다. 24 동안 진행됩니다.<br />{{
status.availableUnique.find((item) => item.key === uniqueForm.itemId)?.info
}}</small
>
<button
class="legacy-button buy-button"
:disabled="isUnited || actionBusy"
@click="openUniqueAuction"
>
경매 시작
</button>
</article>
</div>
<div class="divider"></div>
<div class="action-grid">
<article class="shop-item simple-item">
<div class="control-row">
<span>랜덤 초기화</span
><button class="legacy-button" :disabled="isUnited || actionBusy" @click="resetTurnTime">
구입
</button>
</div>
<small
>다다음턴부터 시간이 랜덤하게 바뀝니다. (필요 포인트가 피보나치식으로 증가합니다)<br /><b
>필요 포인트: {{ status.resetCosts.resetTurnTime }}</b
><template v-if="turnTimeLabel"><br />적용 시간: {{ turnTimeLabel }}</template></small
>
</article>
<article class="shop-item simple-item">
<div class="control-row">
<span>랜덤 유니크 획득</span
><button class="legacy-button" :disabled="isUnited || actionBusy" @click="buyRandomUnique">
구입
</button>
</div>
<small
>다음 턴에 랜덤 유니크를 얻습니다.<br /><b
>필요 포인트: {{ status.inheritConst.inheritItemRandomPoint }}</b
></small
>
</article>
<article class="shop-item simple-item">
<div class="control-row">
<span>즉시 전투 특기 초기화</span
><button class="legacy-button" :disabled="isUnited || actionBusy" @click="resetSpecialWar">
구입
</button>
</div>
<small
>즉시 전투 특기를 초기화합니다. (필요 포인트가 피보나치식으로 증가합니다)<br /><b
>필요 포인트: {{ status.resetCosts.resetSpecialWar }}</b
></small
>
</article>
</div>
<div class="divider"></div>
<div class="buff-grid">
<article v-for="key in buffKeys" :key="key" class="shop-item buff-item">
<div class="control-row">
<label :for="`buff-${key}`">{{ buffLabels[key] }}</label>
<input
:id="`buff-${key}`"
v-model.number="buffTargets[key]"
type="number"
:min="status.buffLevels[key] ?? 0"
max="5"
/>
</div>
<small
>{{ buffHelp[key] }}<br /><b>필요 포인트: {{ buffCost(key, buffTargets[key]) }}</b></small
>
<div class="dual-buttons">
<button
:disabled="isUnited || actionBusy || (status.buffLevels[key] ?? 0) >= 5"
class="legacy-button secondary"
:disabled="actionBusy"
@click="buffTargets[key] = status.buffLevels[key] ?? 0"
>
리셋
</button>
<button
class="legacy-button"
:disabled="isUnited || actionBusy"
@click="buyHiddenBuff(key)"
>
구입
</button>
</div>
</div>
</article>
</div>
</PanelCard>
<PanelCard title="전투 특기 제어" subtitle="다음 특기 지정 및 초기화">
<div v-if="!status" class="muted">전투 특기 정보를 불러오지 못했습니다.</div>
<div v-else class="action-stack">
<div class="action-row">
<label class="form-field">
<span>다음 전투 특기</span>
<select v-model="nextSpecialKey" class="form-input">
<option v-for="special in status.availableSpecialWar" :key="special.key" :value="special.key">
{{ special.name }}
<div class="divider"></div>
<div class="action-grid bottom-actions">
<article class="shop-item">
<div class="control-row">
<label for="owner-target">장수 소유자 확인</label>
<select id="owner-target" v-model="ownerTargetId">
<option disabled value="">장수 선택</option>
<option
v-for="general in status.availableTargetGenerals"
:key="general.id"
:value="String(general.id)"
>
{{ general.name }}
</option>
</select>
<small class="muted">비용 {{ status.inheritConst.inheritSpecificSpecialPoint }} 포인트</small>
</label>
<button :disabled="isUnited || actionBusy" @click="reserveSpecialWar">예약</button>
</div>
<div class="action-row">
<div>
<div class="muted">현재 전투 특기: {{ currentSpecialName }}</div>
<div class="muted">
초기화 비용 {{ status.resetCosts.resetSpecialWar }} 포인트
({{ status.resetLevels.resetSpecialWar }})
</div>
<small
>장수의 소유자를 찾습니다. 대상에게도 알림이 전송됩니다.<br /><b
>필요 포인트: {{ status.inheritConst.inheritCheckOwnerPoint }}</b
></small
>
<button class="legacy-button buy-button" :disabled="isUnited || actionBusy" @click="checkOwner">
소유자 찾기
</button>
<p v-if="ownerResult" class="owner-result">
{{ ownerResult.targetName }} 소유자: {{ ownerResult.ownerName }}
</p>
</article>
<article class="shop-item stat-reset">
<div class="stat-layout">
<span>능력치 초기화</span>
<div>
<strong>기본 능력치</strong>
<label
>
<input
v-model.number="resetStatForm.leadership"
type="number"
:min="statRules?.min"
:max="statRules?.max"
/></label>
<label
>
<input
v-model.number="resetStatForm.strength"
type="number"
:min="statRules?.min"
:max="statRules?.max"
/></label>
<label
>
<input
v-model.number="resetStatForm.intel"
type="number"
:min="statRules?.min"
:max="statRules?.max"
/></label>
<strong>추가 능력치</strong>
<label
> <input v-model.number="resetStatForm.bonus[0]" type="number" min="0" max="5"
/></label>
<label
> <input v-model.number="resetStatForm.bonus[1]" type="number" min="0" max="5"
/></label>
<label
> <input v-model.number="resetStatForm.bonus[2]" type="number" min="0" max="5"
/></label>
</div>
</div>
<button :disabled="isUnited || actionBusy" @click="resetSpecialWar">초기화</button>
</div>
<small
>시즌 1회에 능력치를 초기화합니다.<br /><b>필요 포인트: {{ resetStatCost }}</b
><br /><span v-if="resetStatErrors.length">{{ resetStatErrors[0] }}</span></small
>
<button
class="legacy-button buy-button"
:disabled="isUnited || actionBusy || resetStatErrors.length > 0"
@click="resetStats"
>
능력치 초기화
</button>
</article>
</div>
</PanelCard>
</section>
<PanelCard title="턴 시간 초기화" subtitle="턴 시간대를 재설정합니다.">
<div v-if="!status" class="muted"> 시간 정보를 불러오지 못했습니다.</div>
<div v-else class="action-stack">
<div class="action-row">
<div class="muted">
비용 {{ status.resetCosts.resetTurnTime }} 포인트 ({{ status.resetLevels.resetTurnTime }})
</div>
<button :disabled="isUnited || actionBusy" @click="resetTurnTime"> 시간 변경</button>
</div>
<div v-if="turnTimeLabel" class="muted">다음 적용 시각: {{ turnTimeLabel }}</div>
<section class="inherit-logs">
<h2 class="section-title legacy-bg1">유산 포인트 변경 내역</h2>
<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>
<span>{{ entry.text }}</span>
</div>
</PanelCard>
<PanelCard title="능력치 초기화" subtitle="능력치를 다시 배분합니다.">
<div v-if="!status" class="muted">능력치 정보를 불러오지 못했습니다.</div>
<div v-else class="stat-panel">
<div class="stat-grid">
<label class="form-field">
<span>통솔</span>
<input v-model.number="resetStatForm.leadership" type="number" class="form-input" />
</label>
<label class="form-field">
<span>무력</span>
<input v-model.number="resetStatForm.strength" type="number" class="form-input" />
</label>
<label class="form-field">
<span>지력</span>
<input v-model.number="resetStatForm.intel" type="number" class="form-input" />
</label>
</div>
<div class="stat-grid">
<label class="form-field">
<span>보너스 통솔</span>
<input v-model.number="resetStatForm.bonus[0]" type="number" min="0" max="5" class="form-input" />
</label>
<label class="form-field">
<span>보너스 무력</span>
<input v-model.number="resetStatForm.bonus[1]" type="number" min="0" max="5" class="form-input" />
</label>
<label class="form-field">
<span>보너스 지력</span>
<input v-model.number="resetStatForm.bonus[2]" type="number" min="0" max="5" class="form-input" />
</label>
</div>
<div class="stat-summary">
<div>총합 {{ resetStatTotal }} / {{ statRules?.total ?? '-' }}</div>
<div>보너스 {{ resetBonusSum }} · 비용 {{ resetStatCost }}</div>
<div v-if="resetStatErrors.length" class="stat-errors">
<div v-for="item in resetStatErrors" :key="item">{{ item }}</div>
</div>
</div>
<div class="action-row">
<button :disabled="isUnited || actionBusy" class="ghost" @click="applyResetBalanced">균형형</button>
<button :disabled="isUnited || actionBusy" @click="resetStats">능력치 초기화</button>
</div>
</div>
</PanelCard>
<PanelCard title="유니크 상점" subtitle="유니크 아이템 관련 기능">
<div v-if="!status" class="muted">유니크 정보를 불러오지 못했습니다.</div>
<div v-else class="action-stack">
<div class="action-row">
<div class="muted">랜덤 유니크 구매 ({{ status.inheritConst.inheritItemRandomPoint }} 포인트)</div>
<button :disabled="isUnited || actionBusy" @click="buyRandomUnique">구입</button>
</div>
<div class="action-row">
<label class="form-field">
<span>유니크 아이템 ID</span>
<input v-model="uniqueForm.itemId" type="text" class="form-input" />
</label>
<label class="form-field">
<span>입찰 포인트</span>
<input v-model.number="uniqueForm.amount" type="number" class="form-input" />
<small class="muted">최소 {{ status.inheritConst.inheritItemUniqueMinPoint }} 포인트</small>
</label>
<button :disabled="isUnited || actionBusy" @click="openUniqueAuction">신청</button>
</div>
</div>
</PanelCard>
<PanelCard title="소유자 확인" subtitle="상대 장수의 소유자를 확인합니다.">
<div v-if="!status" class="muted">대상 장수 목록을 불러오지 못했습니다.</div>
<div v-else class="action-stack">
<label class="form-field">
<span>대상 장수</span>
<select v-model="ownerTargetId" class="form-input">
<option v-for="general in status.availableTargetGenerals" :key="general.id" :value="String(general.id)">
{{ general.name }}
</option>
</select>
<small class="muted">비용 {{ status.inheritConst.inheritCheckOwnerPoint }} 포인트</small>
</label>
<button :disabled="isUnited || actionBusy" @click="checkOwner">확인</button>
<div v-if="ownerResult" class="muted">
{{ ownerResult.targetName }} 소유자: {{ ownerResult.ownerName }}
</div>
</div>
</PanelCard>
<PanelCard title="유산 기록" subtitle="최근 유산 로그">
<template #actions>
<button class="ghost" :disabled="logLoading" @click="loadLogs(true)">갱신</button>
</template>
<div v-if="logLoading && logs.length === 0">
<SkeletonLines :lines="3" />
</div>
<div v-else-if="logs.length === 0" class="muted">기록이 없습니다.</div>
<div v-else class="log-list">
<div v-for="entry in logs" :key="entry.id" class="log-entry">
<span class="log-date">{{ entry.year }} {{ entry.month }}</span>
<span>{{ entry.text }}</span>
</div>
</div>
<div class="log-footer">
<button class="ghost" :disabled="logLoading || logEnd" @click="loadLogs()"> 보기</button>
</div>
</PanelCard>
</section>
<button class="legacy-button more-button" :disabled="logLoading || logEnd" @click="loadLogs()">
가져오기
</button>
</section>
</template>
</main>
</template>
<style scoped>
.inherit-page {
min-height: 100vh;
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.inherit-header {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid rgba(201, 164, 90, 0.4);
padding-bottom: 12px;
}
.inherit-title {
font-size: 1.6rem;
font-weight: 600;
}
.inherit-subtitle {
font-size: 0.85rem;
color: rgba(232, 221, 196, 0.7);
}
.inherit-actions {
display: flex;
gap: 8px;
}
.inherit-error {
border: 1px solid rgba(240, 90, 90, 0.6);
padding: 8px 10px;
color: rgba(240, 150, 150, 0.9);
}
.inherit-message {
border: 1px solid rgba(120, 190, 120, 0.5);
padding: 8px 10px;
color: rgba(180, 230, 180, 0.9);
}
.inherit-grid {
.top-back-bar {
width: min(100%, 1000px);
min-height: 38px;
margin: 0 auto;
border: 1px solid #888;
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
.summary-panel {
display: flex;
flex-direction: column;
gap: 10px;
}
.summary-head {
display: flex;
grid-template-columns: 100px 1fr 100px;
align-items: center;
justify-content: space-between;
gap: 8px;
text-align: center;
padding: 3px 6px;
box-sizing: border-box;
}
.summary-total {
font-size: 1rem;
font-weight: 600;
}
.summary-state {
font-size: 0.75rem;
.top-button {
padding: 4px 8px;
border: 1px solid rgba(201, 164, 90, 0.4);
text-decoration: none;
}
.summary-state.united {
border-color: rgba(240, 120, 120, 0.6);
color: rgba(240, 150, 150, 0.9);
.inherit-page {
width: min(100%, 1000px);
margin: 0 auto;
border: 1px solid #888;
border-top: 0;
overflow: hidden;
box-sizing: border-box;
padding: 0 8px 10px;
color: #fff;
font:
14px/1.3 Pretendard,
'Apple SD Gothic Neo',
'Noto Sans KR',
'Malgun Gothic',
sans-serif;
}
.summary-list {
display: flex;
flex-direction: column;
gap: 4px;
.notice,
.loading-state,
.log-empty {
padding: 10px;
text-align: center;
}
.summary-row {
display: flex;
justify-content: space-between;
font-size: 0.8rem;
color: rgba(232, 221, 196, 0.8);
.notice.error {
color: #ffb0b0;
}
.summary-footer {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.75rem;
color: rgba(232, 221, 196, 0.7);
.notice.success {
color: #b6efb6;
}
.buff-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.buff-row {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 8px;
border: 1px solid rgba(201, 164, 90, 0.2);
padding: 8px;
}
.buff-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.buff-name {
font-weight: 600;
font-size: 0.85rem;
}
.buff-level {
font-size: 0.7rem;
color: rgba(232, 221, 196, 0.7);
}
.buff-action {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.buff-cost {
font-size: 0.75rem;
color: rgba(232, 221, 196, 0.7);
}
.action-stack {
display: flex;
flex-direction: column;
gap: 12px;
}
.action-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
justify-content: space-between;
}
.stat-panel {
display: flex;
flex-direction: column;
gap: 12px;
}
.stat-grid {
.point-grid,
.action-grid,
.buff-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.stat-summary {
font-size: 0.75rem;
color: rgba(232, 221, 196, 0.7);
display: flex;
flex-direction: column;
gap: 4px;
.inherit-item,
.shop-item {
padding: 8px 16px;
box-sizing: border-box;
min-width: 0;
}
.stat-errors {
color: rgba(240, 150, 150, 0.9);
.inherit-item {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(100px, 1fr);
align-items: start;
gap: 6px;
}
.form-field {
.inherit-item label {
text-align: right;
padding: 7px 8px 0 0;
}
.inherit-item input,
.shop-item input,
.shop-item select {
width: 100%;
min-width: 0;
border: 1px solid #6c757d;
border-radius: 4px;
background: #212529;
color: #fff;
padding: 6px 8px;
box-sizing: border-box;
}
.inherit-item small,
.shop-item small {
grid-column: 1 / -1;
min-height: 34px;
text-align: right;
color: #aeb2b6;
}
.inherit-item small {
min-height: 36px;
}
.divider {
grid-column: 1 / -1;
border-top: 1px solid rgba(255, 255, 255, 0.22);
margin: 4px 2px;
}
.section-title {
font-size: 14px;
font-weight: 400;
text-align: center;
margin: 0 -8px;
padding: 2px;
}
.leading-actions .shop-item:first-child {
grid-column: 2;
}
.control-row {
display: grid;
grid-template-columns: 1fr 1fr;
align-items: center;
gap: 8px;
min-height: 36px;
}
.control-row > label,
.control-row > span {
text-align: right;
}
.shop-item {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 0.75rem;
}
.form-input {
border: 1px solid rgba(201, 164, 90, 0.4);
background: rgba(10, 10, 10, 0.8);
padding: 6px 8px;
color: inherit;
.shop-item .buy-button {
width: 50%;
margin-left: auto;
}
button {
border: 1px solid rgba(201, 164, 90, 0.4);
padding: 6px 12px;
font-size: 0.8rem;
background: rgba(16, 16, 16, 0.6);
color: inherit;
.simple-item small {
min-height: 55px;
}
button.ghost {
background: transparent;
.buff-item small {
min-height: 72px;
}
.log-list {
display: flex;
flex-direction: column;
.dual-buttons {
display: grid;
grid-template-columns: 1fr 1fr;
}
.legacy-button.secondary {
border-color: #51585e;
background: #5c636a;
}
.bottom-actions .shop-item:first-child {
grid-column: 1;
}
.stat-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
text-align: right;
}
.log-entry {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 0.8rem;
.stat-layout > div {
display: grid;
gap: 3px;
}
.log-date {
font-size: 0.7rem;
color: rgba(232, 221, 196, 0.6);
.stat-layout label {
display: grid;
grid-template-columns: 22px 1fr;
align-items: center;
}
.log-footer {
margin-top: 8px;
.stat-layout strong {
text-align: left;
font-size: 12px;
}
.muted {
color: rgba(232, 221, 196, 0.6);
font-size: 0.75rem;
.owner-result {
margin: 0;
color: #fff;
text-align: right;
}
.inherit-logs {
margin: 8px 0 0;
}
.log-row {
display: grid;
grid-template-columns: minmax(150px, 20ch) 1fr;
gap: 8px;
padding: 3px 8px;
}
.log-row small {
color: #aeb2b6;
text-align: right;
font-variant-numeric: tabular-nums;
}
.more-button {
width: 100%;
margin-top: 6px;
}
button:focus-visible,
input:focus-visible,
select:focus-visible,
a:focus-visible {
outline: 2px solid #f39c12;
outline-offset: 1px;
}
@media (max-width: 767px) {
.point-grid,
.action-grid,
.buff-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.leading-actions .shop-item:first-child {
grid-column: 1;
}
}
@media (max-width: 575px) {
.top-back-bar,
.inherit-page {
width: 500px;
max-width: 100%;
}
.point-grid,
.action-grid,
.buff-grid {
grid-template-columns: 1fr;
}
.divider {
grid-column: 1;
}
.inherit-item,
.shop-item {
padding-left: 16px;
padding-right: 16px;
}
.log-row {
grid-template-columns: 1fr;
}
.log-row small {
text-align: left;
}
}
</style>
+13 -10
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { RouterLink, useRouter } from 'vue-router';
import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import { trpc } from '../utils/trpc';
@@ -281,6 +281,7 @@ onMounted(() => {
<p class="join-subtitle">로그인 완료, 아직 장수가 없는 상태입니다.</p>
</div>
<div class="join-tabs">
<RouterLink class="simulator-link" to="/battle-simulator">전투 시뮬레이터</RouterLink>
<button :class="{ active: activeTab === 'create' }" @click="activeTab = 'create'">장수 생성</button>
<button :class="{ active: activeTab === 'possess' }" @click="activeTab = 'possess'">NPC 빙의</button>
</div>
@@ -464,17 +465,13 @@ onMounted(() => {
<section v-else class="join-grid">
<PanelCard title="빙의 가능한 NPC 목록" subtitle="NPC 타입2 장수를 선택해 빙의합니다.">
<template #actions>
<button class="ghost" :disabled="npcLoading" @click="loadNpcCandidates(true)">
목록 새로고침
</button>
<button class="ghost" :disabled="npcLoading" @click="loadNpcCandidates(true)">목록 새로고침</button>
</template>
<div v-if="npcError" class="muted">{{ npcError }}</div>
<div v-if="npcLoading && npcCandidates.length === 0">
<SkeletonLines :lines="3" />
</div>
<div v-else-if="npcCandidates.length === 0" class="muted">
빙의 가능한 NPC가 없습니다.
</div>
<div v-else-if="npcCandidates.length === 0" class="muted">빙의 가능한 NPC가 없습니다.</div>
<div v-else class="npc-list">
<div v-for="npc in npcCandidates" :key="npc.id" class="npc-card">
<div class="npc-header">
@@ -490,9 +487,7 @@ onMounted(() => {
<div>나이 {{ npc.age }}</div>
<div>도시 {{ npc.city?.name ?? '-' }}</div>
</div>
<button class="npc-action" :disabled="submitting" @click="possessGeneral(npc.id)">
빙의
</button>
<button class="npc-action" :disabled="submitting" @click="possessGeneral(npc.id)">빙의</button>
</div>
</div>
<div class="npc-footer">
@@ -542,6 +537,14 @@ onMounted(() => {
font-size: 0.8rem;
}
.simulator-link {
border: 1px solid rgba(112, 170, 141, 0.55);
padding: 6px 10px;
color: #bfe2cd;
font-size: 0.8rem;
text-decoration: none;
}
.join-tabs button.active {
background: rgba(201, 164, 90, 0.2);
}
+57 -34
View File
@@ -50,7 +50,7 @@ const {
reservedNationTurns,
messageDraftText,
targetMailbox,
mailboxOptions,
mailboxGroups,
statusLine,
realtimeLabel,
} = storeToRefs(dashboard);
@@ -104,6 +104,10 @@ watch(
<RouterLink class="ghost" to="/global-info">중원 정보</RouterLink>
<RouterLink class="ghost" to="/current-city">현재 도시</RouterLink>
<RouterLink class="ghost" to="/nation/generals">세력 장수</RouterLink>
<RouterLink v-if="(boardAccess?.permission ?? -1) >= 1" class="ghost" to="/nation/secret"
>암행부</RouterLink
>
<span v-else class="ghost disabled" aria-disabled="true">암행부</span>
<RouterLink class="ghost" to="/nation/personnel">인사부</RouterLink>
<RouterLink class="ghost" to="/troop">부대 편성</RouterLink>
<RouterLink class="ghost" to="/nation/finance">내무부</RouterLink>
@@ -115,10 +119,11 @@ watch(
<RouterLink class="ghost" to="/dynasty">왕조일람</RouterLink>
<RouterLink class="ghost" to="/yearbook">연감</RouterLink>
<RouterLink class="ghost" to="/nation-betting">천통국 베팅</RouterLink>
<RouterLink class="ghost" to="/traffic">접속량정보</RouterLink>
<RouterLink class="ghost" to="/npc-list">빙의일람</RouterLink>
<a class="ghost" href="/xe/community" target="_blank" rel="noopener">게시판</a>
<RouterLink class="ghost" to="/battle-simulator">전투 시뮬레이터</RouterLink>
<RouterLink class="ghost" to="/my-page"> 정보</RouterLink>
<RouterLink class="ghost" to="/my-page"> 정보&amp;설정</RouterLink>
<RouterLink class="ghost" :class="{ highlight: tournamentStage === 1 }" to="/tournament"
>토너먼트</RouterLink
>
@@ -216,22 +221,26 @@ watch(
</div>
<div v-if="mobileTab === 'messages'" class="mobile-panel">
<PanelCard title="메시지함">
<MessagePanel
:messages="messages"
:loading="loading"
:target-mailbox="targetMailbox"
:draft-text="messageDraftText"
:mailbox-options="mailboxOptions"
:can-respond-diplomacy="messages?.canRespondDiplomacy ?? false"
@update:target-mailbox="targetMailbox = $event"
@update:draft-text="messageDraftText = $event"
@send="dashboard.sendMessage"
@load-older="dashboard.loadOlderMessages"
@refresh="dashboard.refreshMessages"
@respond="dashboard.respondToMessage"
/>
</PanelCard>
<MessagePanel
class="mobile-message-panel"
:messages="messages"
:loading="loading"
:target-mailbox="targetMailbox"
:draft-text="messageDraftText"
:mailbox-groups="mailboxGroups"
:general-id="general?.id ?? 0"
:general-name="general?.name ?? ''"
:nation-id="general?.nationId ?? 0"
:can-respond-diplomacy="messages?.canRespondDiplomacy ?? false"
@update:target-mailbox="targetMailbox = $event"
@update:draft-text="messageDraftText = $event"
@send="dashboard.sendMessage"
@load-older="dashboard.loadOlderMessages"
@refresh="dashboard.refreshMessages"
@respond="dashboard.respondToMessage"
@read-latest="dashboard.readLatestMessage"
@delete="dashboard.deleteMessage"
/>
</div>
</section>
@@ -251,22 +260,6 @@ watch(
<div>세력 {{ lobbyInfo?.nationCnt ?? '-' }}</div>
</div>
</PanelCard>
<PanelCard title="메시지함">
<MessagePanel
:messages="messages"
:loading="loading"
:target-mailbox="targetMailbox"
:draft-text="messageDraftText"
:mailbox-options="mailboxOptions"
:can-respond-diplomacy="messages?.canRespondDiplomacy ?? false"
@update:target-mailbox="targetMailbox = $event"
@update:draft-text="messageDraftText = $event"
@send="dashboard.sendMessage"
@load-older="dashboard.loadOlderMessages"
@refresh="dashboard.refreshMessages"
@respond="dashboard.respondToMessage"
/>
</PanelCard>
</div>
<div class="stack">
@@ -302,6 +295,26 @@ watch(
<div v-else class="placeholder">개인 기록 영역</div>
</PanelCard>
</div>
<MessagePanel
class="desktop-message-panel"
:messages="messages"
:loading="loading"
:target-mailbox="targetMailbox"
:draft-text="messageDraftText"
:mailbox-groups="mailboxGroups"
:general-id="general?.id ?? 0"
:general-name="general?.name ?? ''"
:nation-id="general?.nationId ?? 0"
:can-respond-diplomacy="messages?.canRespondDiplomacy ?? false"
@update:target-mailbox="targetMailbox = $event"
@update:draft-text="messageDraftText = $event"
@send="dashboard.sendMessage"
@load-older="dashboard.loadOlderMessages"
@refresh="dashboard.refreshMessages"
@respond="dashboard.respondToMessage"
@read-latest="dashboard.readLatestMessage"
@delete="dashboard.deleteMessage"
/>
</section>
</main>
</template>
@@ -396,6 +409,16 @@ button {
gap: 16px;
}
.desktop-message-panel {
grid-column: 1 / -1;
}
.mobile-message-panel {
width: 100vw;
min-width: 0;
margin-left: -24px;
}
.layout-mobile {
display: flex;
flex-direction: column;
+543 -467
View File
@@ -1,19 +1,16 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useMediaQuery } from '@vueuse/core';
import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
import CityBasicCard from '../components/main/CityBasicCard.vue';
import NationBasicCard from '../components/main/NationBasicCard.vue';
import { trpc } from '../utils/trpc';
import { formatLog } from '../utils/formatLog';
const SCREEN_MODE_KEY = 'sammo-screen-mode';
const SCREEN_MODE_KEY = 'sam.screenMode';
const CUSTOM_CSS_KEY = 'sam_customCSS';
type ScreenMode = 'auto' | '500px' | '1000px';
type LogType = 'generalHistory' | 'battleDetail' | 'battleResult' | 'generalAction';
type ItemSlotKey = 'horse' | 'weapon' | 'book' | 'item';
type MyGeneralResponse = Awaited<ReturnType<typeof trpc.general.me.query>>;
type WorldStateSnapshot = {
type WorldSnapshot = {
currentYear: number;
currentMonth: number;
tickSeconds: number;
@@ -21,48 +18,54 @@ type WorldStateSnapshot = {
meta: Record<string, unknown>;
} | null;
type LogType = 'generalHistory' | 'battleDetail' | 'battleResult' | 'generalAction';
type LogLine = {
id: number;
html: string;
};
type ItemSlotKey = 'horse' | 'weapon' | 'book' | 'item';
type ItemSlot = {
key: ItemSlotKey;
label: string;
code: string | null;
};
const logTypes: LogType[] = ['generalHistory', 'battleDetail', 'battleResult', 'generalAction'];
const logLabels: Record<LogType, string> = {
generalHistory: '장수 열전',
battleDetail: '전투 기록',
battleResult: '전투 결과',
generalAction: '개인 기록',
type SettingForm = {
tnmt: number;
defence_train: number;
use_treatment: number;
use_auto_nation_turn: number;
};
const data = ref<MyGeneralResponse | null>(null);
const world = ref<WorldSnapshot>(null);
const loading = ref(false);
const error = ref<string | null>(null);
const data = ref<MyGeneralResponse | null>(null);
const worldState = ref<WorldStateSnapshot>(null);
const screenMode = ref<ScreenMode>('auto');
const customCss = ref('');
const cssSaving = ref(false);
let cssTimer: number | null = null;
const logs = reactive<Record<LogType, LogLine[]>>({
const form = reactive<SettingForm>({
tnmt: 1,
defence_train: 80,
use_treatment: 10,
use_auto_nation_turn: 1,
});
const logTypes: LogType[] = ['generalAction', 'battleDetail', 'generalHistory', 'battleResult'];
const logLabels: Record<LogType, string> = {
generalAction: '개인 기록',
battleDetail: '전투 기록',
generalHistory: '장수 열전',
battleResult: '전투 결과',
};
const logColors: Record<LogType, string> = {
generalAction: 'skyblue',
battleDetail: 'orange',
generalHistory: 'skyblue',
battleResult: 'orange',
};
const logs = reactive<Record<LogType, Array<{ id: number; html: string }>>>({
generalHistory: [],
battleDetail: [],
battleResult: [],
generalAction: [],
});
const logLoading = reactive<Record<LogType, boolean>>({
generalHistory: false,
battleDetail: false,
battleResult: false,
generalAction: false,
});
const logHasMore = reactive<Record<LogType, boolean>>({
generalHistory: true,
battleDetail: true,
@@ -70,520 +73,593 @@ const logHasMore = reactive<Record<LogType, boolean>>({
generalAction: true,
});
const activeLogTab = ref<LogType>('generalAction');
const isMobile = useMediaQuery('(max-width: 1024px)');
const screenMode = ref<'auto' | '500px' | '1000px'>('auto');
const errorText = (value: unknown): string =>
value instanceof Error ? value.message : typeof value === 'string' ? value : 'unknown_error';
const resolveErrorMessage = (value: unknown): string => {
if (value instanceof Error) {
return value.message;
}
if (typeof value === 'string') {
return value;
}
return 'unknown_error';
const asRecord = (value: unknown): Record<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
const numberValue = (value: unknown, fallback: number): number => {
const parsed = typeof value === 'number' ? value : Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
};
const resolveNumber = (value: unknown, fallback: number): number => {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return fallback;
};
const statusLine = computed(() =>
world.value
? `${world.value.currentYear}${world.value.currentMonth}월 · ${Math.max(
1,
Math.round(world.value.tickSeconds / 60)
)}분 턴`
: '내 정보를 불러오는 중'
);
const statusLine = computed(() => {
if (!worldState.value) {
return '내 정보를 불러오는 중';
}
const turnTerm = resolveNumber((worldState.value.config as Record<string, unknown>)?.turnTermMinutes, 0);
const termLabel = turnTerm > 0 ? ` · 턴 ${turnTerm}` : '';
return `${worldState.value.currentYear}${worldState.value.currentMonth}${termLabel}`;
});
const itemSlots = computed<ItemSlot[]>(() => {
const items = data.value?.general?.items;
return [
{ key: 'horse', label: '말', code: items?.horse ?? null },
{ key: 'weapon', label: '무기', code: items?.weapon ?? null },
{ key: 'book', label: '서적', code: items?.book ?? null },
{ key: 'item', label: '아이템', code: items?.item ?? null },
];
});
const canSave = computed(() => (data.value?.settings.myset ?? 1) > 0);
const penalties = computed(() => Object.entries(data.value?.penalties ?? {}));
const items = computed<Array<{ key: ItemSlotKey; name: string; code: string | null }>>(() => [
{ key: 'horse', name: '말', code: data.value?.general.items.horse ?? null },
{ key: 'weapon', name: '무기', code: data.value?.general.items.weapon ?? null },
{ key: 'book', name: '서적', code: data.value?.general.items.book ?? null },
{ key: 'item', name: '도구', code: data.value?.general.items.item ?? null },
]);
const autorunUser = computed(() => asRecord(world.value?.meta.autorun_user));
const showAutoNationTurn = computed(() => Boolean(asRecord(autorunUser.value.options).chief));
const showVacation = computed(() => !autorunUser.value.limit_minutes);
const actionAvailability = computed(() => {
const general = data.value?.general;
const meta = (worldState.value?.meta ?? {}) as Record<string, unknown>;
const config = (worldState.value?.config ?? {}) as Record<string, unknown>;
const autorunUser = (meta.autorun_user ?? {}) as Record<string, unknown>;
const turntime = meta.turntime ? new Date(String(meta.turntime)) : null;
const opentime = meta.opentime ? new Date(String(meta.opentime)) : null;
const preopen = Boolean(turntime && opentime && turntime.getTime() <= opentime.getTime());
const npcMode = resolveNumber(config.npcMode, 0);
const meta = world.value?.meta ?? {};
const config = world.value?.config ?? {};
const constConfig = asRecord(config.const);
const availableInstantAction = asRecord(constConfig.availableInstantAction ?? config.availableInstantAction);
const turnTime = meta.turntime ? new Date(String(meta.turntime)) : null;
const openTime = meta.opentime ? new Date(String(meta.opentime)) : null;
const preopen = Boolean(turnTime && openTime && turnTime.getTime() <= openTime.getTime());
const npcMode = numberValue(config.npcMode ?? config.npcmode, 0);
return {
canDieOnPrestart: Boolean(preopen && general && general.npcState === 0 && general.nationId === 0),
canBuildNationCandidate: Boolean(preopen && general && general.nationId === 0),
canVacation: !(autorunUser.limit_minutes ?? false),
canInstantRetreat: Boolean(general && general.nationId > 0),
canSelectOtherGeneral: Boolean(npcMode === 2 && general && general.npcState === 0),
dieOnPrestart: Boolean(preopen && general?.npcState === 0 && general.nationId === 0),
buildNationCandidate: Boolean(preopen && general?.nationId === 0),
instantRetreat: Boolean(availableInstantAction.instantRetreat),
selectOtherGeneral: Boolean(npcMode === 2 && general?.npcState === 0),
};
});
const loadLogs = async () => {
if (!data.value?.general?.id) {
return;
const applyCustomCss = (text: string) => {
let style = document.getElementById('sammo-custom-css') as HTMLStyleElement | null;
if (!style) {
style = document.createElement('style');
style.id = 'sammo-custom-css';
document.head.appendChild(style);
}
await Promise.all(
logTypes.map((type) => loadLog(type))
);
style.textContent = text;
};
const loadLog = async (type: LogType, beforeId?: number) => {
if (logLoading[type]) {
return;
}
if (logLoading[type]) return;
logLoading[type] = true;
try {
const response = await trpc.general.getMyLog.query({ type, beforeId });
const formatted = response.logs.map((entry) => ({
id: entry.id,
html: formatLog(entry.text),
}));
if (beforeId) {
logs[type].push(...formatted);
} else {
logs[type] = formatted;
}
logHasMore[type] = formatted.length >= 24;
} catch (err) {
error.value = resolveErrorMessage(err);
const next = response.logs.map((entry) => ({ id: entry.id, html: formatLog(entry.text) }));
logs[type] = beforeId ? [...logs[type], ...next] : next;
logHasMore[type] = next.length >= 24;
} catch (cause) {
error.value = errorText(cause);
} finally {
logLoading[type] = false;
}
};
const loadMyPage = async () => {
if (loading.value) {
return;
}
const loadPage = async () => {
if (loading.value) return;
loading.value = true;
error.value = null;
try {
const general = await trpc.general.me.query();
const world = await (trpc.world.getState.query as unknown as () => Promise<WorldStateSnapshot>)();
const [general, state] = await Promise.all([
trpc.general.me.query(),
trpc.world.getState.query() as Promise<WorldSnapshot>,
]);
data.value = general;
worldState.value = world ?? null;
await loadLogs();
} catch (err) {
error.value = resolveErrorMessage(err);
world.value = state;
if (general) {
Object.assign(form, general.settings);
}
await Promise.all(logTypes.map((type) => loadLog(type)));
} catch (cause) {
error.value = errorText(cause);
} finally {
loading.value = false;
}
};
const confirmAction = async (message: string, action: () => Promise<void>) => {
if (!confirm(message)) {
return;
}
const saveSettings = async () => {
if (!canSave.value) return;
try {
await action();
await loadMyPage();
} catch (err) {
alert(`실패했습니다: ${resolveErrorMessage(err)}`);
await trpc.general.setMySetting.mutate({ ...form });
await loadPage();
} catch (cause) {
alert(`실패했습니다: ${errorText(cause)}`);
}
};
const handleDieOnPrestart = () =>
confirmAction('정말로 삭제하시겠습니까?', async () => {
await trpc.general.dieOnPrestart.mutate();
window.location.reload();
});
const handleBuildNationCandidate = () =>
confirmAction('거병 이후 장수를 삭제할 수 없습니다. 거병하시겠습니까?', async () => {
await trpc.general.buildNationCandidate.mutate();
});
const handleInstantRetreat = () =>
confirmAction('아군 접경으로 이동할까요?', async () => {
await trpc.general.instantRetreat.mutate();
});
const handleVacation = () =>
confirmAction('휴가 기능을 신청할까요?', async () => {
await trpc.general.vacation.mutate();
});
const handleDropItem = (slot: ItemSlot) =>
confirmAction(`${slot.label}(${slot.code ?? '-'})을(를) 파기하시겠습니까?`, async () => {
await trpc.general.dropItem.mutate({ itemType: slot.key });
});
const refreshScreenMode = () => {
if (typeof window === 'undefined') {
return;
}
const mode = window.localStorage.getItem(SCREEN_MODE_KEY);
if (mode === '500px' || mode === '1000px') {
screenMode.value = mode;
} else {
screenMode.value = 'auto';
const confirmMutation = async (message: string, mutation: () => Promise<unknown>) => {
if (!confirm(message)) return;
try {
await mutation();
await loadPage();
} catch (cause) {
alert(`실패했습니다: ${errorText(cause)}`);
}
};
watch(
() => isMobile.value,
(value) => {
if (value && !logTypes.includes(activeLogTab.value)) {
activeLogTab.value = 'generalAction';
}
}
);
const dropItem = (item: { key: ItemSlotKey; name: string; code: string | null }) =>
confirmMutation(`${item.code ?? item.name}을(를) 버리시겠습니까?`, () =>
trpc.general.dropItem.mutate({ itemType: item.key })
);
watch(screenMode, (mode) => {
localStorage.setItem(SCREEN_MODE_KEY, mode);
document.dispatchEvent(new CustomEvent('tryChangeScreenMode'));
});
watch(customCss, (text) => {
if (cssTimer !== null) window.clearTimeout(cssTimer);
cssSaving.value = true;
cssTimer = window.setTimeout(() => {
localStorage.setItem(CUSTOM_CSS_KEY, text);
applyCustomCss(text);
cssSaving.value = false;
}, 500);
});
onMounted(() => {
refreshScreenMode();
void loadMyPage();
const storedMode = localStorage.getItem(SCREEN_MODE_KEY);
screenMode.value = storedMode === '500px' || storedMode === '1000px' ? storedMode : 'auto';
customCss.value = localStorage.getItem(CUSTOM_CSS_KEY) ?? '';
applyCustomCss(customCss.value);
void loadPage();
});
</script>
<template>
<main class="my-page" :class="`screen-${screenMode}`">
<header class="page-header">
<div>
<h1 class="page-title"> 정보</h1>
<p class="page-subtitle">{{ statusLine }}</p>
</div>
<div class="header-actions">
<RouterLink class="ghost" to="/">메인</RouterLink>
<RouterLink class="ghost" to="/my-settings">게임 설정</RouterLink>
<button class="ghost" @click="loadMyPage">새로고침</button>
</div>
</header>
<main id="container" class="legacy-page bg0" :class="`screen-${screenMode}`">
<div class="title-row">
<span> </span>
<RouterLink class="legacy-button" to="/">돌아가기</RouterLink>
<button class="legacy-button" type="button" @click="loadPage">새로고침</button>
</div>
<div v-if="error" class="error">{{ error }}</div>
<div v-if="error" class="error-row">{{ error }}</div>
<div class="status-row">{{ statusLine }}</div>
<section class="layout-grid">
<div class="stack">
<PanelCard title="장수 상태">
<GeneralBasicCard :general="data?.general ?? null" :loading="loading" />
</PanelCard>
<PanelCard title="도시 상태">
<CityBasicCard :city="data?.city ?? null" :loading="loading" />
</PanelCard>
<PanelCard title="세력 상태">
<NationBasicCard :nation="data?.nation ?? null" :loading="loading" />
</PanelCard>
</div>
<div class="stack">
<PanelCard title="장수 상태 변경" subtitle="중요 액션은 확인 후 실행됩니다.">
<div class="action-grid">
<button
v-if="actionAvailability.canVacation"
class="action-btn"
type="button"
@click="handleVacation"
>
휴가 신청
</button>
<button
v-if="actionAvailability.canDieOnPrestart"
class="action-btn"
type="button"
@click="handleDieOnPrestart"
>
장수 삭제
</button>
<button
v-if="actionAvailability.canBuildNationCandidate"
class="action-btn"
type="button"
@click="handleBuildNationCandidate"
>
사전 거병
</button>
<button
v-if="actionAvailability.canInstantRetreat"
class="action-btn"
type="button"
@click="handleInstantRetreat"
>
접경 귀환
</button>
<RouterLink
v-if="actionAvailability.canSelectOtherGeneral"
class="action-btn link"
to="/join"
>
다른 장수 선택
</RouterLink>
<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">
<img
:src="
data.general.picture ? `/image/game/${data.general.picture}` : '/image/game/default.jpg'
"
alt=""
/>
<strong>{{ data.general.name }}</strong>
</div>
</PanelCard>
<PanelCard title="아이템 파기" subtitle="소지 중인 장비를 선택합니다.">
<div class="item-grid">
<button
v-for="slot in itemSlots"
:key="slot.key"
class="item-btn"
type="button"
:disabled="!slot.code"
@click="handleDropItem(slot)"
>
{{ slot.label }}: {{ slot.code ?? '-' }}
</button>
</div>
</PanelCard>
<PanelCard v-if="isMobile" title="장수 기록">
<div class="log-tabs">
<button
v-for="type in logTypes"
:key="type"
:class="{ active: activeLogTab === type }"
@click="activeLogTab = type"
>
{{ logLabels[type] }}
</button>
</div>
<div class="log-block">
<div class="log-title">{{ logLabels[activeLogTab] }}</div>
<SkeletonLines v-if="loading || logLoading[activeLogTab]" :lines="4" />
<!-- eslint-disable vue/no-v-html -->
<template v-else>
<div v-if="logs[activeLogTab].length === 0" class="empty">기록이 없습니다.</div>
<div
v-for="entry in logs[activeLogTab]"
:key="entry.id"
class="log-line"
v-html="entry.html"
/>
<button
v-if="logHasMore[activeLogTab]"
class="ghost log-more"
@click="loadLog(activeLogTab, logs[activeLogTab].at(-1)?.id)"
>
이전 로그 불러오기
</button>
</template>
<!-- eslint-enable vue/no-v-html -->
</div>
</PanelCard>
<PanelCard v-else title="장수 기록" subtitle="개인 기록 및 전투 로그">
<div class="log-grid">
<div v-for="type in logTypes" :key="type" class="log-block">
<div class="log-title">{{ logLabels[type] }}</div>
<SkeletonLines v-if="loading || logLoading[type]" :lines="3" />
<!-- eslint-disable vue/no-v-html -->
<template v-else>
<div v-if="logs[type].length === 0" class="empty">기록이 없습니다.</div>
<div v-for="entry in logs[type]" :key="entry.id" class="log-line" v-html="entry.html" />
<button
v-if="logHasMore[type]"
class="ghost log-more"
@click="loadLog(type, logs[type].at(-1)?.id)"
>
이전 로그 불러오기
</button>
</template>
<!-- eslint-enable vue/no-v-html -->
<dl>
<div>
<dt>통솔</dt>
<dd>{{ data.general.stats.leadership }}</dd>
</div>
</div>
</PanelCard>
<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>
</dl>
</div>
</div>
<div class="settings-column">
<div class="setting-line">
토너먼트
<label><input v-model.number="form.tnmt" type="radio" :value="0" />수동참여</label>
<label><input v-model.number="form.tnmt" type="radio" :value="1" />자동참여</label>
</div>
<div class="hint"> 개막직전 남는자리가 있을경우 랜덤하게 참여합니다.</div>
<label class="setting-line">
환약 사용
<select v-model.number="form.use_treatment">
<option :value="10">경상</option>
<option :value="21">중상</option>
<option :value="41">심각</option>
<option :value="61">위독</option>
<option :value="100">사용안함</option>
</select>
</label>
<div class="hint"> 부상을 입었을 환약을 사용하는 기준입니다.</div>
<label v-if="showAutoNationTurn" class="setting-line">
자동 사령턴 허용
<select v-model.number="form.use_auto_nation_turn">
<option :value="1">허용</option>
<option :value="0">허용 안함</option>
</select>
</label>
<label class="setting-line">
수비
<select v-model.number="form.defence_train">
<option :value="90">수비 (훈사90)</option>
<option :value="80">수비 (훈사80)</option>
<option :value="60">수비 (훈사60)</option>
<option :value="40">수비 (훈사40)</option>
<option :value="999">수비 안함 [훈련 -3, 사기 -6]</option>
</select>
</label>
<button
id="set_my_setting"
class="action-button"
type="button"
:hidden="!canSave"
@click="saveSettings"
>
설정저장
</button>
<div class="hint"> 설정저장은 이달중 {{ data?.settings.myset ?? 0 }} 남았습니다.</div>
<div v-if="penalties.length" class="penalties">
징계 목록(저장 갱신)
<div v-for="[key, value] in penalties" :key="key">{{ key }} : {{ value }}</div>
</div>
<div v-if="showVacation" class="action-line">
<br />
<button
class="action-button"
type="button"
@click="confirmMutation('휴가 기능을 신청할까요?', () => trpc.general.vacation.mutate())"
>
휴가 신청
</button>
</div>
<div v-if="actionAvailability.dieOnPrestart" class="action-line">
가오픈 기간 장수 삭제<br />
<button
class="action-button"
@click="confirmMutation('정말로 삭제하시겠습니까?', () => trpc.general.dieOnPrestart.mutate())"
>
장수 삭제
</button>
</div>
<div v-if="actionAvailability.buildNationCandidate" class="action-line">
서버 개시 이전 거병(2턴부터 건국 가능)<br />
<button
class="action-button"
@click="
confirmMutation('거병 이후 장수를 삭제할 수 없게됩니다. 거병하시겠습니까?', () =>
trpc.general.buildNationCandidate.mutate()
)
"
>
사전 거병
</button>
</div>
<div v-if="actionAvailability.instantRetreat" class="action-line">
거리 3칸 이내 아국 도시로 즉시 이동<br />
<button
class="action-button"
@click="
confirmMutation('아군 접경으로 이동할까요?', () => trpc.general.instantRetreat.mutate())
"
>
접경 귀환
</button>
</div>
<div class="screen-mode-row">
<span>500px/1000px 모드<br />(모바일 전용, 즉시 설정)</span>
<div class="button-group">
<label><input v-model="screenMode" type="radio" value="auto" />자동</label>
<label><input v-model="screenMode" type="radio" value="500px" />500px</label>
<label><input v-model="screenMode" type="radio" value="1000px" />1000px</label>
</div>
</div>
<div class="item-title">아이템 파기</div>
<div class="item-group">
<button
v-for="item in items"
:key="item.key"
type="button"
:disabled="!item.code"
@click="dropItem(item)"
>
{{ item.code ?? '-' }}
</button>
</div>
<label class="custom-css">
개인용 CSS <span>{{ cssSaving ? '(저장 중)' : '' }}</span>
<textarea id="custom_css" v-model="customCss" />
</label>
</div>
</section>
<section class="log-grid">
<article v-for="type in logTypes" :key="type" class="log-panel">
<h2 :style="{ color: logColors[type] }">{{ logLabels[type] }}</h2>
<div v-if="logLoading[type]" class="loading">불러오는 중...</div>
<div v-else>
<div v-for="entry in logs[type]" :key="entry.id" class="log-line" v-html="entry.html" />
<div v-if="logs[type].length === 0" class="empty">기록이 없습니다.</div>
<button
v-if="logHasMore[type]"
class="load-old"
type="button"
@click="loadLog(type, logs[type].at(-1)?.id)"
>
이전 로그 불러오기
</button>
</div>
</article>
</section>
</main>
</template>
<style scoped>
.my-page {
.legacy-page {
width: 100%;
max-width: 1000px;
min-width: 500px;
min-height: 100vh;
padding: 24px;
display: flex;
flex-direction: column;
gap: 24px;
transition: width 0.2s ease;
margin: 0 auto;
padding: 0;
color: #fff;
background-color: #111;
background-image: url('/image/game/back_walnut.jpg');
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
font-size: 14px;
line-height: 1.3;
}
.my-page.screen-500px {
.legacy-page.screen-500px {
max-width: 500px;
}
.my-page.screen-1000px {
.legacy-page.screen-1000px {
max-width: 1000px;
}
.page-header {
.title-row {
height: 54px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
align-content: flex-start;
align-items: flex-start;
justify-content: flex-start;
flex-wrap: wrap;
gap: 0 4px;
border: 1px solid #666;
background: transparent;
font-size: 14px;
}
.page-title {
font-size: 1.6rem;
.title-row > span {
flex-basis: 100%;
height: 18px;
letter-spacing: 0;
}
.legacy-button,
button,
select,
textarea {
border: 1px solid #777;
border-radius: 0;
color: #fff;
background: #6b6b6b;
font: inherit;
}
.legacy-button {
min-height: 34px;
padding: 5px 10px;
border-color: #2d5d7f;
border-radius: 4px;
background: #315f86;
color: #fff;
font-weight: 700;
text-decoration: none;
letter-spacing: 0;
}
.page-subtitle {
color: rgba(232, 221, 196, 0.7);
margin-top: 6px;
}
.header-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.layout-grid {
display: grid;
grid-template-columns: minmax(0, 320px) minmax(0, 1fr);
gap: 18px;
}
.stack {
display: flex;
flex-direction: column;
gap: 18px;
}
.action-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 10px;
}
.action-btn {
border: 1px solid rgba(201, 164, 90, 0.5);
background: rgba(12, 12, 12, 0.6);
color: inherit;
padding: 8px 12px;
font-size: 0.85rem;
button {
cursor: pointer;
}
button:disabled {
cursor: default;
opacity: 0.45;
}
.status-row,
.error-row {
padding: 4px 8px;
text-align: center;
}
.action-btn.link {
display: inline-flex;
align-items: center;
justify-content: center;
.error-row {
color: #ff7777;
border: 1px solid #a33;
}
.item-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 8px;
}
.item-btn {
border: 1px solid rgba(201, 164, 90, 0.4);
background: rgba(12, 12, 12, 0.6);
color: inherit;
padding: 8px 10px;
font-size: 0.82rem;
cursor: pointer;
text-align: left;
}
.item-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.top-grid,
.log-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.log-block {
border: 1px solid rgba(201, 164, 90, 0.3);
padding: 8px;
background: rgba(12, 12, 12, 0.6);
min-height: 160px;
.general-column,
.settings-column,
.log-panel {
border: 1px solid #666;
background-image: url('/image/game/back_walnut.jpg');
}
.log-title {
font-weight: 600;
margin-bottom: 6px;
font-size: 0.9rem;
.section-title,
.log-panel h2 {
min-height: 34px;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
border-bottom: 1px solid #666;
background-color: #14241b;
background-image: url('/image/game/back_green.jpg');
font-size: 1.25em;
font-weight: 500;
}
.log-line {
padding: 4px 0;
border-bottom: 1px dashed rgba(201, 164, 90, 0.2);
.sky {
color: skyblue;
}
.log-line:last-child {
border-bottom: none;
.general-table {
display: grid;
grid-template-columns: 150px 1fr;
padding: 0;
background-color: #172a52;
background-image: url('/image/game/back_blue.jpg');
}
.log-more {
.portrait-cell {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 10px;
border-right: 1px solid #777;
}
.portrait-cell img {
width: 64px;
height: 64px;
object-fit: cover;
}
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;
}
.setting-line {
display: block;
margin-top: 5px;
}
.hint {
margin: 0 0 13px;
color: orange;
}
.action-button {
width: 160px;
height: 30px;
margin: 4px 0;
background: #225500;
}
.action-line {
margin: 12px 0;
}
.penalties {
margin: 12px 0;
color: #f66;
}
.screen-mode-row {
display: grid;
grid-template-columns: 160px 1fr;
align-items: center;
margin: 14px 0;
}
.button-group {
display: flex;
}
.button-group label {
padding: 5px 8px;
border: 1px solid #666;
background: #26384d;
}
.button-group input {
margin-right: 4px;
}
.item-title {
margin-top: 12px;
}
.item-group {
display: grid;
grid-template-columns: repeat(4, 1fr);
margin: 5px 0 14px;
}
.item-group button {
min-height: 30px;
}
.custom-css {
display: block;
}
.custom-css textarea {
display: block;
width: 420px;
max-width: 100%;
height: 150px;
color: #fff;
background: #000;
}
.log-panel {
min-height: 180px;
}
.log-panel h2 {
color: orange;
}
.log-line,
.empty,
.loading {
padding: 2px 8px;
}
.load-old {
width: 100%;
min-height: 32px;
margin-top: 8px;
}
.log-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.log-tabs button {
border: 1px solid rgba(201, 164, 90, 0.4);
background: transparent;
color: inherit;
padding: 6px 10px;
font-size: 0.8rem;
cursor: pointer;
}
.log-tabs button.active {
background: rgba(201, 164, 90, 0.2);
}
.ghost {
border: 1px solid rgba(201, 164, 90, 0.5);
background: transparent;
color: inherit;
padding: 6px 10px;
font-size: 0.8rem;
cursor: pointer;
}
.empty {
color: rgba(232, 221, 196, 0.6);
font-size: 0.85rem;
}
.error {
color: #f08a5d;
font-size: 0.9rem;
}
@media (max-width: 1024px) {
.layout-grid {
grid-template-columns: 1fr;
@media (max-width: 991px) {
.legacy-page {
width: 500px;
}
.top-grid,
.log-grid {
grid-template-columns: 1fr;
}
+131 -60
View File
@@ -73,9 +73,7 @@ const listYearMonth = computed(() => {
});
const listItems = computed(() =>
list.value
? Object.values(list.value.bettingList).sort((left, right) => right.id - left.id)
: []
list.value ? Object.values(list.value.bettingList).sort((left, right) => right.id - left.id) : []
);
const info = computed(() => detail.value?.bettingInfo ?? null);
@@ -112,9 +110,7 @@ const detailRows = computed(() =>
const myBetMap = computed(() => new Map(detail.value?.myBetting ?? []));
const totalAmount = computed(() =>
(detail.value?.bettingDetail ?? []).reduce((sum, [, value]) => sum + value, 0)
);
const totalAmount = computed(() => (detail.value?.bettingDetail ?? []).reduce((sum, [, value]) => sum + value, 0));
const pureAmount = computed(() =>
(detail.value?.bettingDetail ?? []).reduce(
@@ -137,9 +133,7 @@ const candidateAmounts = computed(() => {
return result;
});
const usedAmount = computed(() =>
Array.from(myBetMap.value.values()).reduce((sum, value) => sum + value, 0)
);
const usedAmount = computed(() => Array.from(myBetMap.value.values()).reduce((sum, value) => sum + value, 0));
const selectedKey = computed(() => JSON.stringify([...selectedCandidates.value].sort((a, b) => a - b)));
@@ -150,10 +144,7 @@ const getErrorMessage = (error: unknown): string => {
return typeof error === 'string' ? error : '요청을 처리하지 못했습니다.';
};
const parseYearMonth = (yearMonth: number): [number, number] => [
Math.floor(yearMonth / 12),
(yearMonth % 12) + 1,
];
const parseYearMonth = (yearMonth: number): [number, number] => [Math.floor(yearMonth / 12), (yearMonth % 12) + 1];
const readSelection = (value: string): number[] => {
try {
@@ -171,8 +162,7 @@ const selectionLabel = (value: string): string =>
.map((index) => candidates.value[index]?.title ?? '-')
.join(', ');
const isListOpen = (item: BettingListItem): boolean =>
!item.finished && listYearMonth.value <= item.closeYearMonth;
const isListOpen = (item: BettingListItem): boolean => !item.finished && listYearMonth.value <= item.closeYearMonth;
const isDetailOpen = computed(() =>
Boolean(info.value && !info.value.finished && currentYearMonth.value <= info.value.closeYearMonth)
@@ -192,19 +182,72 @@ const rowColor = (key: string): string => {
return matched === 0 ? 'red' : matched < info.value.selectCnt ? 'yellow' : 'green';
};
const expectedMultiplier = (key: string, betAmount: number): string => {
if (betAmount <= 0) {
return '0.0';
const rewardByMatch = computed(() => {
const selectCount = info.value?.selectCnt ?? 0;
const rewards = new Array<number>(selectCount + 1).fill(0);
if (selectCount <= 0) {
return rewards;
}
const amountByMatch = new Map<number, number>();
for (const [key, betAmount] of detailRows.value) {
const matched = matchCount(key);
amountByMatch.set(matched, (amountByMatch.get(matched) ?? 0) + betAmount);
}
if (selectCount === 1 || info.value?.isExclusive) {
rewards[selectCount] = totalAmount.value;
return rewards;
}
let remainingReward = totalAmount.value;
let accumulatedReward = 0;
let nextReward = totalAmount.value;
for (let matched = selectCount; matched > 0; matched -= 1) {
nextReward /= 2;
accumulatedReward += nextReward;
if (!amountByMatch.has(matched)) {
continue;
}
rewards[matched] = accumulatedReward;
remainingReward -= accumulatedReward;
accumulatedReward = 0;
}
for (let matched = selectCount; matched >= 0; matched -= 1) {
if (!amountByMatch.has(matched)) {
continue;
}
rewards[matched] += remainingReward;
break;
}
return rewards;
});
const expectedReward = (key: string): number => {
if (!info.value?.finished) {
const reward = info.value?.isExclusive || info.value?.selectCnt === 1 ? totalAmount.value : totalAmount.value / 2;
return (reward / betAmount).toFixed(1);
return info.value?.isExclusive || info.value?.selectCnt === 1 ? totalAmount.value : totalAmount.value / 2;
}
const matched = matchCount(key);
const matchedAmount = detailRows.value
.filter(([candidateKey]) => matchCount(candidateKey) === matched)
.reduce((sum, [, value]) => sum + value, 0);
return matchedAmount > 0 ? (totalAmount.value / matchedAmount).toFixed(1) : '0.0';
return rewardByMatch.value[matchCount(key)] ?? 0;
};
const rewardDivisor = (key: string, betAmount: number): number =>
info.value?.finished
? detailRows.value
.filter(([candidateKey]) => matchCount(candidateKey) === matchCount(key))
.reduce((sum, [, value]) => sum + value, 0)
: betAmount;
const expectedMultiplier = (key: string, betAmount: number): string => {
const divisor = rewardDivisor(key, betAmount);
return divisor > 0 ? (expectedReward(key) / divisor).toFixed(1) : '0.0';
};
const myExpectedReward = (key: string, betAmount: number): string => {
const myAmount = myBetMap.value.get(key);
if (myAmount === undefined) {
return '';
}
const divisor = rewardDivisor(key, betAmount);
const reward = divisor > 0 ? (myAmount * expectedReward(key)) / divisor : 0;
return `(${myAmount.toLocaleString('ko-KR')} -> ${reward.toFixed(1)})`;
};
const loadList = async () => {
@@ -295,7 +338,7 @@ onMounted(() => {
<main id="nation-betting-container" class="nation-betting-page legacy-bg0">
<header class="legacy-top-bar">
<RouterLink class="legacy-nav-button" to="/">돌아가기</RouterLink>
<button class="legacy-nav-button" type="button" :disabled="loadingList" @click="loadList">갱신</button>
<div></div>
<h1>국가 베팅장</h1>
<div></div>
<div></div>
@@ -309,32 +352,37 @@ onMounted(() => {
{{ info.name }}
<span v-if="info.finished">(종료)</span>
<span v-else-if="currentYearMonth <= info.closeYearMonth">
({{ parseYearMonth(info.closeYearMonth)[0] }}
{{ parseYearMonth(info.closeYearMonth)[1] }}월까지)
({{ parseYearMonth(info.closeYearMonth)[0] }} {{ parseYearMonth(info.closeYearMonth)[1] }}월까지)
</span>
<span v-else>(베팅 마감)</span>
(총액: {{ totalAmount.toLocaleString('ko-KR') }})
</div>
<div class="betting-candidates">
<button
<div
v-for="(candidate, index) in candidates"
:key="`${info.id}-${index}`"
type="button"
class="betting-candidate"
:class="{ picked: selectedCandidates.includes(index) || (info.finished && winner.has(index)) }"
:disabled="!isDetailOpen"
@click="toggleCandidate(index)"
class="betting-candidate-cell"
>
<span class="candidate-title legacy-bg1">{{ candidate.title }}</span>
<span class="candidate-info">
<span v-for="line in candidate.info.split('<br>')" :key="line">{{ line }}</span>
</span>
<span class="candidate-rate">
선택율:
{{ (((candidateAmounts.get(index) ?? 0) / Math.max(1, pureAmount)) * 100).toFixed(1) }}%
</span>
</button>
<button
type="button"
class="betting-candidate"
:class="{
picked: selectedCandidates.includes(index) || (info.finished && winner.has(index)),
}"
:disabled="!isDetailOpen"
@click="toggleCandidate(index)"
>
<span class="candidate-title legacy-bg1">{{ candidate.title }}</span>
<span class="candidate-info">
<span v-for="line in candidate.info.split('<br>')" :key="line">{{ line }}</span>
</span>
<span class="candidate-rate">
선택율:
{{ (((candidateAmounts.get(index) ?? 0) / Math.max(1, pureAmount)) * 100).toFixed(1) }}%
</span>
</button>
</div>
</div>
<form v-if="isDetailOpen" class="betting-form" @submit.prevent="submitBet">
@@ -361,7 +409,7 @@ onMounted(() => {
{{ selectionLabel(key) }}
</div>
<div>{{ betAmount.toLocaleString('ko-KR') }}</div>
<div>{{ myBetMap.get(key)?.toLocaleString('ko-KR') ?? '' }}</div>
<div>{{ myExpectedReward(key, betAmount) }}</div>
<div>{{ expectedMultiplier(key, betAmount) }}</div>
</div>
</div>
@@ -382,8 +430,7 @@ onMounted(() => {
{{ item.name }}
<span v-if="item.finished">(종료)</span>
<span v-else-if="isListOpen(item)">
({{ parseYearMonth(item.closeYearMonth)[0] }}
{{ parseYearMonth(item.closeYearMonth)[1] }}월까지)
({{ parseYearMonth(item.closeYearMonth)[0] }} {{ parseYearMonth(item.closeYearMonth)[1] }}월까지)
</span>
<span v-else>(베팅 마감)</span>
</button>
@@ -400,12 +447,11 @@ onMounted(() => {
.nation-betting-page {
position: relative;
width: 500px;
min-height: 100vh;
margin: 0 auto;
color: #fff;
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic';
font-size: 14px;
line-height: 1.3;
line-height: 1.5;
overflow-x: hidden;
}
@@ -458,19 +504,32 @@ onMounted(() => {
}
.section-title {
min-height: 22px;
min-height: 21px;
text-align: center;
line-height: 22px;
line-height: 21px;
}
.betting-candidates {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 4px;
padding: 4px;
display: flex;
flex-wrap: wrap;
margin-top: -3.5px;
margin-right: -1.75px;
margin-left: -1.75px;
}
.betting-candidate-cell {
flex: 0 0 auto;
width: 33.33333333%;
max-width: 100%;
padding-right: 1.75px;
padding-left: 1.75px;
margin-top: 3.5px;
}
.betting-candidate {
width: 100%;
height: 100%;
min-height: 143px;
min-width: 0;
padding: 0;
border: 1px solid gray;
@@ -530,7 +589,7 @@ onMounted(() => {
.betting-form input {
grid-column: span 4;
min-width: 0;
height: 30px;
height: 35.5px;
border: 1px solid #777;
background: #ddd;
color: #303030;
@@ -538,10 +597,11 @@ onMounted(() => {
.betting-form button {
grid-column: span 2;
height: 35.5px;
}
.payout-table {
margin-top: 6px;
margin-top: 0;
}
.payout-row {
@@ -551,7 +611,7 @@ onMounted(() => {
.payout-row > div {
min-width: 0;
padding: 2px 4px;
padding: 0 4px;
}
.payout-row > div:not(:first-child) {
@@ -572,7 +632,7 @@ onMounted(() => {
.betting-item {
display: block;
width: 100%;
width: auto;
margin: 0.25em;
border: 0;
background: transparent;
@@ -595,6 +655,7 @@ onMounted(() => {
.betting-footer .legacy-nav-button {
width: 90px;
height: 35.5px;
}
.betting-notice,
@@ -602,6 +663,15 @@ onMounted(() => {
padding: 6px 10px;
}
.betting-notice {
position: fixed;
top: 8px;
right: 8px;
z-index: 20;
width: min(320px, calc(100vw - 16px));
background: #303030;
}
.betting-notice.error {
border: 1px solid #9b4848;
color: #ffd0d0;
@@ -617,8 +687,9 @@ onMounted(() => {
width: 1000px;
}
.betting-candidates {
grid-template-columns: repeat(6, minmax(0, 1fr));
.betting-candidate-cell {
/* Legacy Bootstrap switches .col-4 to .col-lg-2 at 940px. */
width: 16.66666667%;
}
.betting-form {
+199 -322
View File
@@ -1,349 +1,226 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import { trpc } from '../utils/trpc';
import { formatOfficerLevelText } from '../utils/nationFormat';
import { trpc } from '../utils/trpc';
type GeneralListResponse = Awaited<ReturnType<typeof trpc.nation.getGeneralList.query>>;
type GeneralEntry = GeneralListResponse['generals'][number];
type SortKey =
| 1
| 2
| 3
| 4
| 5
| 6
| 7
| 8
| 9
| 10
| 11
| 12
| 13
| 14
| 15;
const sortOptions: Array<{ key: SortKey; label: string }> = [
{ key: 1, label: '관직' },
{ key: 2, label: '공헌' },
{ key: 3, label: '경험' },
{ key: 4, label: '통솔' },
{ key: 5, label: '무력' },
{ key: 6, label: '지력' },
{ key: 7, label: '자금' },
{ key: 8, label: '군량' },
{ key: 9, label: '병사' },
{ key: 10, label: '벌점' },
{ key: 11, label: '성격' },
{ key: 12, label: '내특' },
{ key: 13, label: '전특' },
{ key: 14, label: '사관' },
{ key: 15, label: 'NPC' },
];
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;
const data = ref<Result | null>(null);
const error = ref('');
const loading = ref(false);
const error = ref<string | null>(null);
const data = ref<GeneralListResponse | null>(null);
const sortKey = ref<SortKey>(1);
const filterText = ref('');
const resolveErrorMessage = (value: unknown): string => {
if (value instanceof Error) {
return value.message;
}
if (typeof value === 'string') {
return value;
}
return 'unknown_error';
};
const loadGenerals = async () => {
if (loading.value) {
return;
}
const sort = ref<Sort>(1);
const options = [
'관직',
'계급',
'명성',
'통솔',
'무력',
'지력',
'자금',
'군량',
'병사',
'벌점',
'성격',
'내특',
'전특',
'사관',
'NPC',
];
const visibleCrew = (general: General): number | null => ('crew' in general ? general.crew : null);
const load = async () => {
loading.value = true;
error.value = null;
error.value = '';
try {
data.value = await trpc.nation.getGeneralList.query();
} catch (err) {
error.value = resolveErrorMessage(err);
} catch (cause) {
error.value = cause instanceof Error ? cause.message : '세력 장수를 불러오지 못했습니다.';
} finally {
loading.value = false;
}
};
const sortGenerals = (list: GeneralEntry[]): GeneralEntry[] => {
const key = sortKey.value;
const sorted = [...list].sort((lhs, rhs) => {
switch (key) {
case 1:
return rhs.officerLevel - lhs.officerLevel;
case 2:
return rhs.dedication - lhs.dedication;
case 3:
return rhs.experience - lhs.experience;
case 4:
return rhs.stats.leadership - lhs.stats.leadership;
case 5:
return rhs.stats.strength - lhs.stats.strength;
case 6:
return rhs.stats.intelligence - lhs.stats.intelligence;
case 7:
return rhs.gold - lhs.gold;
case 8:
return rhs.rice - lhs.rice;
case 9:
return rhs.crew - lhs.crew;
case 10:
return 0;
case 11:
return (lhs.personality?.name ?? '').localeCompare(rhs.personality?.name ?? '');
case 12:
return (lhs.specialDomestic?.name ?? '').localeCompare(rhs.specialDomestic?.name ?? '');
case 13:
return (lhs.specialWar?.name ?? '').localeCompare(rhs.specialWar?.name ?? '');
case 14:
return rhs.belong - lhs.belong;
case 15:
return rhs.npcState - lhs.npcState;
default:
return 0;
}
});
if (key === 11 || key === 12 || key === 13) {
return sorted;
}
return sorted;
};
const filteredGenerals = computed(() => {
const list = data.value?.generals ?? [];
const keyword = filterText.value.trim().toLowerCase();
const filtered = keyword
? list.filter((general) => {
return (
general.name.toLowerCase().includes(keyword) ||
(general.cityName ?? '').toLowerCase().includes(keyword) ||
(general.officerCityName ?? '').toLowerCase().includes(keyword)
);
})
: list;
return sortGenerals(filtered);
});
const nationLevel = computed(() => data.value?.nation.level ?? 0);
const formatSpecial = (general: GeneralEntry): string => {
const domestic = general.specialDomestic?.name ?? '-';
const war = general.specialWar?.name ?? '-';
return `${domestic} / ${war}`;
};
onMounted(() => {
void loadGenerals();
});
const generals = computed(() =>
[...(data.value?.generals ?? [])].sort((a, b) => {
if (sort.value === 1) return 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 special = (general: General) => `${general.specialDomestic?.name ?? '-'} / ${general.specialWar?.name ?? '-'}`;
onMounted(load);
</script>
<template>
<main class="nation-page">
<header class="page-header">
<div>
<h1 class="page-title">세력 장수</h1>
<p class="page-subtitle">세력 장수 현황 정렬</p>
</div>
<div class="header-actions">
<RouterLink class="ghost" to="/">메인</RouterLink>
<RouterLink class="ghost" to="/nation/cities">세력 도시</RouterLink>
<RouterLink class="ghost" to="/nation/personnel">인사부</RouterLink>
<button class="ghost" @click="loadGenerals">새로고침</button>
</div>
<main class="general-page legacy-bg0">
<header>
<strong>세력 장수</strong>
<span
><RouterLink to="/">돌아가기</RouterLink>
<button :disabled="loading" @click="load">새로고침</button></span
>
</header>
<div v-if="error" class="error">{{ error }}</div>
<PanelCard title="세력 장수 목록" subtitle="국가 소속 장수들을 확인합니다.">
<template #actions>
<div class="toolbar-actions">
<select v-model.number="sortKey" class="select-input">
<option v-for="option in sortOptions" :key="option.key" :value="option.key">
{{ option.label }}
</option>
</select>
<input v-model="filterText" class="filter-input" placeholder="이름/도시 검색" />
</div>
</template>
<div class="list-meta"> {{ filteredGenerals.length }}</div>
<SkeletonLines v-if="loading" :lines="6" />
<div v-else class="table-scroll">
<table class="nation-table">
<thead>
<tr>
<th>이름</th>
<th>관직</th>
<th>공헌</th>
<th>경험</th>
<th>통솔</th>
<th>무력</th>
<th>지력</th>
<th>자금</th>
<th>군량</th>
<th>병사</th>
<th>성격</th>
<th>특기</th>
<th>사관</th>
<th>현재 도시</th>
<th>관직 도시</th>
</tr>
</thead>
<tbody>
<tr v-for="general in filteredGenerals" :key="general.id">
<td>
<span v-if="general.npcState > 0" class="npc-tag">NPC</span>
{{ general.name }}
</td>
<td>{{ formatOfficerLevelText(general.officerLevel, nationLevel) }}</td>
<td>{{ general.dedication }}</td>
<td>{{ general.experience }}</td>
<td>{{ general.stats.leadership }}</td>
<td>{{ general.stats.strength }}</td>
<td>{{ general.stats.intelligence }}</td>
<td>{{ general.gold }}</td>
<td>{{ general.rice }}</td>
<td>{{ general.crew }}</td>
<td>{{ general.personality?.name ?? '-' }}</td>
<td>{{ formatSpecial(general) }}</td>
<td>{{ general.belong > 0 ? general.belong : '-' }}</td>
<td>{{ general.cityName ?? '-' }}</td>
<td>{{ general.officerCityName ?? '-' }}</td>
</tr>
</tbody>
</table>
</div>
</PanelCard>
<section class="sort">
정렬순서 :
<select v-model.number="sort" aria-label="세력 장수 정렬">
<option v-for="(label, index) in options" :key="label" :value="index + 1">{{ label }}</option>
</select>
<button>정렬하기</button>
<small v-if="data">열람 등급 {{ data.viewer.permission }}</small>
</section>
<p v-if="error" class="state error" role="alert">{{ error }}</p>
<p v-else-if="loading" class="state">불러오는 중...</p>
<div v-else class="scroll">
<table id="nation-general-list">
<thead>
<tr>
<th> </th>
<th> </th>
<th>통무지</th>
<th>명성/계급</th>
<th>자금</th>
<th>군량</th>
<th>도시</th>
<th>부대</th>
<th>병사</th>
<th>성격</th>
<th>특기</th>
<th>사관</th>
<th>벌점</th>
</tr>
</thead>
<tbody>
<tr v-for="general in generals" :key="general.id">
<td :class="`npc-${general.npcState}`">{{ general.name }}</td>
<td>{{ formatOfficerLevelText(general.officerLevel, data?.nation.level) }}</td>
<td>
{{ general.stats.leadership }}{{ general.stats.strength }}{{ general.stats.intelligence }}
</td>
<td>
Lv {{ general.experienceLevel }}<br />{{
general.dedicationLevel ? `${11 - general.dedicationLevel}품관` : '무품관'
}}
</td>
<td>{{ general.gold.toLocaleString() }}</td>
<td>{{ general.rice.toLocaleString() }}</td>
<td>{{ general.cityName ?? '?' }}</td>
<td>{{ general.troopName ?? '?' }}</td>
<td>{{ visibleCrew(general)?.toLocaleString() ?? '?' }}</td>
<td :title="general.personality?.info ?? ''">{{ general.personality?.name ?? '-' }}</td>
<td
:title="
[general.specialDomestic?.info, general.specialWar?.info].filter(Boolean).join('\n')
"
>
{{ special(general) }}
</td>
<td>{{ general.belong }}</td>
<td>{{ general.refreshScoreTotal }}</td>
</tr>
</tbody>
</table>
</div>
<footer><RouterLink to="/">돌아가기</RouterLink></footer>
</main>
</template>
<style scoped>
.nation-page {
.general-page {
width: 1000px;
min-height: 100vh;
padding: 24px;
margin: 8px auto 0;
font:
16px 'Times New Roman',
serif;
color: #fff;
}
header,
.sort,
footer,
.state {
position: relative;
border: 1px solid #777;
padding: 4px;
text-align: center;
}
header {
min-height: 39px;
display: flex;
flex-direction: column;
gap: 16px;
}
.page-header {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid rgba(201, 164, 90, 0.4);
padding-bottom: 12px;
}
.page-title {
font-size: 1.6rem;
font-weight: 600;
}
.page-subtitle {
font-size: 0.85rem;
color: rgba(232, 221, 196, 0.7);
}
.header-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.ghost {
border: 1px solid rgba(201, 164, 90, 0.4);
padding: 6px 12px;
font-size: 0.8rem;
cursor: pointer;
text-decoration: none;
color: inherit;
background: rgba(16, 16, 16, 0.6);
}
.error {
color: #f5b7b1;
font-size: 0.85rem;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 8px;
}
.select-input {
border: 1px solid rgba(201, 164, 90, 0.4);
background: rgba(16, 16, 16, 0.8);
color: rgba(232, 221, 196, 0.9);
padding: 6px 8px;
font-size: 0.75rem;
}
.filter-input {
border: 1px solid rgba(201, 164, 90, 0.4);
background: rgba(16, 16, 16, 0.8);
color: rgba(232, 221, 196, 0.9);
padding: 6px 8px;
font-size: 0.75rem;
}
.list-meta {
margin-bottom: 8px;
font-size: 0.75rem;
color: rgba(232, 221, 196, 0.6);
}
.table-scroll {
overflow-x: auto;
max-height: 70vh;
overflow-y: auto;
}
.nation-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
}
.nation-table th,
.nation-table td {
padding: 6px 8px;
border-bottom: 1px solid rgba(201, 164, 90, 0.2);
text-align: left;
white-space: nowrap;
}
.nation-table thead th {
font-size: 0.7rem;
color: rgba(232, 221, 196, 0.6);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.npc-tag {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.6rem;
padding: 2px 4px;
}
header span {
position: absolute;
right: 6px;
}
button,
select {
border: 1px solid #888;
border-radius: 2px;
background: #222;
color: #fff;
padding: 1px 6px;
}
.sort small {
float: right;
margin-right: 6px;
border: 1px solid rgba(201, 164, 90, 0.4);
color: rgba(232, 221, 196, 0.8);
color: #ccc;
}
.scroll {
width: 1030px;
margin-left: -15px;
min-height: calc(100vh - 112px);
overflow: auto;
}
table {
width: 1030px;
min-width: 1030px;
border-collapse: separate;
table-layout: fixed;
}
th,
td {
border: 1px solid #777;
padding: 3px;
text-align: center;
overflow-wrap: anywhere;
}
th {
height: 30px;
background: #14241b url('/image/game/back_green.jpg');
font-weight: 400;
}
tbody tr {
height: 66px;
background: rgb(0 0 0 / 18%);
}
.npc-1 {
color: cyan;
}
.npc-2,
.npc-3,
.npc-4,
.npc-5 {
color: #aaa;
}
.error {
color: #ff7373;
}
@media (max-width: 1000px) {
.general-page {
margin: 8px 0 0;
}
}
</style>
@@ -0,0 +1,210 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { trpc } from '../utils/trpc';
type Result = Awaited<ReturnType<typeof trpc.nation.getSecretGeneralList.query>>;
type Sort = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
const data = ref<Result | null>(null);
const error = ref('');
const loading = ref(false);
const sort = ref<Sort>(7);
const options = ['자금', '군량', '도시', '병종', '병사', '삭제턴', '턴', '부대'];
const load = async () => {
loading.value = true;
error.value = '';
try {
data.value = await trpc.nation.getSecretGeneralList.query();
} catch (cause) {
error.value = cause instanceof Error ? cause.message : '암행부를 불러오지 못했습니다.';
} finally {
loading.value = false;
}
};
const generals = computed(() =>
[...(data.value?.generals ?? [])].sort((a, b) => {
if (sort.value === 1) return b.gold - a.gold || a.id - b.id;
if (sort.value === 2) return b.rice - a.rice || a.id - b.id;
if (sort.value === 3) return a.cityId - b.cityId || a.id - b.id;
if (sort.value === 4) return b.crewTypeId - a.crewTypeId || a.id - b.id;
if (sort.value === 5) return b.crew - a.crew || a.id - b.id;
if (sort.value === 6) return a.killTurn - b.killTurn || a.id - b.id;
if (sort.value === 7) return a.turnTime.localeCompare(b.turnTime) || a.id - b.id;
return b.troopId - a.troopId || a.id - b.id;
})
);
onMounted(load);
</script>
<template>
<main class="secret-page">
<table class="layout legacy-bg0 title">
<tbody>
<tr>
<td> <br /><RouterLink to="/"> 닫기</RouterLink></td>
</tr>
<tr>
<td>
정렬순서 :
<select v-model.number="sort" aria-label="암행부 정렬">
<option v-for="(label, index) in options" :key="label" :value="index + 1">
{{ label }}
</option>
</select>
<button>정렬하기</button> <button :disabled="loading" @click="load">새로고침</button>
</td>
</tr>
</tbody>
</table>
<p v-if="error" class="state error legacy-bg0" role="alert">{{ error }}</p>
<p v-else-if="loading" class="state legacy-bg0">불러오는 중...</p>
<template v-else-if="data">
<table class="layout summary legacy-bg0">
<tbody>
<tr>
<th>전체 </th>
<td>{{ data.summary.gold.toLocaleString() }}</td>
<th>전체 </th>
<td>{{ data.summary.rice.toLocaleString() }}</td>
<th>평균 </th>
<td>{{ data.summary.averageGold.toFixed(2) }}</td>
<th>평균 </th>
<td>{{ data.summary.averageRice.toFixed(2) }}</td>
</tr>
<tr>
<th>전체 병력/장수</th>
<td>{{ data.summary.crew.toLocaleString() }}/{{ data.summary.generalCount }}</td>
<template v-for="level in [90, 80, 60] as const" :key="level"
><th>훈사 {{ level }} 병력/장수</th>
<td>
{{ data.summary.readiness[level].crew.toLocaleString() }}/{{
data.summary.readiness[level].generals
}}
</td></template
>
</tr>
</tbody>
</table>
<table id="secret-general-list" class="layout list legacy-bg0">
<thead>
<tr>
<th> </th>
<th>통무지</th>
<th> </th>
<th> </th>
<th> </th>
<th>도시</th>
<th></th>
<th> </th>
<th> </th>
<th>훈련</th>
<th>사기</th>
<th class="commands"> </th>
<th>삭턴</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="general in generals" :key="general.id">
<td>{{ general.name }}<br />Lv {{ general.experienceLevel }}</td>
<td>
{{ general.stats.leadership }}{{ general.stats.strength }}{{ general.stats.intelligence }}
</td>
<td>{{ general.troopName ?? '-' }}</td>
<td>{{ general.gold }}</td>
<td>{{ general.rice }}</td>
<td>{{ general.cityName ?? '-' }}</td>
<td>{{ general.defenceTrainText }}</td>
<td>{{ general.crewTypeId }}</td>
<td>{{ general.crew }}</td>
<td>{{ general.train }}</td>
<td>{{ general.atmos }}</td>
<td class="turns">
<template v-if="general.npcState >= 2">NPC 장수</template
><template v-else
><div v-for="(command, index) in general.reservedCommands" :key="index">
{{ index + 1 }} : {{ command }}
</div></template
>
</td>
<td>{{ general.killTurn }}</td>
<td>{{ general.turnTime.slice(11, 16) }}</td>
</tr>
</tbody>
</table>
</template>
<table class="layout legacy-bg0 footer">
<tbody>
<tr>
<td><RouterLink to="/"> 닫기</RouterLink></td>
</tr>
</tbody>
</table>
</main>
</template>
<style scoped>
.secret-page {
width: 1000px;
margin: 8px auto 0;
font:
16px 'Times New Roman',
serif;
color: #fff;
}
.layout {
width: 1000px;
border-collapse: collapse;
table-layout: fixed;
}
td,
th,
.state {
border: 1px solid #777;
padding: 3px;
text-align: center;
font-weight: 400;
}
button,
select {
border: 1px solid #888;
border-radius: 2px;
background: #222;
color: #fff;
padding: 1px 6px;
}
.summary {
margin: 5px auto;
}
.summary th,
.list th {
background: #14241b url('/image/game/back_green.jpg');
}
.summary th {
width: 120px;
}
.list {
width: 1030px;
margin-left: -15px;
border-collapse: separate;
}
.list tbody tr {
height: 39px;
}
.commands {
width: 213px;
}
.turns {
text-align: left;
font-size: 11px;
}
.error {
color: #ff7373;
}
.footer {
margin-top: 5px;
}
@media (max-width: 1000px) {
.secret-page {
margin: 8px 0 0;
}
}
</style>
+693 -691
View File
@@ -1,78 +1,61 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import { trpc } from '../utils/trpc';
import { npcPriorityHelp } from '../utils/npcPriorityHelp';
import { trpc } from '../utils/trpc';
type NpcPolicyResponse = Awaited<ReturnType<typeof trpc.npc.getPolicy.query>>;
type NationPolicy = NpcPolicyResponse['currentNationPolicy'];
type PolicyKey = keyof NationPolicy;
type PolicyField = {
key: PolicyKey;
type NumericPolicyKey = Exclude<keyof NationPolicy, 'CombatForce' | 'SupportForce' | 'DevelopForce'>;
type PrioritySectionKey = 'nation' | 'general';
type PriorityBucket = 'active' | 'inactive';
interface PolicyField {
key: NumericPolicyKey;
label: string;
step: number;
description: string;
hint?: string;
percent?: boolean;
};
min?: number;
max?: number;
}
type PolicySection = {
title: string;
fields: PolicyField[];
};
const NUMERIC_POLICY_KEYS = [
'reqNationGold',
'reqNationRice',
'reqHumanWarUrgentGold',
'reqHumanWarUrgentRice',
'reqHumanWarRecommandGold',
'reqHumanWarRecommandRice',
'reqHumanDevelGold',
'reqHumanDevelRice',
'reqNPCWarGold',
'reqNPCWarRice',
'reqNPCDevelGold',
'reqNPCDevelRice',
'minimumResourceActionAmount',
'maximumResourceActionAmount',
'minNPCWarLeadership',
'minWarCrew',
'minNPCRecruitCityPopulation',
'safeRecruitCityPopulationRatio',
'properWarTrainAtmos',
'cureThreshold',
] as const;
type NumericPolicyKey = (typeof NUMERIC_POLICY_KEYS)[number];
type PrioritySectionKey = 'nation' | 'general';
type PriorityListState = {
interface PriorityListState {
active: string[];
inactive: string[];
available: string[];
};
}
interface PriorityPanel {
key: PrioritySectionKey;
title: string;
description: string[];
setter: NpcPolicyResponse['lastSetters']['nation'];
state: PriorityListState;
}
interface DragState {
section: PrioritySectionKey;
bucket: PriorityBucket;
index: number;
}
const loading = ref(false);
const error = ref<string | null>(null);
const notice = ref<string | null>(null);
const data = ref<NpcPolicyResponse | null>(null);
const policyDraft = ref<NationPolicy | null>(null);
const lastSavedPolicy = ref<NationPolicy | null>(null);
const nationPriority = ref<PriorityListState | null>(null);
const generalPriority = ref<PriorityListState | null>(null);
const lastSavedNationPriority = ref<string[]>([]);
const lastSavedGeneralPriority = ref<string[]>([]);
const dragState = ref<DragState | null>(null);
const resolveErrorMessage = (value: unknown): string => {
if (value instanceof Error) {
return value.message;
}
if (typeof value === 'string') {
return value;
}
if (value instanceof Error) return value.message;
if (typeof value === 'string') return value;
return 'unknown_error';
};
@@ -85,851 +68,870 @@ const clonePolicy = (source: NationPolicy): NationPolicy => ({
const assignPriorityState = (active: string[], available: string[]): PriorityListState => {
const activeSet = new Set(active);
const inactive = available.filter((item) => !activeSet.has(item));
return {
active: [...active],
inactive,
inactive: available.filter((item) => !activeSet.has(item)),
available: [...available],
};
};
const loadPolicy = async () => {
if (loading.value) {
return;
}
if (loading.value) return;
loading.value = true;
error.value = null;
try {
data.value = await trpc.npc.getPolicy.query();
} catch (err) {
error.value = resolveErrorMessage(err);
} catch (caught) {
error.value = resolveErrorMessage(caught);
} finally {
loading.value = false;
}
};
onMounted(() => {
void loadPolicy();
watch(data, (value) => {
if (!value) return;
policyDraft.value = clonePolicy(value.currentNationPolicy);
lastSavedPolicy.value = clonePolicy(value.currentNationPolicy);
nationPriority.value = assignPriorityState(value.currentNationPriority, value.availableNationPriorityItems);
generalPriority.value = assignPriorityState(
value.currentGeneralActionPriority,
value.availableGeneralActionPriorityItems
);
lastSavedNationPriority.value = [...value.currentNationPriority];
lastSavedGeneralPriority.value = [...value.currentGeneralActionPriority];
});
watch(
() => data.value,
(value) => {
if (!value) {
return;
}
policyDraft.value = clonePolicy(value.currentNationPolicy);
lastSavedPolicy.value = clonePolicy(value.currentNationPolicy);
nationPriority.value = assignPriorityState(value.currentNationPriority, value.availableNationPriorityItems);
generalPriority.value = assignPriorityState(
value.currentGeneralActionPriority,
value.availableGeneralActionPriorityItems
);
lastSavedNationPriority.value = [...value.currentNationPriority];
lastSavedGeneralPriority.value = [...value.currentGeneralActionPriority];
}
);
onMounted(() => void loadPolicy());
const formatNumber = (value: number): string => new Intl.NumberFormat('ko-KR').format(Math.round(value));
const calcPolicyValue = (key: NumericPolicyKey): number => {
if (!data.value || !policyDraft.value) {
return 0;
}
if (!data.value || !policyDraft.value) return 0;
const value = policyDraft.value[key];
if (value === 0) {
return data.value.zeroPolicy[key];
}
return value;
return value === 0 ? data.value.zeroPolicy[key] : value;
};
const safeRecruitPercent = computed({
get: () => (policyDraft.value?.safeRecruitCityPopulationRatio ?? 0) * 100,
set: (value: number) => {
if (!policyDraft.value) {
return;
}
policyDraft.value.safeRecruitCityPopulationRatio = value / 100;
if (policyDraft.value) policyDraft.value.safeRecruitCityPopulationRatio = value / 100;
},
});
const policySections = computed<PolicySection[]>(() => {
if (!data.value) {
return [];
}
const policyFields = computed<PolicyField[]>(() => {
if (!data.value) return [];
const statMax = data.value.defaultStatMax;
const statNpcMax = data.value.defaultStatNpcMax;
return [
{
title: '국가 재정',
fields: [
{
key: 'reqNationGold',
label: '국가 권장 금',
step: 100,
description: '이보다 많으면 포상, 적으면 몰수/헌납합니다.(긴급포상 제외)',
},
{
key: 'reqNationRice',
label: '국가 권장 쌀',
step: 100,
description: '이보다 많으면 포상, 적으면 몰수/헌납합니다.(긴급포상 제외)',
},
],
key: 'reqNationGold',
label: '국가 권장 금',
step: 100,
description: '이보다 많으면 포상, 적으면 몰수/헌납합니다.(긴급포상 제외)',
},
{
title: '유저 전투장',
fields: [
{
key: 'reqHumanWarUrgentGold',
label: '긴급포상 금',
step: 100,
description:
'유저장긴급포상시 이보다 금이 적은 장수에게 포상합니다.',
hint: `0이면 보병 6회 징병(${formatNumber(statMax * 100 * 6)}) 가능한 금을 기준으로 하며, 현재 ${formatNumber(
data.value.zeroPolicy.reqHumanWarUrgentGold
)}입니다.`,
},
{
key: 'reqHumanWarUrgentRice',
label: '긴급포상 쌀',
step: 100,
description:
'유저장긴급포상시 이보다 쌀이 적은 장수에게 포상합니다.',
hint: `0이면 기본 병종으로 ${formatNumber(statMax * 100 * 6)}명 사살 가능한 쌀을 기준으로 하며, 현재 ${formatNumber(
data.value.zeroPolicy.reqHumanWarUrgentRice
)}입니다.`,
},
{
key: 'reqHumanWarRecommandGold',
label: '권장 금',
step: 100,
description: '유저전투장에게 주는 금입니다. 이보다 적으면 포상합니다.',
hint: `0이면 긴급포상 금의 2배를 기준으로 하며, 현재 ${formatNumber(
calcPolicyValue('reqHumanWarUrgentGold') * 2
)}입니다.`,
},
{
key: 'reqHumanWarRecommandRice',
label: '권장 쌀',
step: 100,
description: '유저전투장에게 주는 쌀입니다. 이보다 적으면 포상합니다.',
hint: `0이면 긴급포상 쌀의 2배를 기준으로 하며, 현재 ${formatNumber(
calcPolicyValue('reqHumanWarUrgentRice') * 2
)}입니다.`,
},
],
key: 'reqNationRice',
label: '국가 권장 쌀',
step: 100,
description: '이보다 많으면 포상, 적으면 몰수/헌납합니다.(긴급포상 제외)',
},
{
title: '유저 내정장',
fields: [
{
key: 'reqHumanDevelGold',
label: '권장 금',
step: 100,
description: '유저내정장에게 주는 금입니다. 이보다 적으면 포상합니다.',
},
{
key: 'reqHumanDevelRice',
label: '권장 쌀',
step: 100,
description: '유저내정장에게 주는 쌀입니다. 이보다 적으면 포상합니다.',
},
],
key: 'reqHumanWarUrgentGold',
label: '유저전투장 긴급포상 금',
step: 100,
description: '유저장긴급포상시 이보다 금이 적은 장수에게 포상합니다.',
hint: `0이면 보병 6회 징병(${formatNumber(statMax * 100)} * 6) 가능한 금을 기준으로 하며, 그 수치는 현재 ${formatNumber(data.value.zeroPolicy.reqHumanWarUrgentGold)}입니다.`,
},
{
title: 'NPC 전투장',
fields: [
{
key: 'reqNPCWarGold',
label: '권장 금',
step: 100,
description: 'NPC전투장에게 주는 금입니다. 이보다 적으면 포상합니다.',
hint: `0이면 기본 병종 4회(${formatNumber(statNpcMax * 100 * 4)}) 징병비를 기준으로 하며, 현재 ${formatNumber(
data.value.zeroPolicy.reqNPCWarGold
)}입니다.`,
},
{
key: 'reqNPCWarRice',
label: '권장 쌀',
step: 100,
description: 'NPC전투장에게 주는 쌀입니다. 이보다 적으면 포상합니다.',
hint: `0이면 기본 병종으로 ${formatNumber(statNpcMax * 100 * 4)}명 사살 가능한 쌀을 기준으로 하며, 현재 ${formatNumber(
data.value.zeroPolicy.reqNPCWarRice
)}입니다.`,
},
],
key: 'reqHumanWarUrgentRice',
label: '유저전투장 긴급포상 쌀',
step: 100,
description: '유저장긴급포상시 이보다 쌀이 적은 장수에게 포상합니다.',
hint: `0이면 기본 병종으로 ${formatNumber(statMax * 100)} * 6명 사살 가능한 쌀을 기준으로 하며, 그 수치는 현재 ${formatNumber(data.value.zeroPolicy.reqHumanWarUrgentRice)}입니다.`,
},
{
title: 'NPC 내정장',
fields: [
{
key: 'reqNPCDevelGold',
label: '권장 금',
step: 100,
description: 'NPC내정장에게 주는 금입니다. 이보다 5배 더 많다면 헌납합니다.',
hint: `0이면 30턴 내정 가능한 금을 기준으로 하며, 현재 ${formatNumber(
data.value.zeroPolicy.reqNPCDevelGold
)}입니다.`,
},
{
key: 'reqNPCDevelRice',
label: '권장 쌀',
step: 100,
description: 'NPC내정장에게 주는 쌀입니다. 이보다 5배 더 많다면 헌납합니다.',
},
],
key: 'reqHumanWarRecommandGold',
label: '유저전투장 권장 금',
step: 100,
description: '유저전투장에게 주는 금입니다. 이보다 적으면 포상합니다.',
hint: `0이면 유저전투장 긴급포상 금의 2배를 기준으로 하며, 그 수치는 현재 ${formatNumber(calcPolicyValue('reqHumanWarUrgentGold') * 2)}입니다.`,
},
{
title: '자원 정책',
fields: [
{
key: 'minimumResourceActionAmount',
label: '포상/몰수/헌납 최소 단위',
step: 100,
description: '연산결과가 이 단위보다 적다면 수행하지 않습니다.',
},
{
key: 'maximumResourceActionAmount',
label: '포상/몰수/헌납 최대 단위',
step: 100,
description: '연산결과가 이 단위보다 크다면 이 값에 맞춥니다.',
},
],
key: 'reqHumanWarRecommandRice',
label: '유저전투장 권장 쌀',
step: 100,
description: '유저전투장에게 주는 쌀입니다. 이보다 적으면 포상합니다.',
hint: `0이면 유저전투장 긴급포상 쌀의 2배를 기준으로 하며, 그 수치는 현재 ${formatNumber(calcPolicyValue('reqHumanWarUrgentRice') * 2)}입니다.`,
},
{
title: '전투/징병 기준',
fields: [
{
key: 'minWarCrew',
label: '최소 전투 가능 병력 수',
step: 50,
description: '이보다 적을 때에는 징병을 시도합니다.',
},
{
key: 'minNPCRecruitCityPopulation',
label: 'NPC 최소 징병 가능 인구 수',
step: 100,
description:
'도시의 인구가 이보다 낮으면 NPC는 도시에서 징병하지 않고 후방 워프합니다.',
},
{
key: 'safeRecruitCityPopulationRatio',
label: '제자리 징병 허용 인구율(%)',
step: 0.5,
description:
'전쟁 시 후방 발령, 후방 워프의 기준 인구입니다. 이보다 많다면 충분하다고 판단합니다.',
percent: true,
},
{
key: 'minNPCWarLeadership',
label: 'NPC 전투 참여 통솔 기준',
step: 5,
description: '이 수치보다 같거나 높으면 NPC전투장으로 분류됩니다.',
},
],
key: 'reqHumanDevelGold',
label: '유저내정장 권장 금',
step: 100,
description: '유저내정장에게 주는 금입니다. 이보다 적으면 포상합니다.',
},
{
title: '상태 기준',
fields: [
{
key: 'properWarTrainAtmos',
label: '훈련/사기진작 목표치',
step: 5,
description: '훈련/사기진작 기준치입니다. 이보다 같거나 높으면 출병합니다.',
},
{
key: 'cureThreshold',
label: '요양 기준(%)',
step: 5,
description: '요양 기준입니다. 이보다 많이 부상을 입으면 요양합니다.',
},
],
key: 'reqHumanDevelRice',
label: '유저내정장 권장 쌀',
step: 100,
description: '유저내정장에게 주는 쌀입니다. 이보다 적으면 포상합니다.',
},
{
key: 'reqNPCWarGold',
label: 'NPC전투장 권장 금',
step: 100,
description: 'NPC전투장에게 주는 금입니다. 이보다 적으면 포상합니다.',
hint: `0이면 기본 병종 4회(${formatNumber(statNpcMax * 100)} * 4) 징병비를 기준으로 하며, 그 수치는 현재 ${formatNumber(data.value.zeroPolicy.reqNPCWarGold)}입니다.`,
},
{
key: 'reqNPCWarRice',
label: 'NPC전투장 권장 쌀',
step: 100,
description: 'NPC전투장에게 주는 쌀입니다. 이보다 적으면 포상합니다.',
hint: `0이면 기본 병종으로 ${formatNumber(statNpcMax * 100)} * 4명 사살 가능한 쌀을 기준으로 하며, 그 수치는 현재 ${formatNumber(data.value.zeroPolicy.reqNPCWarRice)}입니다.`,
},
{
key: 'reqNPCDevelGold',
label: 'NPC내정장 권장 금',
step: 100,
description: 'NPC내정장에게 주는 금입니다. 이보다 5배 더 많다면 헌납합니다.',
hint: `0이면 30턴 내정 가능한 금을 기준으로 하며, 그 수치는 현재 ${formatNumber(data.value.zeroPolicy.reqNPCDevelGold)}입니다.`,
},
{
key: 'reqNPCDevelRice',
label: 'NPC내정장 권장 쌀',
step: 100,
description: 'NPC내정장에게 주는 쌀입니다. 이보다 5배 더 많다면 헌납합니다.',
},
{
key: 'minimumResourceActionAmount',
label: '포상/몰수/헌납/삼/팜 최소 단위',
step: 100,
min: 100,
description: '연산결과가 이 단위보다 적다면 수행하지 않습니다.',
},
{
key: 'maximumResourceActionAmount',
label: '포상/몰수/헌납/삼/팜 최대 단위',
step: 100,
min: 100,
description: '연산결과가 이 단위보다 크다면, 이 값에 맞춥니다.',
},
{
key: 'minWarCrew',
label: '최소 전투 가능 병력 수',
step: 50,
description: '이보다 적을 때에는 징병을 시도합니다.',
},
{
key: 'minNPCRecruitCityPopulation',
label: 'NPC 최소 징병 가능 인구 수',
step: 100,
description: '도시의 인구가 이보다 낮으면 NPC는 도시에서 징병하지 않고 후방 워프합니다.',
hint: 'NPC의 최대 병력수보다 낮게 설정하면 제자리에서 정착장려를 합니다.',
},
{
key: 'safeRecruitCityPopulationRatio',
label: '제자리 징병 허용 인구율(%)',
step: 0.5,
min: 0,
max: 100,
percent: true,
description: '전쟁 시 후방 발령, 후방 워프의 기준 인구입니다. 이보다 많다면 충분하다고 판단합니다.',
hint: 'NPC의 최대 병력수보다 낮게 설정하면 제자리에서 정착장려를 합니다.',
},
{
key: 'minNPCWarLeadership',
label: 'NPC 전투 참여 통솔 기준',
step: 5,
description: '이 수치보다 같거나 높으면 NPC전투장으로 분류됩니다.',
},
{
key: 'properWarTrainAtmos',
label: '훈련/사기진작 목표치',
step: 5,
min: 20,
max: 100,
description: '훈련/사기진작 기준치입니다. 이보다 같거나 높으면 출병합니다.',
},
{
key: 'cureThreshold',
label: '요양 기준',
step: 5,
min: 10,
max: 100,
description: '요양 기준 %입니다. 이보다 많이 부상을 입으면 요양합니다.',
},
];
});
const canEdit = computed(() => (data.value?.permissionLevel ?? 0) >= 3);
const priorityPanels = computed<PriorityPanel[]>(() => {
if (!data.value || !nationPriority.value || !generalPriority.value) return [];
return [
{
key: 'nation',
title: 'NPC 사령턴 우선순위',
description: ['예턴이 없거나, 지정되어 있더라도 실패하면', '아래 순위에 따라 사령턴을 시도합니다.'],
setter: data.value.lastSetters.nation,
state: nationPriority.value,
},
{
key: 'general',
title: 'NPC 일반턴 우선순위',
description: [
'순위가 높은 것부터 시도합니다.',
'아무것도 실행할 수 없으면 물자조달이나 인재탐색을 합니다.',
],
setter: data.value.lastSetters.general,
state: generalPriority.value,
},
];
});
const resetPolicy = () => {
if (!data.value) {
return;
}
if (!window.confirm('초기 설정으로 되돌릴까요?')) {
return;
}
if (!data.value || !window.confirm('초기 설정으로 되돌릴까요?')) return;
policyDraft.value = clonePolicy(data.value.defaultNationPolicy);
notice.value = '서버 초깃값을 적용했습니다. 설정 버튼을 누르면 반영됩니다.';
};
const rollbackPolicy = () => {
if (!lastSavedPolicy.value) {
return;
}
if (!window.confirm('이전 설정으로 되돌릴까요?')) {
return;
}
if (!lastSavedPolicy.value || !window.confirm('이전 설정으로 되돌릴까요?')) return;
policyDraft.value = clonePolicy(lastSavedPolicy.value);
notice.value = '이전 설정으로 되돌렸습니다.';
};
const submitPolicy = async () => {
if (!policyDraft.value) {
return;
}
if (!window.confirm('저장할까요?')) {
return;
}
if (!policyDraft.value || !window.confirm('저장할까요?')) return;
error.value = null;
notice.value = null;
try {
await trpc.npc.setNationPolicy.mutate(policyDraft.value);
await loadPolicy();
} catch (err) {
error.value = resolveErrorMessage(err);
lastSavedPolicy.value = clonePolicy(policyDraft.value);
notice.value = 'NPC 정책이 반영되었습니다.';
} catch (caught) {
error.value = `설정하지 못했습니다: ${resolveErrorMessage(caught)}`;
}
};
const moveItem = (list: string[], from: number, to: number) => {
if (to < 0 || to >= list.length) {
return;
}
const [item] = list.splice(from, 1);
list.splice(to, 0, item);
};
const insertByOrder = (list: string[], item: string, orderMap: Map<string, number>) => {
const targetOrder = orderMap.get(item) ?? Number.MAX_SAFE_INTEGER;
const index = list.findIndex((entry) => (orderMap.get(entry) ?? Number.MAX_SAFE_INTEGER) > targetOrder);
if (index === -1) {
list.push(item);
} else {
list.splice(index, 0, item);
}
};
const togglePriority = (section: PrioritySectionKey, item: string, enable: boolean) => {
const target = section === 'nation' ? nationPriority.value : generalPriority.value;
if (!target) {
return;
}
if (enable) {
const index = target.inactive.indexOf(item);
if (index >= 0) {
target.inactive.splice(index, 1);
const orderMap = new Map(target.available.map((entry, idx) => [entry, idx]));
insertByOrder(target.active, item, orderMap);
}
return;
}
const index = target.active.indexOf(item);
if (index >= 0) {
target.active.splice(index, 1);
const orderMap = new Map(target.available.map((entry, idx) => [entry, idx]));
insertByOrder(target.inactive, item, orderMap);
}
};
const reorderPriority = (section: PrioritySectionKey, index: number, direction: number) => {
const target = section === 'nation' ? nationPriority.value : generalPriority.value;
if (!target) {
return;
}
moveItem(target.active, index, index + direction);
};
const resetPriority = (section: PrioritySectionKey) => {
if (!data.value) {
return;
}
if (!window.confirm('초기 설정으로 되돌릴까요?')) {
return;
}
if (!data.value || !window.confirm('초기 설정으로 되돌릴까요?')) return;
if (section === 'nation') {
nationPriority.value = assignPriorityState(data.value.defaultNationPriority, data.value.availableNationPriorityItems);
nationPriority.value = assignPriorityState(
data.value.defaultNationPriority,
data.value.availableNationPriorityItems
);
} else {
generalPriority.value = assignPriorityState(
data.value.defaultGeneralActionPriority,
data.value.availableGeneralActionPriorityItems
);
}
notice.value = '서버 초깃값을 적용했습니다. 설정 버튼을 누르면 반영됩니다.';
};
const rollbackPriority = (section: PrioritySectionKey) => {
if (!window.confirm('이전 설정으로 되돌릴까요?')) {
return;
}
if (section === 'nation' && data.value) {
nationPriority.value = assignPriorityState(lastSavedNationPriority.value, data.value.availableNationPriorityItems);
}
if (section === 'general' && data.value) {
if (!data.value || !window.confirm('이전 설정으로 되돌릴까요?')) return;
if (section === 'nation') {
nationPriority.value = assignPriorityState(
lastSavedNationPriority.value,
data.value.availableNationPriorityItems
);
} else {
generalPriority.value = assignPriorityState(
lastSavedGeneralPriority.value,
data.value.availableGeneralActionPriorityItems
);
}
notice.value = '이전 설정으로 되돌렸습니다.';
};
const submitPriority = async (section: PrioritySectionKey) => {
if (!data.value) {
return;
}
if (!window.confirm('저장할까요?')) {
return;
}
const state = section === 'nation' ? nationPriority.value : generalPriority.value;
if (!state || !window.confirm('저장할까요?')) return;
error.value = null;
notice.value = null;
try {
if (section === 'nation' && nationPriority.value) {
await trpc.npc.setNationPriority.mutate(nationPriority.value.active);
if (section === 'nation') {
await trpc.npc.setNationPriority.mutate(state.active);
lastSavedNationPriority.value = [...state.active];
} else {
await trpc.npc.setGeneralPriority.mutate(state.active);
lastSavedGeneralPriority.value = [...state.active];
}
if (section === 'general' && generalPriority.value) {
await trpc.npc.setGeneralPriority.mutate(generalPriority.value.active);
}
await loadPolicy();
} catch (err) {
error.value = resolveErrorMessage(err);
notice.value = 'NPC 정책이 반영되었습니다.';
} catch (caught) {
error.value = `설정하지 못했습니다: ${resolveErrorMessage(caught)}`;
}
};
const startDrag = (event: DragEvent, section: PrioritySectionKey, bucket: PriorityBucket, index: number) => {
dragState.value = { section, bucket, index };
event.dataTransfer?.setData('text/plain', `${section}:${bucket}:${index}`);
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
};
const dropPriority = (event: DragEvent, section: PrioritySectionKey, bucket: PriorityBucket, targetIndex?: number) => {
event.preventDefault();
const source = dragState.value;
const state = section === 'nation' ? nationPriority.value : generalPriority.value;
if (!source || source.section !== section || !state) return;
const sourceList = state[source.bucket];
const targetList = state[bucket];
const [item] = sourceList.splice(source.index, 1);
if (!item) return;
let index = targetIndex ?? targetList.length;
if (sourceList === targetList && source.index < index) index -= 1;
targetList.splice(Math.max(0, Math.min(index, targetList.length)), 0, item);
dragState.value = null;
};
</script>
<template>
<main class="npc-page">
<header class="page-header">
<div>
<h1 class="page-title">NPC 정책</h1>
<p class="page-subtitle">사령/일반 AI 우선순위 자원 기준</p>
<main id="npc-policy-page" class="npc-page">
<nav class="top-back-bar legacy-bg0">
<RouterLink class="back-button" to="/">돌아가기</RouterLink>
<strong>NPC 정책</strong>
</nav>
<div v-if="loading && !data" class="page-state legacy-bg0">불러오는 중...</div>
<div v-else-if="!data" class="page-state error-state legacy-bg0" role="alert">
{{ error ?? 'NPC 정책을 불러오지 못했습니다.' }}
<button type="button" @click="loadPolicy">다시 시도</button>
</div>
<section v-else-if="policyDraft" id="container" class="policy-container legacy-bg0">
<div class="section_bar legacy-bg1">국가 정책</div>
<div class="setter">
최근 설정: {{ data.lastSetters.policy.setter ?? '-없음-' }} ({{
data.lastSetters.policy.date ?? '설정 기록 없음'
}})
</div>
<div class="header-actions">
<RouterLink class="ghost" to="/">메인</RouterLink>
<button class="ghost" @click="loadPolicy">새로고침</button>
<div v-if="error" class="feedback error-feedback" role="alert">{{ error }}</div>
<div v-if="notice" class="feedback notice-feedback" role="status">{{ notice }}</div>
<div class="form_list">
<div v-for="field in policyFields" :key="field.key" class="policy-field">
<div class="field-row">
<label :for="`npc-policy-${field.key}`">{{ field.label }}</label>
<input
v-if="field.percent"
:id="`npc-policy-${field.key}`"
v-model.number="safeRecruitPercent"
type="number"
:step="field.step"
:min="field.min"
:max="field.max"
/>
<input
v-else
:id="`npc-policy-${field.key}`"
v-model.number="policyDraft[field.key]"
type="number"
:step="field.step"
:min="field.min"
:max="field.max"
/>
</div>
<p>{{ field.description }}</p>
<p v-if="field.hint">{{ field.hint }}</p>
</div>
</div>
</header>
<div v-if="error" class="error">{{ error }}</div>
<div class="work-in-progress">
전투 부대는 작업중입니다(json양식: {부대번호:[시작도시번호(아국),도착도시번호(적국)],...})
<br />후방 징병 부대는 작업중입니다(json양식: [부대번호,...]) <br />내정 부대는 작업중입니다(json양식:
[부대번호,...])
<input type="hidden" :value="JSON.stringify(policyDraft.CombatForce)" />
<input type="hidden" :value="JSON.stringify(policyDraft.SupportForce)" />
<input type="hidden" :value="JSON.stringify(policyDraft.DevelopForce)" />
</div>
<section v-if="loading && !data">
<PanelCard title="NPC 정책 로딩">
<SkeletonLines :lines="6" />
</PanelCard>
</section>
<section v-else-if="data && policyDraft" class="npc-layout">
<PanelCard title="국가 정책" subtitle="NPC 자원 기준과 전투 판단 기준">
<div class="setter">
최근 설정: {{ data.lastSetters.policy.setter ?? '-없음-' }} ({{
data.lastSetters.policy.date ?? '설정 기록 없음'
}})
<div class="control_bar">
<div class="button-group">
<button class="reset_btn" type="button" @click="resetPolicy">초깃값으로</button>
<button class="revert_btn" type="button" @click="rollbackPolicy">이전값으로</button>
</div>
<div v-if="!canEdit" class="readonly-note">권한이 부족하여 읽기 전용으로 표시됩니다.</div>
<div v-for="section in policySections" :key="section.title" class="policy-section">
<h3 class="section-title">{{ section.title }}</h3>
<div class="policy-grid">
<div
v-for="field in section.fields"
:key="field.key"
class="policy-field"
>
<label class="field-label">{{ field.label }}</label>
<input
v-if="field.percent"
v-model.number="safeRecruitPercent"
type="number"
class="field-input"
:step="field.step"
min="0"
max="100"
:disabled="!canEdit"
/>
<input
v-else
v-model.number="policyDraft[field.key as PolicyKey]"
type="number"
class="field-input"
:step="field.step"
min="0"
:disabled="!canEdit"
/>
<p class="field-desc">{{ field.description }}</p>
<p v-if="field.hint" class="field-hint">{{ field.hint }}</p>
</div>
</div>
</div>
<div class="control-bar">
<div class="btn-group">
<button class="ghost" :disabled="!canEdit" @click="resetPolicy">초깃값으로</button>
<button class="ghost" :disabled="!canEdit" @click="rollbackPolicy">이전값으로</button>
</div>
<button class="primary" :disabled="!canEdit" @click="submitPolicy">설정</button>
</div>
</PanelCard>
<button class="submit_btn" type="button" @click="submitPolicy">설정</button>
</div>
<div class="priority-grid">
<PanelCard title="NPC 사령턴 우선순위" subtitle="예턴 실패 시 우선순위대로 실행">
<div class="setter">
최근 설정: {{ data.lastSetters.nation.setter ?? '-없음-' }} ({{
data.lastSetters.nation.date ?? '설정 기록 없음'
}})
<div class="priority-sections">
<section
v-for="panel in priorityPanels"
:key="panel.key"
:class="['priority-panel', panel.key === 'nation' ? 'half_section_left' : 'half_section_right']"
>
<div class="section_bar legacy-bg1">{{ panel.title }}</div>
<div class="priority-meta">
<small>
최근 설정: {{ panel.setter.setter ?? '-없음-' }} ({{
panel.setter.date ?? '설정 기록 없음'
}})
</small>
</div>
<div class="priority-description">
<small>{{ panel.description[0] }}<br />{{ panel.description[1] }}</small>
</div>
<div v-if="!canEdit" class="readonly-note">권한이 부족하여 읽기 전용으로 표시됩니다.</div>
<div class="priority-columns">
<div class="priority-column">
<div class="column-title">비활성</div>
<div class="priority-list">
<div class="sub_bar legacy-bg2">비활성</div>
<div
class="priority-list"
@dragover.prevent
@drop="dropPriority($event, panel.key, 'inactive')"
>
<div class="inactive-header">&lt;비활성화 항목들&gt;</div>
<div
v-for="item in nationPriority?.inactive ?? []"
v-for="(item, index) in panel.state.inactive"
:key="item"
class="priority-item"
draggable="true"
@dragstart="startDrag($event, panel.key, 'inactive', index)"
@dragover.prevent
@drop.stop="dropPriority($event, panel.key, 'inactive', index)"
>
<span class="priority-name">{{ item }}</span>
<span
class="priority-help"
:data-text="npcPriorityHelp[item] ?? '설명 없음'"
>
?
</span>
<button class="ghost" :disabled="!canEdit" @click="togglePriority('nation', item, true)">
활성
</button>
<div class="priority_info">
<span class="drag-handle"></span>
<span>{{ item }}</span>
<button
class="help-button"
type="button"
:aria-label="`${item} 설명`"
:data-text="npcPriorityHelp[item] ?? '설명 없음'"
>
?
</button>
</div>
</div>
</div>
</div>
<div class="priority-column">
<div class="column-title">활성</div>
<div class="priority-list">
<div class="sub_bar legacy-bg2">활성</div>
<div
class="priority-list"
@dragover.prevent
@drop="dropPriority($event, panel.key, 'active')"
>
<div
v-for="(item, idx) in nationPriority?.active ?? []"
:key="item"
v-for="(item, index) in panel.state.active"
:key="`${item}-${index}`"
class="priority-item"
draggable="true"
@dragstart="startDrag($event, panel.key, 'active', index)"
@dragover.prevent
@drop.stop="dropPriority($event, panel.key, 'active', index)"
>
<div class="priority-main">
<span class="priority-name">{{ item }}</span>
<span
class="priority-help"
<div class="priority_info">
<span class="drag-handle"></span>
<span>{{ item }}</span>
<button
class="help-button"
type="button"
:aria-label="`${item} 설명`"
:data-text="npcPriorityHelp[item] ?? '설명 없음'"
>
?
</span>
</div>
<div class="priority-actions">
<button class="ghost" :disabled="!canEdit" @click="reorderPriority('nation', idx, -1)"></button>
<button class="ghost" :disabled="!canEdit" @click="reorderPriority('nation', idx, 1)">아래</button>
<button class="ghost" :disabled="!canEdit" @click="togglePriority('nation', item, false)">
비활성
</button>
</div>
</div>
</div>
</div>
</div>
<div class="control-bar">
<div class="btn-group">
<button class="ghost" :disabled="!canEdit" @click="resetPriority('nation')">초깃값으로</button>
<button class="ghost" :disabled="!canEdit" @click="rollbackPriority('nation')">이전값으로</button>
<div class="control_bar priority-control">
<div class="button-group">
<button class="reset_btn" type="button" @click="resetPriority(panel.key)">
초깃값으로
</button>
<button class="revert_btn" type="button" @click="rollbackPriority(panel.key)">
이전값으로
</button>
</div>
<button class="primary" :disabled="!canEdit" @click="submitPriority('nation')">설정</button>
<button class="submit_btn" type="button" @click="submitPriority(panel.key)">설정</button>
</div>
</PanelCard>
<PanelCard title="NPC 일반턴 우선순위" subtitle="순위가 높은 것부터 시도">
<div class="setter">
최근 설정: {{ data.lastSetters.general.setter ?? '-없음-' }} ({{
data.lastSetters.general.date ?? '설정 기록 없음'
}})
</div>
<div v-if="!canEdit" class="readonly-note">권한이 부족하여 읽기 전용으로 표시됩니다.</div>
<div class="priority-columns">
<div class="priority-column">
<div class="column-title">비활성</div>
<div class="priority-list">
<div
v-for="item in generalPriority?.inactive ?? []"
:key="item"
class="priority-item"
>
<span class="priority-name">{{ item }}</span>
<span
class="priority-help"
:data-text="npcPriorityHelp[item] ?? '설명 없음'"
>
?
</span>
<button class="ghost" :disabled="!canEdit" @click="togglePriority('general', item, true)">
활성
</button>
</div>
</div>
</div>
<div class="priority-column">
<div class="column-title">활성</div>
<div class="priority-list">
<div
v-for="(item, idx) in generalPriority?.active ?? []"
:key="item"
class="priority-item"
>
<div class="priority-main">
<span class="priority-name">{{ item }}</span>
<span
class="priority-help"
:data-text="npcPriorityHelp[item] ?? '설명 없음'"
>
?
</span>
</div>
<div class="priority-actions">
<button class="ghost" :disabled="!canEdit" @click="reorderPriority('general', idx, -1)"></button>
<button class="ghost" :disabled="!canEdit" @click="reorderPriority('general', idx, 1)">아래</button>
<button class="ghost" :disabled="!canEdit" @click="togglePriority('general', item, false)">
비활성
</button>
</div>
</div>
</div>
</div>
</div>
<div class="control-bar">
<div class="btn-group">
<button class="ghost" :disabled="!canEdit" @click="resetPriority('general')">초깃값으로</button>
<button class="ghost" :disabled="!canEdit" @click="rollbackPriority('general')">이전값으로</button>
</div>
<button class="primary" :disabled="!canEdit" @click="submitPriority('general')">설정</button>
</div>
</PanelCard>
</section>
</div>
</section>
</main>
</template>
<style scoped>
:global(html),
:global(body),
:global(#app) {
min-width: 500px;
margin: 0;
background: #000;
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
font-size: 14px;
line-height: 21px;
}
:global(body:has(#npc-policy-page)) {
min-width: 500px;
line-height: 21px;
}
.npc-page {
min-height: 100vh;
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
color: #fff;
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
font-size: 14px;
line-height: 21px;
}
.page-header {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid rgba(201, 164, 90, 0.4);
padding-bottom: 12px;
.legacy-bg0 {
background-image: url('/image/game/back_walnut.jpg');
}
.page-title {
font-size: 1.6rem;
font-weight: 600;
.legacy-bg1 {
background-image: url('/image/game/back_green.jpg');
}
.page-subtitle {
font-size: 0.85rem;
color: rgba(232, 221, 196, 0.7);
.legacy-bg2 {
background-image: url('/image/game/back_blue.jpg');
}
.header-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
.top-back-bar {
position: relative;
height: 32px;
max-width: 1000px;
margin: 0 auto;
text-align: center;
box-sizing: border-box;
}
.ghost {
border: 1px solid rgba(201, 164, 90, 0.4);
padding: 6px 12px;
font-size: 0.8rem;
cursor: pointer;
background: rgba(16, 16, 16, 0.6);
color: inherit;
.top-back-bar strong {
font-size: 24px;
line-height: 32px;
font-weight: 400;
}
.primary {
border: 1px solid rgba(201, 164, 90, 0.6);
padding: 6px 12px;
font-size: 0.8rem;
cursor: pointer;
background: rgba(201, 164, 90, 0.2);
color: inherit;
.back-button {
position: absolute;
inset: 0 auto 0 0;
width: 88px;
color: #fff;
background: #087f45;
border: 1px solid #0a9960;
border-radius: 0 0 4px;
font-weight: 700;
line-height: 30px;
text-decoration: none;
}
.error {
color: #f5b7b1;
font-size: 0.85rem;
.back-button:hover,
.back-button:focus-visible {
background: #0a9960;
outline: 2px solid #fff;
outline-offset: -2px;
}
.npc-layout {
display: flex;
flex-direction: column;
gap: 16px;
.policy-container,
.page-state {
width: 100%;
max-width: 1000px;
margin: 0 auto;
border: 1px solid #888;
box-sizing: border-box;
}
.setter {
font-size: 0.75rem;
color: rgba(232, 221, 196, 0.6);
margin-bottom: 8px;
.page-state {
padding: 16px;
min-height: 100px;
}
.readonly-note {
font-size: 0.75rem;
color: rgba(245, 208, 138, 0.8);
margin-bottom: 8px;
.page-state button {
margin-left: 12px;
}
.policy-section {
margin-top: 12px;
.section_bar {
min-height: 23px;
border: 0.5px solid #aaa;
box-sizing: border-box;
text-align: center;
}
.section-title {
font-size: 0.95rem;
margin-bottom: 8px;
color: rgba(232, 221, 196, 0.9);
.setter,
.priority-meta {
min-height: 21px;
padding: 0 12px;
color: #8e8e8e;
font-size: 12.25px;
line-height: 18.375px;
text-align: right;
box-sizing: border-box;
}
.policy-grid {
.feedback {
margin: 4px 12px;
padding: 5px 10px;
border: 1px solid;
border-radius: 4px;
}
.error-feedback,
.error-state {
color: #ffd4d4;
border-color: #a94442;
background-color: rgba(120, 20, 20, 0.75);
}
.notice-feedback {
color: #d9ffd9;
border-color: #3c763d;
background-color: rgba(20, 90, 20, 0.7);
}
.form_list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 12px;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin: 8px;
}
.policy-field {
border: 1px solid rgba(201, 164, 90, 0.2);
background: rgba(16, 16, 16, 0.5);
padding: 10px;
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
padding: 0 10.5px;
box-sizing: border-box;
}
.field-label {
font-size: 0.8rem;
font-weight: 600;
}
.field-input {
background: rgba(12, 12, 12, 0.8);
border: 1px solid rgba(201, 164, 90, 0.3);
color: inherit;
padding: 6px 8px;
font-size: 0.8rem;
font-family: inherit;
}
.field-desc {
font-size: 0.7rem;
color: rgba(232, 221, 196, 0.6);
}
.field-hint {
font-size: 0.7rem;
color: rgba(232, 221, 196, 0.45);
white-space: pre-line;
}
.control-bar {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.btn-group {
display: flex;
gap: 8px;
}
.priority-grid {
.field-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 16px;
grid-template-columns: minmax(0, 1fr) 224px;
align-items: center;
min-height: 34px;
}
.field-row label {
min-width: 0;
}
.field-row input {
width: 224px;
height: 34px;
padding: 5.25px 10.5px;
color: #303030;
background: #ddd;
border: 1px solid #000;
border-radius: 4px;
box-sizing: border-box;
font: inherit;
}
.field-row input:focus {
border-color: #66afe9;
outline: 2px solid rgba(102, 175, 233, 0.7);
}
.policy-field p {
min-height: 18.375px;
margin: 0;
color: #888;
font-size: 12.25px;
line-height: 18.375px;
text-align: right;
}
.work-in-progress {
margin: 0 11px 15px;
padding: 14px;
color: #fff;
background: #444;
border: 1px solid #444;
border-radius: 4px;
}
.control_bar {
display: flex;
justify-content: flex-end;
padding: 0 10.6667px 10.6667px;
min-height: 56.8125px;
box-sizing: border-box;
}
.button-group {
display: flex;
}
.control_bar button {
width: 150px;
height: 35.5px;
margin-top: 10.6667px;
padding: 5.25px 10.5px;
color: #fff;
border: 1px solid;
font: inherit;
cursor: pointer;
}
.reset_btn {
background: #303030;
border-color: #2b2b2b !important;
border-radius: 4px 0 0 4px;
}
.revert_btn {
background: #444;
border-color: #3d3d3d !important;
border-radius: 0 4px 4px 0;
}
.submit_btn {
margin-left: 14px;
background: #375a7f;
border-color: #325172 !important;
border-radius: 4px;
}
.control_bar button:hover {
filter: brightness(1.15);
}
.control_bar button:focus-visible,
.help-button:focus-visible {
outline: 2px solid #fff;
outline-offset: -2px;
}
.priority-sections {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.priority-panel {
min-width: 0;
}
.half_section_left {
border-right: 0.5px solid #aaa;
}
.priority-meta {
float: right;
}
.priority-description {
clear: both;
min-height: 42px;
padding: 0 8px;
color: #888;
box-sizing: border-box;
}
.priority-description small {
font-size: 12.25px;
line-height: 18.375px;
}
.priority-columns {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.priority-column {
border: 1px solid rgba(201, 164, 90, 0.2);
padding: 8px;
background: rgba(12, 12, 12, 0.5);
min-width: 0;
}
.column-title {
font-size: 0.8rem;
margin-bottom: 6px;
color: rgba(232, 221, 196, 0.7);
.sub_bar {
height: 22px;
margin: 0 5px;
border: 0.5px solid #aaa;
text-align: center;
box-sizing: border-box;
}
.priority-list {
display: flex;
min-height: 37px;
margin: 0 10px;
flex-direction: column;
gap: 6px;
}
.inactive-header,
.priority-item {
height: 37px;
padding: 7px 14px;
border: 1px solid #444;
box-sizing: border-box;
}
.inactive-header {
color: #1d1d1d;
background: #d6d6d6;
}
.priority-item {
border: 1px solid rgba(201, 164, 90, 0.2);
padding: 6px;
display: flex;
flex-direction: column;
gap: 6px;
background: rgba(16, 16, 16, 0.6);
color: #fff;
background: #303030;
cursor: grab;
}
.priority-main {
display: flex;
.priority-item:active {
cursor: grabbing;
opacity: 0.8;
}
.priority_info {
display: grid;
height: 21px;
grid-template-columns: 24px minmax(0, 1fr) 24px;
align-items: center;
gap: 6px;
}
.priority-name {
font-size: 0.78rem;
.drag-handle {
font-size: 18px;
}
.priority-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.priority-help {
.help-button {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
font-size: 0.7rem;
border-radius: 999px;
border: 1px solid rgba(201, 164, 90, 0.4);
cursor: default;
width: 24px;
height: 22.375px;
padding: 0 3.5px;
color: #fff;
background: #444;
border: 1px solid #3d3d3d;
border-radius: 3px;
font-size: 12.25px;
line-height: 18.375px;
cursor: pointer;
}
.priority-help::after {
content: attr(data-text);
.help-button::after {
position: absolute;
bottom: 125%;
left: 50%;
transform: translateX(-50%);
background: rgba(16, 16, 16, 0.9);
border: 1px solid rgba(201, 164, 90, 0.4);
padding: 8px;
font-size: 0.7rem;
width: 220px;
right: 0;
bottom: calc(100% + 5px);
z-index: 10;
width: 300px;
padding: 7px;
color: #fff;
background: #111;
border: 1px solid #777;
border-radius: 4px;
content: attr(data-text);
font-size: 12px;
line-height: 18px;
text-align: left;
white-space: pre-line;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
z-index: 10;
transition: opacity 0.15s ease;
}
.priority-help:hover::after {
.help-button:hover::after,
.help-button:focus-visible::after {
opacity: 1;
}
@media (max-width: 1024px) {
.npc-page {
padding: 16px;
.priority-control {
margin-top: 0;
}
@media (max-width: 991px) {
.form_list,
.priority-sections {
grid-template-columns: 1fr;
}
.half_section_left {
border-right: 0;
}
}
</style>
@@ -108,6 +108,7 @@ onMounted(() => {
<RouterLink v-else-if="session.needsGeneral" class="ghost" to="/join">장수 생성/빙의</RouterLink>
<RouterLink v-else class="ghost" to="/">메인으로</RouterLink>
<RouterLink class="ghost" to="/npc-list">빙의일람</RouterLink>
<RouterLink class="ghost" to="/traffic">접속량정보</RouterLink>
<button class="ghost" @click="refreshPublicData">새로고침</button>
</div>
</header>
+343
View File
@@ -0,0 +1,343 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { trpc } from '../utils/trpc';
type TrafficData = Awaited<ReturnType<typeof trpc.public.getTraffic.query>>;
const data = ref<TrafficData | null>(null);
const loading = ref(false);
const errorMessage = ref('');
const getErrorMessage = (error: unknown): string => {
if (error instanceof Error) {
return error.message;
}
return typeof error === 'string' ? error : '요청을 처리하지 못했습니다.';
};
const load = async () => {
if (loading.value) {
return;
}
loading.value = true;
errorMessage.value = '';
try {
data.value = await trpc.public.getTraffic.query();
} catch (error) {
// Preserve the last successful graph if a later refresh fails.
errorMessage.value = getErrorMessage(error);
} finally {
loading.value = false;
}
};
const refreshRows = computed(() =>
(data.value?.history ?? []).map((entry) => ({
...entry,
value: entry.refresh,
width: Math.round((entry.refresh / Math.max(1, data.value?.maxRefresh ?? 1)) * 1_000) / 10,
}))
);
const onlineRows = computed(() =>
(data.value?.history ?? []).map((entry) => ({
...entry,
value: entry.online,
width: Math.round((entry.online / Math.max(1, data.value?.maxOnline ?? 1)) * 1_000) / 10,
}))
);
const timeLabel = (value: string): string => {
const timePart = value.includes('T') ? value.split('T')[1] : value.slice(11);
return (timePart ?? '').slice(0, 5);
};
const trafficColor = (percentage: number): string => {
const channel = (value: number): string =>
Math.floor((Math.max(0, Math.min(100, value)) * 255) / 100)
.toString(16)
.padStart(2, '0');
return `#${channel(percentage)}00${channel(100 - percentage)}`;
};
onMounted(() => {
void load();
});
</script>
<template>
<main id="traffic-container" class="traffic-page">
<table class="legacy-table title-table legacy-bg0">
<tbody>
<tr>
<td>
<br />
<RouterLink class="legacy-close" to="/">돌아가기</RouterLink>
</td>
</tr>
</tbody>
</table>
<div v-if="errorMessage" class="traffic-error" role="alert">{{ errorMessage }}</div>
<div v-if="loading && !data" class="traffic-loading">불러오는 중...</div>
<section v-if="data" class="chart-layout">
<table class="legacy-table chart-table legacy-bg0">
<thead>
<tr>
<th colspan="4" class="legacy-bg2 chart-title"> </th>
</tr>
</thead>
<tbody>
<tr v-for="(entry, index) in refreshRows" :key="`${entry.date}-${index}`" class="chart-row">
<td class="period">{{ entry.year }} {{ entry.month }}</td>
<td class="time legacy-bg2">{{ timeLabel(entry.date) }}</td>
<td class="separator legacy-bg1"></td>
<td class="bar-cell">
<div
v-if="entry.width > 0"
class="big-bar"
:style="{ width: `${entry.width}%`, backgroundColor: trafficColor(entry.width) }"
>
<span v-if="entry.width >= 10">{{ entry.value }}</span>
</div>
<span v-if="entry.width < 10" class="out-bar">{{ entry.value }}</span>
</td>
</tr>
<tr><td colspan="4" class="legacy-bg1 spacer"></td></tr>
<tr><td colspan="4" class="record">최고기록: {{ data.maxRefresh }}</td></tr>
</tbody>
</table>
<table class="legacy-table chart-table legacy-bg0">
<thead>
<tr>
<th colspan="4" class="legacy-bg2 chart-title"> </th>
</tr>
</thead>
<tbody>
<tr v-for="(entry, index) in onlineRows" :key="`${entry.date}-${index}`" class="chart-row">
<td class="period">{{ entry.year }} {{ entry.month }}</td>
<td class="time legacy-bg2">{{ timeLabel(entry.date) }}</td>
<td class="separator legacy-bg1"></td>
<td class="bar-cell">
<div
v-if="entry.width > 0"
class="big-bar"
:style="{ width: `${entry.width}%`, backgroundColor: trafficColor(entry.width) }"
>
<span v-if="entry.width >= 10">{{ entry.value }}</span>
</div>
<span v-if="entry.width < 10" class="out-bar">{{ entry.value }}</span>
</td>
</tr>
<tr><td colspan="4" class="legacy-bg1 spacer"></td></tr>
<tr><td colspan="4" class="record">최고기록: {{ data.maxOnline }}</td></tr>
</tbody>
</table>
</section>
<table v-if="data" class="legacy-table suspect-table legacy-bg0">
<thead>
<tr>
<th colspan="3" class="legacy-bg2 chart-title"> (순간과도갱신)</th>
</tr>
</thead>
<tbody>
<tr v-for="entry in data.suspects" :key="entry.generalId ?? 'total'">
<td class="suspect-name">{{ entry.name }}</td>
<td class="suspect-score">{{ entry.refreshScoreTotal }}({{ entry.refresh }})</td>
<td class="little-bar-cell">
<div
v-if="entry.refresh > 0"
class="little-bar"
:style="{
width: `${Math.round((entry.refresh / Math.max(1, data.suspects[0]?.refresh ?? 1)) * 1_000) / 10}%`,
backgroundColor: trafficColor(
Math.round((entry.refresh / Math.max(1, data.suspects[0]?.refresh ?? 1)) * 1_000) / 10
),
}"
></div>
</td>
</tr>
</tbody>
</table>
<table class="legacy-table footer-table legacy-bg0">
<tbody>
<tr><td><RouterLink class="legacy-close" to="/">돌아가기</RouterLink></td></tr>
<tr><td class="banner">SAMMO</td></tr>
</tbody>
</table>
</main>
</template>
<style scoped>
.traffic-page {
width: 1016px;
min-width: 1016px;
min-height: 100vh;
margin: 0 auto;
padding: 0;
color: #fff;
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
font-size: 14px;
line-height: normal;
}
.legacy-table {
border-collapse: collapse;
padding: 0;
color: #fff;
font-size: 14px;
}
.legacy-table td,
.legacy-table th {
border: 1px solid gray;
padding: 0;
text-align: center;
font-weight: 400;
}
.legacy-bg0 {
background-color: #302016;
background-image: url('/image/game/back_walnut.jpg');
}
.legacy-bg1 {
background-color: #423226;
background-image: url('/image/game/back_sandal.jpg');
}
.legacy-bg2 {
background-color: #14241b;
background-image: url('/image/game/back_green.jpg');
}
.title-table,
.footer-table {
width: 1000px;
margin: 0 auto;
}
.title-table {
margin-bottom: 18px;
}
.title-table td {
height: 54px;
}
.chart-layout {
width: 1016px;
display: flex;
gap: 26px;
align-items: flex-start;
box-sizing: border-box;
padding: 0 12px;
}
.chart-table {
width: 483px;
table-layout: fixed;
}
.chart-title {
height: 34px;
font-size: 24px;
}
.chart-row {
height: 30px;
}
.period {
width: 100px;
}
.time {
width: 60px;
}
.separator {
width: 2px;
}
.bar-cell {
width: 320px;
text-align: left !important;
white-space: nowrap;
}
.big-bar {
float: left;
position: relative;
height: 30px;
}
.big-bar span {
float: right;
padding-right: 1ch;
line-height: 30px;
}
.out-bar {
line-height: 30px;
margin-left: 1ch;
}
.spacer {
height: 5px;
}
.record {
height: 30px;
}
.suspect-table {
margin: 18px auto;
}
.suspect-name,
.suspect-score {
width: 98px;
}
.little-bar-cell {
width: 798px;
text-align: left !important;
}
.little-bar {
height: 17px;
}
.footer-table {
margin-top: 18px;
}
.banner {
height: 24px;
}
.legacy-close {
color: #fff;
text-decoration: underline;
}
.traffic-error,
.traffic-loading {
width: 1000px;
margin: -12px auto 12px;
border: 1px solid gray;
padding: 6px;
text-align: center;
background: #302016;
}
.traffic-error {
color: #ff8080;
}
</style>