feat(frontend): restore betting and NPC list pages

This commit is contained in:
2026-07-26 02:04:03 +00:00
parent 8f9ab4f1e2
commit ba27b71e2f
7 changed files with 1279 additions and 0 deletions
+156
View File
@@ -1,10 +1,13 @@
import { TRPCError } from '@trpc/server';
import { asRecord } from '@sammo-ts/common';
import type { GameApiContext } from '../../context.js';
import { zWorldStateConfig, zWorldStateMeta } from '../../context.js';
import { loadMapLayout } from '../../maps/mapLayout.js';
import { loadPublicMap } from '../../maps/worldMap.js';
import { procedure, router } from '../../trpc.js';
import { loadTraitNames } from '../nation/shared.js';
import { z } from 'zod';
type WorldTrendSnapshot = {
year: number;
@@ -37,6 +40,8 @@ type NationCountRow = {
count: number;
};
type NpcListSort = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
const PUBLIC_CACHE_TTL_SECONDS = 600;
const buildPublicCacheKey = (ctx: GameApiContext, key: string): string =>
@@ -151,6 +156,52 @@ const loadCachedNationList = async (ctx: GameApiContext): Promise<NationSummary[
return summary;
};
const normalizeTraitKey = (value: string): string | null => (value && value !== 'None' ? value : null);
const readFiniteMetaNumber = (meta: Record<string, unknown>, key: string): number => {
const value = meta[key];
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
};
const compareString = (left: string, right: string): number => {
if (left === right) {
return 0;
}
return left < right ? -1 : 1;
};
const sortNpcList = <T extends {
name: string;
nationId: number;
statTotal: number;
leadership: number;
strength: number;
intelligence: number;
experience: number;
dedication: number;
}>(rows: T[], sort: NpcListSort): T[] =>
rows.sort((left, right) => {
switch (sort) {
case 2:
return left.nationId - right.nationId;
case 3:
return right.statTotal - left.statTotal;
case 4:
return right.leadership - left.leadership;
case 5:
return right.strength - left.strength;
case 6:
return right.intelligence - left.intelligence;
case 7:
return right.experience - left.experience;
case 8:
return right.dedication - left.dedication;
case 1:
default:
return compareString(left.name, right.name);
}
});
export const publicRouter = router({
getMapLayout: procedure.query(async ({ ctx }) => {
return loadMapLayout(ctx.profile.scenario);
@@ -208,4 +259,109 @@ export const publicRouter = router({
intelligence: general.intel,
}));
}),
getNpcList: procedure
.input(
z
.object({
sort: z.number().int().min(1).max(8).catch(1).optional(),
})
.optional()
)
.query(async ({ ctx, input }) => {
const sort = (input?.sort ?? 1) as NpcListSort;
const [generals, nations] = await Promise.all([
ctx.db.general.findMany({
where: { npcState: { gt: 0 } },
select: {
id: true,
name: true,
npcState: true,
nationId: true,
leadership: true,
strength: true,
intel: true,
experience: true,
dedication: true,
personalCode: true,
specialCode: true,
special2Code: true,
meta: true,
},
orderBy: { id: 'asc' },
}),
ctx.db.nation.findMany({
select: { id: true, name: true },
}),
]);
const personalityKeys = generals.map((general) => normalizeTraitKey(general.personalCode));
const domesticKeys = generals.map((general) => normalizeTraitKey(general.specialCode));
const warKeys = generals.map((general) => normalizeTraitKey(general.special2Code));
const [personalityMap, domesticMap, warMap] = await Promise.all([
loadTraitNames(personalityKeys, 'personality'),
loadTraitNames(domesticKeys, 'domestic'),
loadTraitNames(warKeys, 'war'),
]);
const nationMap = new Map(nations.map((nation) => [nation.id, nation.name]));
// Legacy select_pool rows preceded possessed NPC rows before its stable-value sort.
const pool = generals.filter((general) => general.npcState >= 2);
const possessed = generals.filter((general) => general.npcState === 1);
const rows = [...pool, ...possessed].map((general) => {
const meta = asRecord(general.meta);
const personalityKey = normalizeTraitKey(general.personalCode);
const domesticKey = normalizeTraitKey(general.specialCode);
const warKey = normalizeTraitKey(general.special2Code);
const ownerName =
general.npcState === 1
? typeof meta.owner_name === 'string'
? meta.owner_name
: typeof meta.ownerName === 'string'
? meta.ownerName
: ''
: '';
return {
id: general.id,
name: general.name,
npcState: general.npcState,
ownerName,
level: readFiniteMetaNumber(meta, 'explevel'),
nationId: general.nationId,
nationName: nationMap.get(general.nationId) ?? '-',
personality: personalityKey
? {
key: personalityKey,
name: personalityMap.get(personalityKey)?.name ?? personalityKey,
info: personalityMap.get(personalityKey)?.info ?? '',
}
: null,
specialDomestic: domesticKey
? {
key: domesticKey,
name: domesticMap.get(domesticKey)?.name ?? domesticKey,
info: domesticMap.get(domesticKey)?.info ?? '',
}
: null,
specialWar: warKey
? {
key: warKey,
name: warMap.get(warKey)?.name ?? warKey,
info: warMap.get(warKey)?.info ?? '',
}
: null,
statTotal: general.leadership + general.strength + general.intel,
leadership: general.leadership,
strength: general.strength,
intelligence: general.intel,
experience: general.experience,
dedication: general.dedication,
};
});
return {
sort,
generals: sortNpcList(rows, sort),
};
}),
});
+143
View File
@@ -0,0 +1,143 @@
import { describe, expect, it } from 'vitest';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { RedisConnector } from '@sammo-ts/infra';
import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js';
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
import type { DatabaseClient, GameApiContext, GameProfile } from '../src/context.js';
import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js';
import { appRouter } from '../src/router.js';
const profile: GameProfile = {
id: 'che',
scenario: 'default',
name: 'che:default',
};
const buildContext = (): GameApiContext => {
const generalRows = [
{
id: 10,
name: '관우',
npcState: 1,
nationId: 1,
leadership: 90,
strength: 95,
intel: 75,
experience: 800,
dedication: 700,
personalCode: 'None',
specialCode: 'None',
special2Code: 'None',
meta: { owner_name: '악령 관우', explevel: 4 },
},
{
id: 20,
name: '조운',
npcState: 2,
nationId: 0,
leadership: 90,
strength: 95,
intel: 75,
experience: 900,
dedication: 600,
personalCode: 'None',
specialCode: 'None',
special2Code: 'None',
meta: { owner_name: '노출 금지', explevel: 5 },
},
];
const db = {
general: {
findMany: async (args: { where: { npcState: { gt: number } } }) => {
expect(args.where).toEqual({ npcState: { gt: 0 } });
return generalRows;
},
},
nation: {
findMany: async () => [{ id: 1, name: '촉' }],
},
};
const redis = {
get: async () => null,
set: async () => null,
} as unknown as RedisConnector['client'];
return {
db: db as unknown as DatabaseClient,
turnDaemon: new InMemoryTurnDaemonTransport(),
battleSim: new InMemoryBattleSimTransport(),
profile,
auth: null as GameSessionTokenPayload | null,
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
redis,
accessTokenStore: new RedisAccessTokenStore(redis, profile.name),
flushStore: new InMemoryFlushStore(),
gameTokenSecret: 'test-secret',
};
};
describe('public.getNpcList', () => {
it('returns only the legacy-compatible public DTO without user identifiers', async () => {
const result = await appRouter.createCaller(buildContext()).public.getNpcList({ sort: 1 });
expect(result.generals).toEqual([
{
id: 10,
name: '관우',
npcState: 1,
ownerName: '악령 관우',
level: 4,
nationId: 1,
nationName: '촉',
personality: null,
specialDomestic: null,
specialWar: null,
statTotal: 260,
leadership: 90,
strength: 95,
intelligence: 75,
experience: 800,
dedication: 700,
},
{
id: 20,
name: '조운',
npcState: 2,
ownerName: '',
level: 5,
nationId: 0,
nationName: '-',
personality: null,
specialDomestic: null,
specialWar: null,
statTotal: 260,
leadership: 90,
strength: 95,
intelligence: 75,
experience: 900,
dedication: 600,
},
]);
expect(JSON.stringify(result)).not.toContain('노출 금지');
expect(JSON.stringify(result)).not.toContain('userId');
});
it('keeps pool rows before possessed NPCs when the selected value is tied', async () => {
const result = await appRouter.createCaller(buildContext()).public.getNpcList({ sort: 3 });
expect(result.generals.map((general) => general.id)).toEqual([20, 10]);
});
it('falls an invalid legacy sort value back to name order', async () => {
const caller = appRouter.createCaller(buildContext());
const result = await caller.public.getNpcList({ sort: 99 } as unknown as { sort: 1 });
expect(result.sort).toBe(1);
expect(result.generals.map((general) => general.name)).toEqual(['관우', '조운']);
});
});
+16
View File
@@ -28,6 +28,8 @@ import DynastyDetailView from '../views/DynastyDetailView.vue';
import SurveyView from '../views/SurveyView.vue';
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 { useSessionStore } from '../stores/session';
const routes = [
@@ -220,6 +222,20 @@ const routes = [
requiresAuth: true,
},
},
{
path: '/nation-betting',
name: 'nation-betting',
component: NationBettingView,
meta: {
requiresAuth: true,
requiresGeneral: true,
},
},
{
path: '/npc-list',
name: 'npc-list',
component: NpcListView,
},
{
path: '/my-page',
name: 'my-page',
+2
View File
@@ -103,6 +103,8 @@ watch(
<RouterLink class="ghost" to="/hall-of-fame">명예의 전당</RouterLink>
<RouterLink class="ghost" to="/dynasty">왕조일람</RouterLink>
<RouterLink class="ghost" to="/yearbook">연감</RouterLink>
<RouterLink class="ghost" to="/nation-betting">천통국 베팅</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>
@@ -0,0 +1,640 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { trpc } from '../utils/trpc';
type BettingListItem = {
id: number;
type: string;
name: string;
finished: boolean;
selectCnt: number;
isExclusive: boolean | null;
reqInheritancePoint: boolean;
openYearMonth: number;
closeYearMonth: number;
winner: unknown;
totalAmount: number;
};
type BettingList = {
bettingList: Record<string, BettingListItem>;
year: number;
month: number;
};
type BettingDetail = {
bettingInfo: Omit<BettingListItem, 'totalAmount'> & {
candidates: unknown;
};
bettingDetail: ReadonlyArray<readonly [string, number]>;
myBetting: ReadonlyArray<readonly [string, number]>;
remainPoint: number;
year: number;
month: number;
};
const bettingApi = trpc.betting as unknown as {
getList: {
query: (input: { req: 'bettingNation' }) => Promise<BettingList>;
};
getDetail: {
query: (input: { bettingId: number }) => Promise<BettingDetail>;
};
bet: {
mutate: (input: { bettingId: number; bettingType: number[]; amount: number }) => Promise<{ result: boolean }>;
};
};
type Candidate = {
title: string;
info: string;
};
const list = ref<BettingList | null>(null);
const detail = ref<BettingDetail | null>(null);
const selectedBettingId = ref<number | null>(null);
const selectedCandidates = ref<number[]>([]);
const amount = ref(0);
const loadingList = ref(false);
const loadingDetail = ref(false);
const submitting = ref(false);
const errorMessage = ref('');
const noticeMessage = ref('');
const currentYearMonth = computed(() => {
if (!detail.value) {
return 0;
}
return detail.value.year * 12 + detail.value.month - 1;
});
const listYearMonth = computed(() => {
if (!list.value) {
return 0;
}
return list.value.year * 12 + list.value.month - 1;
});
const listItems = computed(() =>
list.value
? Object.values(list.value.bettingList).sort((left, right) => right.id - left.id)
: []
);
const info = computed(() => detail.value?.bettingInfo ?? null);
const candidates = computed<Candidate[]>(() => {
const value = info.value?.candidates;
if (!Array.isArray(value)) {
return [];
}
return value.map((candidate) => {
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) {
return { title: '-', info: '' };
}
return {
title: typeof candidate.title === 'string' ? candidate.title : '-',
info: typeof candidate.info === 'string' ? candidate.info : '',
};
});
});
const winner = computed(() => {
const value = info.value?.winner;
if (!Array.isArray(value)) {
return new Set<number>();
}
return new Set(value.filter((item): item is number => typeof item === 'number' && Number.isInteger(item)));
});
const detailRows = computed(() =>
(detail.value?.bettingDetail ?? [])
.filter(([key]) => readSelection(key).every((value) => value >= 0))
.sort((left, right) => right[1] - left[1])
);
const myBetMap = computed(() => new Map(detail.value?.myBetting ?? []));
const totalAmount = computed(() =>
(detail.value?.bettingDetail ?? []).reduce((sum, [, value]) => sum + value, 0)
);
const pureAmount = computed(() =>
(detail.value?.bettingDetail ?? []).reduce(
(sum, [key, value]) => sum + (readSelection(key).some((item) => item < 0) ? 0 : value),
0
)
);
const candidateAmounts = computed(() => {
const result = new Map<number, number>();
for (const [key, value] of detail.value?.bettingDetail ?? []) {
const selection = readSelection(key);
if (selection.some((item) => item < 0)) {
continue;
}
for (const item of selection) {
result.set(item, (result.get(item) ?? 0) + value);
}
}
return result;
});
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)));
const getErrorMessage = (error: unknown): string => {
if (error instanceof Error) {
return error.message;
}
return typeof error === 'string' ? error : '요청을 처리하지 못했습니다.';
};
const parseYearMonth = (yearMonth: number): [number, number] => [
Math.floor(yearMonth / 12),
(yearMonth % 12) + 1,
];
const readSelection = (value: string): number[] => {
try {
const parsed: unknown = JSON.parse(value);
return Array.isArray(parsed)
? parsed.filter((item): item is number => typeof item === 'number' && Number.isInteger(item))
: [];
} catch {
return [];
}
};
const selectionLabel = (value: string): string =>
readSelection(value)
.map((index) => candidates.value[index]?.title ?? '-')
.join(', ');
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)
);
const matchCount = (key: string): number =>
readSelection(key).reduce((count, value) => count + (winner.value.has(value) ? 1 : 0), 0);
const rowColor = (key: string): string => {
if (!info.value?.finished) {
return '';
}
const matched = matchCount(key);
if (info.value.isExclusive) {
return matched === info.value.selectCnt ? 'green' : 'red';
}
return matched === 0 ? 'red' : matched < info.value.selectCnt ? 'yellow' : 'green';
};
const expectedMultiplier = (key: string, betAmount: number): string => {
if (betAmount <= 0) {
return '0.0';
}
if (!info.value?.finished) {
const reward = info.value?.isExclusive || info.value?.selectCnt === 1 ? totalAmount.value : totalAmount.value / 2;
return (reward / betAmount).toFixed(1);
}
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';
};
const loadList = async () => {
if (loadingList.value) {
return;
}
loadingList.value = true;
errorMessage.value = '';
try {
list.value = await bettingApi.getList.query({ req: 'bettingNation' });
} catch (error) {
errorMessage.value = getErrorMessage(error);
} finally {
loadingList.value = false;
}
};
const loadDetail = async (bettingId: number, resetSelection = true, preserveNotice = false) => {
selectedBettingId.value = bettingId;
loadingDetail.value = true;
errorMessage.value = '';
if (!preserveNotice) {
noticeMessage.value = '';
}
try {
detail.value = await bettingApi.getDetail.query({ bettingId });
if (resetSelection) {
selectedCandidates.value = [];
amount.value = 0;
}
} catch (error) {
errorMessage.value = getErrorMessage(error);
} finally {
loadingDetail.value = false;
}
};
const toggleCandidate = (index: number) => {
if (!info.value || !isDetailOpen.value) {
return;
}
const current = selectedCandidates.value;
if (current.includes(index)) {
selectedCandidates.value = current.filter((value) => value !== index);
return;
}
if (info.value.selectCnt === 1) {
selectedCandidates.value = [index];
return;
}
if (current.length >= info.value.selectCnt) {
errorMessage.value = `이미 ${info.value.selectCnt}개를 선택했습니다.`;
return;
}
selectedCandidates.value = [...current, index];
};
const submitBet = async () => {
if (!info.value || submitting.value) {
return;
}
submitting.value = true;
errorMessage.value = '';
noticeMessage.value = '';
try {
await bettingApi.bet.mutate({
bettingId: info.value.id,
bettingType: [...selectedCandidates.value],
amount: amount.value,
});
noticeMessage.value = '베팅했습니다';
await loadDetail(info.value.id, true, true);
await loadList();
} catch (error) {
// Legacy form keeps the selected candidates and amount after a failed request.
errorMessage.value = getErrorMessage(error);
} finally {
submitting.value = false;
}
};
onMounted(() => {
void loadList();
});
</script>
<template>
<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>
<h1>국가 베팅장</h1>
<div></div>
<div></div>
</header>
<div v-if="errorMessage" class="betting-notice error" role="alert">{{ errorMessage }}</div>
<div v-if="noticeMessage" class="betting-notice success" role="status">{{ noticeMessage }}</div>
<section v-if="detail && info" class="betting-detail">
<div class="section-title legacy-bg2">
{{ info.name }}
<span v-if="info.finished">(종료)</span>
<span v-else-if="currentYearMonth <= info.closeYearMonth">
({{ parseYearMonth(info.closeYearMonth)[0] }}
{{ parseYearMonth(info.closeYearMonth)[1] }}월까지)
</span>
<span v-else>(베팅 마감)</span>
(총액: {{ totalAmount.toLocaleString('ko-KR') }})
</div>
<div class="betting-candidates">
<button
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)"
>
<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>
<form v-if="isDetailOpen" class="betting-form" @submit.prevent="submitBet">
<div>
잔여 {{ info.reqInheritancePoint ? '포인트' : '금' }}:
{{ detail.remainPoint.toLocaleString('ko-KR') }}
</div>
<div>사용 포인트: {{ usedAmount.toLocaleString('ko-KR') }}</div>
<div>대상: {{ selectionLabel(selectedKey) }}</div>
<input v-model.number="amount" aria-label="베팅 금액" type="number" min="10" max="1000" step="10" />
<button type="submit" :disabled="submitting">베팅</button>
</form>
<div class="payout-table">
<div class="section-title legacy-bg2">배당 순위</div>
<div class="payout-row payout-head">
<div>대상</div>
<div>베팅액</div>
<div> 베팅</div>
<div>{{ info.finished ? '배율' : '기대 배율' }}</div>
</div>
<div v-for="[key, betAmount] in detailRows" :key="key" class="payout-row">
<div :style="{ color: rowColor(key), fontWeight: myBetMap.has(key) ? 'bold' : undefined }">
{{ selectionLabel(key) }}
</div>
<div>{{ betAmount.toLocaleString('ko-KR') }}</div>
<div>{{ myBetMap.get(key)?.toLocaleString('ko-KR') ?? '' }}</div>
<div>{{ expectedMultiplier(key, betAmount) }}</div>
</div>
</div>
</section>
<div v-if="loadingDetail && !detail" class="betting-loading">불러오는 중...</div>
<section class="betting-list">
<div class="section-title legacy-bg2">베팅 목록</div>
<button
v-for="item in listItems"
:key="item.id"
type="button"
class="betting-item"
:class="{ active: selectedBettingId === item.id }"
@click="loadDetail(item.id)"
>
[{{ parseYearMonth(item.openYearMonth)[0] }} {{ parseYearMonth(item.openYearMonth)[1] }}]
{{ item.name }}
<span v-if="item.finished">(종료)</span>
<span v-else-if="isListOpen(item)">
({{ parseYearMonth(item.closeYearMonth)[0] }}
{{ parseYearMonth(item.closeYearMonth)[1] }}월까지)
</span>
<span v-else>(베팅 마감)</span>
</button>
<div v-if="loadingList && !list" class="betting-loading">로딩 중...</div>
</section>
<footer class="betting-footer">
<RouterLink class="legacy-nav-button" to="/">돌아가기</RouterLink>
</footer>
</main>
</template>
<style scoped>
.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;
overflow-x: hidden;
}
.legacy-top-bar {
width: 100%;
height: 32px;
display: grid;
grid-template-columns: 90px 90px 1fr 90px 90px;
}
.legacy-top-bar h1 {
margin: 0;
font-size: 24px;
font-weight: 500;
line-height: 32px;
text-align: center;
}
.legacy-nav-button,
.betting-form button {
height: 32px;
border: 1px solid #004f28;
background: #00582c;
color: #fff;
font-weight: 600;
text-align: center;
text-decoration: none;
cursor: pointer;
}
.legacy-nav-button {
display: grid;
place-items: center;
margin-right: 2px;
}
.legacy-nav-button:hover,
.legacy-nav-button:focus,
.betting-form button:hover,
.betting-form button:focus {
filter: brightness(1.18);
}
.legacy-nav-button:focus-visible,
.betting-form button:focus-visible,
.betting-candidate:focus-visible,
.betting-item:focus-visible {
outline: 2px solid #f39c12;
outline-offset: 1px;
}
.section-title {
min-height: 22px;
text-align: center;
line-height: 22px;
}
.betting-candidates {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 4px;
padding: 4px;
}
.betting-candidate {
min-width: 0;
padding: 0;
border: 1px solid gray;
border-radius: 0.5em;
overflow: hidden;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.betting-candidate:disabled {
cursor: default;
opacity: 1;
}
.betting-candidate.picked {
border: 1px solid white;
outline: 1.5px solid white;
}
.candidate-title,
.candidate-info,
.candidate-rate {
display: block;
}
.candidate-title {
text-align: center;
}
.picked .candidate-title {
font-weight: 700;
}
.candidate-info,
.candidate-rate {
padding: 1ch;
}
.candidate-info span {
display: block;
}
.betting-form {
display: grid;
grid-template-columns: repeat(6, 1fr);
align-items: center;
}
.betting-form > div {
grid-column: span 3;
padding: 4px;
}
.betting-form input {
grid-column: span 4;
min-width: 0;
height: 30px;
border: 1px solid #777;
background: #ddd;
color: #303030;
}
.betting-form button {
grid-column: span 2;
}
.payout-table {
margin-top: 6px;
}
.payout-row {
display: grid;
grid-template-columns: 5fr 2fr 3fr 2fr;
}
.payout-row > div {
min-width: 0;
padding: 2px 4px;
}
.payout-row > div:not(:first-child) {
text-align: right;
}
.payout-head {
border-bottom: 1px solid gray;
}
.payout-head > div {
text-align: center;
}
.betting-list {
margin-top: 1em;
}
.betting-item {
display: block;
width: 100%;
margin: 0.25em;
border: 0;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.betting-item:hover,
.betting-item:focus,
.betting-item.active {
text-decoration: underline;
}
.betting-footer {
min-height: 52px;
padding-top: 20px;
}
.betting-footer .legacy-nav-button {
width: 90px;
}
.betting-notice,
.betting-loading {
padding: 6px 10px;
}
.betting-notice.error {
border: 1px solid #9b4848;
color: #ffd0d0;
}
.betting-notice.success {
border: 1px solid #477a47;
color: #d8f5d8;
}
@media (min-width: 1000px) {
.nation-betting-page {
width: 1000px;
}
.betting-candidates {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
.betting-form {
grid-template-columns: repeat(12, 1fr);
}
.betting-form > div {
grid-column: span 3;
}
.betting-form input {
grid-column: span 2;
}
.betting-form button {
grid-column: span 1;
}
}
</style>
+321
View File
@@ -0,0 +1,321 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { trpc } from '../utils/trpc';
type NpcList = Awaited<ReturnType<typeof trpc.public.getNpcList.query>>;
type NpcListSort = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
const sort = ref<NpcListSort>(1);
const data = ref<NpcList | 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.getNpcList.query({ sort: sort.value });
} catch (error) {
// Keep both the selected sort and the last successful table after a failed refresh.
errorMessage.value = getErrorMessage(error);
} finally {
loading.value = false;
}
};
onMounted(() => {
void load();
});
</script>
<template>
<main id="npc-list-container" class="npc-list-page">
<table class="legacy-table title-table legacy-bg0">
<tbody>
<tr>
<td>
<br />
<RouterLink class="legacy-close" to="/">돌아가기</RouterLink>
</td>
</tr>
<tr>
<td>
<form class="sort-form" @submit.prevent="load">
<label for="npc-list-sort">정렬순서 :</label>
<select id="npc-list-sort" v-model.number="sort" name="type" size="1">
<option :value="1">이름</option>
<option :value="2">국가</option>
<option :value="3">종능</option>
<option :value="4">통솔</option>
<option :value="5">무력</option>
<option :value="6">지력</option>
<option :value="7">명성</option>
<option :value="8">계급</option>
</select>
<button type="submit" :disabled="loading">정렬하기</button>
</form>
</td>
</tr>
</tbody>
</table>
<div v-if="errorMessage" class="npc-error" role="alert">{{ errorMessage }}</div>
<div v-if="loading && !data" class="npc-loading">불러오는 중...</div>
<table v-if="data" class="legacy-table npc-table legacy-bg0">
<colgroup>
<col class="col-name" />
<col class="col-owner" />
<col class="col-level" />
<col class="col-nation" />
<col class="col-personality" />
<col class="col-special" />
<col class="col-stat" />
<col class="col-leadership" />
<col class="col-strength" />
<col class="col-intelligence" />
<col class="col-experience" />
<col class="col-dedication" />
</colgroup>
<thead>
<tr class="legacy-bg1">
<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 data.generals" :key="general.id" :data-general-id="general.id">
<td :class="{ possessed: general.npcState === 1 }">{{ general.name }}</td>
<td>{{ general.ownerName }}</td>
<td>Lv {{ general.level }}</td>
<td>{{ general.nationName }}</td>
<td>
<span v-if="general.personality" class="trait-tooltip" tabindex="0">
{{ general.personality.name }}
<span role="tooltip">{{ general.personality.info }}</span>
</span>
<span v-else>-</span>
</td>
<td>
<span v-if="general.specialDomestic" class="trait-tooltip" tabindex="0">
{{ general.specialDomestic.name }}
<span role="tooltip">{{ general.specialDomestic.info }}</span>
</span>
<span v-else>-</span>
/
<span v-if="general.specialWar" class="trait-tooltip" tabindex="0">
{{ general.specialWar.name }}
<span role="tooltip">{{ general.specialWar.info }}</span>
</span>
<span v-else>-</span>
</td>
<td>{{ general.statTotal }}</td>
<td>{{ general.leadership }}</td>
<td>{{ general.strength }}</td>
<td>{{ general.intelligence }}</td>
<td>{{ general.experience }}</td>
<td>{{ general.dedication }}</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 · Legacy compatible NPC list</td>
</tr>
</tbody>
</table>
</main>
</template>
<style scoped>
.npc-list-page {
width: 1000px;
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;
}
.legacy-table {
width: 1000px;
border-collapse: collapse;
padding: 0;
table-layout: fixed;
font-size: 14px;
word-break: break-all;
}
.legacy-table td,
.legacy-table th {
border: 1px solid gray;
padding: 0;
text-align: center;
word-break: break-all;
}
.title-table td {
min-height: 20px;
}
.sort-form {
min-height: 25px;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
}
.sort-form select,
.sort-form button {
height: 23px;
font: inherit;
}
.sort-form select {
background: #ddd;
color: #303030;
}
.sort-form button {
cursor: pointer;
}
.npc-table {
margin-top: 0;
}
.npc-table th {
height: 20px;
font-weight: 400;
}
.npc-table td {
height: 20px;
}
.col-name,
.col-owner {
width: 102px;
}
.col-level,
.col-personality,
.col-stat,
.col-leadership,
.col-strength,
.col-intelligence {
width: 68px;
}
.col-nation {
width: 118px;
}
.col-special {
width: 88px;
}
.col-experience,
.col-dedication {
width: 78px;
}
.possessed {
color: skyblue;
}
.trait-tooltip {
position: relative;
cursor: help;
}
.trait-tooltip [role='tooltip'] {
display: none;
position: absolute;
z-index: 10;
left: 50%;
bottom: calc(100% + 4px);
width: 220px;
padding: 5px 7px;
transform: translateX(-50%);
border: 1px solid #888;
background: #202020;
color: #fff;
text-align: left;
word-break: keep-all;
}
.trait-tooltip:hover [role='tooltip'],
.trait-tooltip:focus [role='tooltip'] {
display: block;
}
.legacy-close {
color: #fff;
}
.legacy-close:hover,
.legacy-close:focus {
color: #fff;
text-decoration: underline;
}
.legacy-close:focus-visible,
.sort-form select:focus-visible,
.sort-form button:focus-visible {
outline: 2px solid #f39c12;
outline-offset: 1px;
}
.footer-table {
margin-top: 0;
}
.footer-table td {
height: 21px;
}
.banner {
font-size: 12px;
}
.npc-error,
.npc-loading {
width: 1000px;
box-sizing: border-box;
padding: 6px 10px;
}
.npc-error {
border: 1px solid #9b4848;
color: #ffd0d0;
}
</style>
@@ -107,6 +107,7 @@ onMounted(() => {
<RouterLink v-if="!session.isAuthed" class="ghost" to="/login">로그인</RouterLink>
<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>
<button class="ghost" @click="refreshPublicData">새로고침</button>
</div>
</header>