feat: implement Join UI for general creation and possession; add API integration for session management
This commit is contained in:
@@ -21,6 +21,16 @@ import {
|
||||
type MessageRecordDraft,
|
||||
type MessageType,
|
||||
} from '@sammo-ts/logic';
|
||||
import {
|
||||
isPersonalityTraitKey,
|
||||
isWarTraitKey,
|
||||
loadPersonalityTraitModules,
|
||||
loadWarTraitModules,
|
||||
PersonalityTraitLoader,
|
||||
PERSONALITY_TRAIT_KEYS,
|
||||
WarTraitLoader,
|
||||
WAR_TRAIT_KEYS,
|
||||
} from '@sammo-ts/logic';
|
||||
import { buildNationTarget, buildTargetFromGeneral, resolveNationInfo } from './messages/targets.js';
|
||||
import {
|
||||
fetchMessagesFromMailbox,
|
||||
@@ -82,6 +92,76 @@ const getMyGeneral = async (ctx: Pick<GameApiContext, 'db' | 'auth'>) => {
|
||||
return general;
|
||||
};
|
||||
|
||||
const DEFAULT_JOIN_STAT = {
|
||||
total: 165,
|
||||
min: 15,
|
||||
max: 80,
|
||||
bonusMin: 3,
|
||||
bonusMax: 5,
|
||||
};
|
||||
|
||||
const asRecord = (value: unknown): Record<string, unknown> =>
|
||||
value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||
|
||||
const asNumber = (value: unknown, fallback: number): number =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
|
||||
const asStringArray = (value: unknown): string[] =>
|
||||
Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
|
||||
|
||||
const resolveJoinStat = (worldState: WorldStateRow) => {
|
||||
const config = asRecord(worldState.config);
|
||||
const stat = asRecord(config.stat);
|
||||
return {
|
||||
total: asNumber(stat.total, DEFAULT_JOIN_STAT.total),
|
||||
min: asNumber(stat.min, DEFAULT_JOIN_STAT.min),
|
||||
max: asNumber(stat.max, DEFAULT_JOIN_STAT.max),
|
||||
bonusMin: DEFAULT_JOIN_STAT.bonusMin,
|
||||
bonusMax: DEFAULT_JOIN_STAT.bonusMax,
|
||||
};
|
||||
};
|
||||
|
||||
const hashString = (value: string): number => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
hash = (hash * 31 + value.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
return hash;
|
||||
};
|
||||
|
||||
const pickFromList = (values: string[], seed: string): string | null => {
|
||||
if (!values.length) {
|
||||
return null;
|
||||
}
|
||||
const index = hashString(seed) % values.length;
|
||||
return values[index] ?? null;
|
||||
};
|
||||
|
||||
let cachedPersonalityOptions: Array<{ key: string; name: string; info: string }> | null = null;
|
||||
|
||||
const loadPersonalityOptions = async () => {
|
||||
if (cachedPersonalityOptions) {
|
||||
return cachedPersonalityOptions;
|
||||
}
|
||||
const modules = await loadPersonalityTraitModules([...PERSONALITY_TRAIT_KEYS], new PersonalityTraitLoader());
|
||||
cachedPersonalityOptions = modules.map((trait) => ({
|
||||
key: trait.key,
|
||||
name: trait.name,
|
||||
info: trait.info ?? '',
|
||||
}));
|
||||
return cachedPersonalityOptions;
|
||||
};
|
||||
|
||||
const loadWarOptions = async (keys: string[]) => {
|
||||
const unique = Array.from(new Set(keys.filter((key) => isWarTraitKey(key))));
|
||||
const modules = await loadWarTraitModules(unique, new WarTraitLoader());
|
||||
return modules.map((trait) => ({
|
||||
key: trait.key,
|
||||
name: trait.name,
|
||||
info: trait.info ?? '',
|
||||
}));
|
||||
};
|
||||
|
||||
export const appRouter = router({
|
||||
health: router({
|
||||
ping: procedure.query(({ ctx }) => ({
|
||||
@@ -143,6 +223,290 @@ export const appRouter = router({
|
||||
};
|
||||
}),
|
||||
}),
|
||||
join: router({
|
||||
getConfig: authedProcedure.query(async ({ ctx }) => {
|
||||
const worldState = await ctx.db.worldState.findFirst();
|
||||
if (!worldState) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'World state is not initialized.',
|
||||
});
|
||||
}
|
||||
|
||||
const config = asRecord(worldState.config);
|
||||
const configConst = asRecord(config.const);
|
||||
const availableSpecialWar = asStringArray(configConst.availableSpecialWar);
|
||||
const warKeys = availableSpecialWar.length > 0 ? availableSpecialWar : [...WAR_TRAIT_KEYS];
|
||||
|
||||
const [personalities, warSpecials, nationRows] = await Promise.all([
|
||||
loadPersonalityOptions(),
|
||||
loadWarOptions(warKeys),
|
||||
ctx.db.nation.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
meta: true,
|
||||
},
|
||||
orderBy: { id: 'asc' },
|
||||
}),
|
||||
]);
|
||||
|
||||
const nations = nationRows.map((nation) => {
|
||||
const meta = asRecord(nation.meta);
|
||||
return {
|
||||
id: nation.id,
|
||||
name: nation.name,
|
||||
color: nation.color,
|
||||
scoutMessage: typeof meta.infoText === 'string' ? meta.infoText : null,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
rules: {
|
||||
stat: resolveJoinStat(worldState),
|
||||
allowCustomName: true,
|
||||
},
|
||||
user: {
|
||||
id: ctx.auth?.user.id ?? '',
|
||||
displayName: ctx.auth?.user.displayName ?? '',
|
||||
},
|
||||
personalities: [
|
||||
{ key: 'Random', name: '???', info: '무작위 성격을 선택합니다.' },
|
||||
...personalities,
|
||||
],
|
||||
warSpecials,
|
||||
nations,
|
||||
};
|
||||
}),
|
||||
createGeneral: authedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
name: z.string().min(1).max(18),
|
||||
leadership: z.number().int(),
|
||||
strength: z.number().int(),
|
||||
intel: z.number().int(),
|
||||
pic: z.boolean().optional(),
|
||||
character: z.string(),
|
||||
inheritSpecial: z.string().optional(),
|
||||
inheritTurntimeZone: z.number().int().optional(),
|
||||
inheritCity: z.number().int().optional(),
|
||||
inheritBonusStat: z.tuple([z.number().int(), z.number().int(), z.number().int()]).optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.auth?.user.id;
|
||||
if (!userId) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
|
||||
const worldState = await ctx.db.worldState.findFirst();
|
||||
if (!worldState) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'World state is not initialized.',
|
||||
});
|
||||
}
|
||||
|
||||
const statRule = resolveJoinStat(worldState);
|
||||
const statTotal = input.leadership + input.strength + input.intel;
|
||||
|
||||
if (
|
||||
input.leadership < statRule.min ||
|
||||
input.strength < statRule.min ||
|
||||
input.intel < statRule.min ||
|
||||
input.leadership > statRule.max ||
|
||||
input.strength > statRule.max ||
|
||||
input.intel > statRule.max
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: '능력치 범위를 벗어났습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
if (statTotal > statRule.total) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `능력치 합이 ${statRule.total}을 초과했습니다.`,
|
||||
});
|
||||
}
|
||||
|
||||
const personalityOptions = await loadPersonalityOptions();
|
||||
const personalityKeys = personalityOptions.map((trait) => trait.key);
|
||||
const chosenPersonality =
|
||||
input.character === 'Random'
|
||||
? pickFromList(personalityKeys, `${userId}:${input.name}`) ?? 'None'
|
||||
: isPersonalityTraitKey(input.character)
|
||||
? input.character
|
||||
: 'None';
|
||||
|
||||
return ctx.db.$transaction(async (db) => {
|
||||
const existing = await db.general.findFirst({ where: { userId } });
|
||||
if (existing) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: '이미 장수가 생성되어 있습니다.',
|
||||
});
|
||||
}
|
||||
const nameExists = await db.general.findFirst({ where: { name: input.name } });
|
||||
if (nameExists) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: '이미 존재하는 장수명입니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const maxId = await db.general.aggregate({ _max: { id: true } });
|
||||
const nextId = (maxId._max.id ?? 0) + 1;
|
||||
const cityList = await db.city.findMany({ select: { id: true }, orderBy: { id: 'asc' } });
|
||||
if (!cityList.length) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: '도시 정보를 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
const cityIndex = hashString(userId) % cityList.length;
|
||||
const cityId = cityList[cityIndex]?.id ?? cityList[0].id;
|
||||
|
||||
const general = await db.general.create({
|
||||
data: {
|
||||
id: nextId,
|
||||
userId,
|
||||
name: input.name,
|
||||
nationId: 0,
|
||||
cityId,
|
||||
troopId: 0,
|
||||
npcState: 0,
|
||||
leadership: input.leadership,
|
||||
strength: input.strength,
|
||||
intel: input.intel,
|
||||
personalCode: chosenPersonality ?? 'None',
|
||||
specialCode: 'None',
|
||||
special2Code: 'None',
|
||||
turnTime: new Date(),
|
||||
meta: {
|
||||
createdBy: 'join',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return { ok: true, generalId: general.id };
|
||||
});
|
||||
}),
|
||||
listPossessCandidates: authedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
limit: z.number().int().min(1).max(50).optional(),
|
||||
offset: z.number().int().min(0).optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const limit = input.limit ?? 20;
|
||||
const offset = input.offset ?? 0;
|
||||
|
||||
const candidates = await ctx.db.general.findMany({
|
||||
where: {
|
||||
userId: null,
|
||||
npcState: { gte: 2 },
|
||||
},
|
||||
orderBy: { id: 'asc' },
|
||||
skip: offset,
|
||||
take: limit,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
npcState: true,
|
||||
nationId: true,
|
||||
cityId: true,
|
||||
leadership: true,
|
||||
strength: true,
|
||||
intel: true,
|
||||
age: true,
|
||||
officerLevel: true,
|
||||
personalCode: true,
|
||||
specialCode: true,
|
||||
special2Code: true,
|
||||
picture: true,
|
||||
imageServer: true,
|
||||
},
|
||||
});
|
||||
|
||||
const [nationRows, cityRows] = await Promise.all([
|
||||
ctx.db.nation.findMany({ select: { id: true, name: true, color: true } }),
|
||||
ctx.db.city.findMany({ select: { id: true, name: true } }),
|
||||
]);
|
||||
const nationMap = new Map(nationRows.map((nation) => [nation.id, nation]));
|
||||
const cityMap = new Map(cityRows.map((city) => [city.id, city]));
|
||||
|
||||
return candidates.map((candidate) => {
|
||||
const nation = nationMap.get(candidate.nationId);
|
||||
const city = cityMap.get(candidate.cityId);
|
||||
return {
|
||||
id: candidate.id,
|
||||
name: candidate.name,
|
||||
npcState: candidate.npcState,
|
||||
nation: nation
|
||||
? { id: nation.id, name: nation.name, color: nation.color }
|
||||
: { id: 0, name: '재야', color: '#666666' },
|
||||
city: city ? { id: city.id, name: city.name } : null,
|
||||
stats: {
|
||||
leadership: candidate.leadership,
|
||||
strength: candidate.strength,
|
||||
intelligence: candidate.intel,
|
||||
},
|
||||
age: candidate.age,
|
||||
officerLevel: candidate.officerLevel,
|
||||
personality: candidate.personalCode,
|
||||
special: candidate.specialCode,
|
||||
specialWar: candidate.special2Code,
|
||||
picture: candidate.picture,
|
||||
imageServer: candidate.imageServer,
|
||||
};
|
||||
});
|
||||
}),
|
||||
possessGeneral: authedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
generalId: z.number().int().positive(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.auth?.user.id;
|
||||
if (!userId) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
const existing = await ctx.db.general.findFirst({ where: { userId } });
|
||||
if (existing) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: '이미 장수가 생성되어 있습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const updated = await ctx.db.general.updateMany({
|
||||
where: {
|
||||
id: input.generalId,
|
||||
userId: null,
|
||||
npcState: { gte: 2 },
|
||||
},
|
||||
data: {
|
||||
userId,
|
||||
npcState: 1,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
if (updated.count === 0) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: '빙의 가능한 장수를 찾지 못했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}),
|
||||
}),
|
||||
battle: router({
|
||||
simulate: procedure.input(zBattleSimRequest).mutation(async ({ ctx, input }) => {
|
||||
const worldState = await ctx.db.worldState.findFirst();
|
||||
|
||||
@@ -1,10 +1,534 @@
|
||||
<script setup lang="ts"></script>
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import PanelCard from '../components/ui/PanelCard.vue';
|
||||
import SkeletonLines from '../components/ui/SkeletonLines.vue';
|
||||
import { trpc } from '../utils/trpc';
|
||||
import { useSessionStore } from '../stores/session';
|
||||
|
||||
type JoinConfig = Awaited<ReturnType<typeof trpc.join.getConfig.query>>;
|
||||
type JoinInput = Parameters<typeof trpc.join.createGeneral.mutate>[0];
|
||||
type PossessCandidate = Awaited<ReturnType<typeof trpc.join.listPossessCandidates.query>>[0];
|
||||
|
||||
const router = useRouter();
|
||||
const session = useSessionStore();
|
||||
|
||||
const loading = ref(true);
|
||||
const error = ref<string | null>(null);
|
||||
const submitting = ref(false);
|
||||
|
||||
const joinConfig = ref<JoinConfig | null>(null);
|
||||
const activeTab = ref<'create' | 'possess'>('create');
|
||||
|
||||
const form = ref<JoinInput>({
|
||||
name: '',
|
||||
leadership: 0,
|
||||
strength: 0,
|
||||
intel: 0,
|
||||
character: 'Random',
|
||||
pic: true,
|
||||
});
|
||||
|
||||
const npcCandidates = ref<PossessCandidate[]>([]);
|
||||
const npcLoading = ref(false);
|
||||
const npcError = ref<string | null>(null);
|
||||
const npcOffset = ref(0);
|
||||
const npcLimit = 20;
|
||||
|
||||
const statRules = computed(() => joinConfig.value?.rules.stat ?? null);
|
||||
const statTotal = computed(() => form.value.leadership + form.value.strength + form.value.intel);
|
||||
const statErrors = computed(() => {
|
||||
const rules = statRules.value;
|
||||
if (!rules) {
|
||||
return [] as string[];
|
||||
}
|
||||
const errors: string[] = [];
|
||||
const values = [form.value.leadership, form.value.strength, form.value.intel];
|
||||
if (values.some((value) => value < rules.min || value > rules.max)) {
|
||||
errors.push(`능력치는 ${rules.min} ~ ${rules.max} 범위여야 합니다.`);
|
||||
}
|
||||
if (statTotal.value > rules.total) {
|
||||
errors.push(`능력치 합이 ${rules.total}을 넘을 수 없습니다.`);
|
||||
}
|
||||
return errors;
|
||||
});
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
if (!statRules.value) {
|
||||
return false;
|
||||
}
|
||||
if (!form.value.name.trim()) {
|
||||
return false;
|
||||
}
|
||||
if (statErrors.value.length > 0) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const nationList = computed(() => joinConfig.value?.nations ?? []);
|
||||
const personalities = computed(() => joinConfig.value?.personalities ?? []);
|
||||
|
||||
const randomInt = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
|
||||
const applyBalancedStats = () => {
|
||||
const rules = statRules.value;
|
||||
if (!rules) {
|
||||
return;
|
||||
}
|
||||
const base = Math.floor(rules.total / 3);
|
||||
form.value.leadership = rules.total - base * 2;
|
||||
form.value.strength = base;
|
||||
form.value.intel = base;
|
||||
};
|
||||
|
||||
const applyRandomStats = () => {
|
||||
const rules = statRules.value;
|
||||
if (!rules) {
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < 40; i += 1) {
|
||||
const leadership = randomInt(rules.min, rules.max);
|
||||
const strength = randomInt(rules.min, rules.max);
|
||||
const intel = rules.total - leadership - strength;
|
||||
if (intel >= rules.min && intel <= rules.max) {
|
||||
form.value.leadership = leadership;
|
||||
form.value.strength = strength;
|
||||
form.value.intel = intel;
|
||||
return;
|
||||
}
|
||||
}
|
||||
applyBalancedStats();
|
||||
};
|
||||
|
||||
const applyFocusedStats = (focus: 'leadership' | 'strength' | 'intel') => {
|
||||
const rules = statRules.value;
|
||||
if (!rules) {
|
||||
return;
|
||||
}
|
||||
const focusValue = Math.min(rules.max, rules.min + Math.floor(rules.total * 0.45));
|
||||
const remain = rules.total - focusValue;
|
||||
const side = Math.floor(remain / 2);
|
||||
form.value.leadership = focus === 'leadership' ? focusValue : side;
|
||||
form.value.strength = focus === 'strength' ? focusValue : side;
|
||||
form.value.intel = focus === 'intel' ? focusValue : remain - side;
|
||||
};
|
||||
|
||||
const loadConfig = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const config = await trpc.join.getConfig.query();
|
||||
joinConfig.value = config;
|
||||
form.value.name = config.user.displayName || '';
|
||||
applyBalancedStats();
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'join_config_failed';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadNpcCandidates = async (reset = false) => {
|
||||
npcLoading.value = true;
|
||||
npcError.value = null;
|
||||
try {
|
||||
if (reset) {
|
||||
npcOffset.value = 0;
|
||||
}
|
||||
const list = await trpc.join.listPossessCandidates.query({
|
||||
limit: npcLimit,
|
||||
offset: npcOffset.value,
|
||||
});
|
||||
npcCandidates.value = reset ? list : [...npcCandidates.value, ...list];
|
||||
npcOffset.value += list.length;
|
||||
} catch (err) {
|
||||
npcError.value = err instanceof Error ? err.message : 'npc_list_failed';
|
||||
} finally {
|
||||
npcLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submitJoin = async () => {
|
||||
if (!canSubmit.value || submitting.value) {
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
await trpc.join.createGeneral.mutate(form.value);
|
||||
await session.refreshGeneralStatus();
|
||||
if (session.hasGeneral) {
|
||||
await router.push({ name: 'home' });
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'join_failed';
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const possessGeneral = async (generalId: number) => {
|
||||
if (submitting.value) {
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
await trpc.join.possessGeneral.mutate({ generalId });
|
||||
await session.refreshGeneralStatus();
|
||||
if (session.hasGeneral) {
|
||||
await router.push({ name: 'home' });
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'possess_failed';
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(activeTab, (value) => {
|
||||
if (value === 'possess' && npcCandidates.value.length === 0) {
|
||||
void loadNpcCandidates(true);
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
void loadConfig();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="min-h-screen px-6 py-8">
|
||||
<h1 class="text-2xl font-semibold">Create or Possess General</h1>
|
||||
<p class="mt-2 text-sm text-amber-200/80">
|
||||
You are logged in but do not have a playable general yet.
|
||||
</p>
|
||||
<main class="join-page">
|
||||
<header class="join-header">
|
||||
<div>
|
||||
<h1 class="join-title">장수 생성/빙의</h1>
|
||||
<p class="join-subtitle">로그인 완료, 아직 장수가 없는 상태입니다.</p>
|
||||
</div>
|
||||
<div class="join-tabs">
|
||||
<button :class="{ active: activeTab === 'create' }" @click="activeTab = 'create'">장수 생성</button>
|
||||
<button :class="{ active: activeTab === 'possess' }" @click="activeTab = 'possess'">NPC 빙의</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="error" class="join-error">{{ error }}</div>
|
||||
|
||||
<div v-if="loading">
|
||||
<SkeletonLines :lines="4" />
|
||||
</div>
|
||||
|
||||
<section v-else-if="activeTab === 'create'" class="join-grid">
|
||||
<PanelCard title="국가 임관 권유">
|
||||
<div v-if="nationList.length === 0" class="muted">국가 정보가 아직 준비되지 않았습니다.</div>
|
||||
<div v-else class="nation-list">
|
||||
<div v-for="nation in nationList" :key="nation.id" class="nation-card">
|
||||
<div class="nation-name" :style="{ backgroundColor: nation.color }">{{ nation.name }}</div>
|
||||
<div class="nation-message">
|
||||
{{ nation.scoutMessage ?? '권유문 없음' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PanelCard>
|
||||
|
||||
<PanelCard title="장수 기본 정보" subtitle="능력치와 성격을 지정합니다.">
|
||||
<div class="form-grid">
|
||||
<label class="form-field">
|
||||
<span>장수명</span>
|
||||
<input v-model="form.name" type="text" class="form-input" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>성격</span>
|
||||
<select v-model="form.character" class="form-input">
|
||||
<option v-for="option in personalities" :key="option.key" :value="option.key">
|
||||
{{ option.name }}
|
||||
</option>
|
||||
</select>
|
||||
<small class="muted">{{ personalities.find((p) => p.key === form.character)?.info }}</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="stat-grid">
|
||||
<label class="form-field">
|
||||
<span>통솔</span>
|
||||
<input v-model.number="form.leadership" type="number" class="form-input" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>무력</span>
|
||||
<input v-model.number="form.strength" type="number" class="form-input" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>지력</span>
|
||||
<input v-model.number="form.intel" type="number" class="form-input" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="stat-actions">
|
||||
<button @click="applyRandomStats">랜덤형</button>
|
||||
<button @click="applyFocusedStats('leadership')">통솔형</button>
|
||||
<button @click="applyFocusedStats('strength')">무력형</button>
|
||||
<button @click="applyFocusedStats('intel')">지력형</button>
|
||||
<button @click="applyBalancedStats">균형형</button>
|
||||
</div>
|
||||
|
||||
<div class="stat-summary">
|
||||
<div>능력치 합계: {{ statTotal }} / {{ statRules?.total ?? '-' }}</div>
|
||||
<div v-if="statErrors.length" class="stat-errors">
|
||||
<div v-for="item in statErrors" :key="item">{{ item }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button :disabled="!canSubmit || submitting" @click="submitJoin">장수 생성</button>
|
||||
<button class="ghost" @click="applyBalancedStats">다시 입력</button>
|
||||
</div>
|
||||
</PanelCard>
|
||||
|
||||
<PanelCard title="유산/빙의 안내" subtitle="유산 포인트와 추가 옵션은 추후 이식 예정입니다.">
|
||||
<div class="muted">특기/도시 선택, 턴 시간 지정은 향후 UI와 함께 제공됩니다.</div>
|
||||
</PanelCard>
|
||||
</section>
|
||||
|
||||
<section v-else class="join-grid">
|
||||
<PanelCard title="빙의 가능한 NPC 목록" subtitle="NPC 타입2 장수를 선택해 빙의합니다.">
|
||||
<template #actions>
|
||||
<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 class="npc-list">
|
||||
<div v-for="npc in npcCandidates" :key="npc.id" class="npc-card">
|
||||
<div class="npc-header">
|
||||
<div class="npc-name">{{ npc.name }}</div>
|
||||
<div class="npc-nation" :style="{ color: npc.nation.color }">
|
||||
{{ npc.nation.name }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="npc-meta">
|
||||
<div>통솔 {{ npc.stats.leadership }}</div>
|
||||
<div>무력 {{ npc.stats.strength }}</div>
|
||||
<div>지력 {{ npc.stats.intelligence }}</div>
|
||||
<div>나이 {{ npc.age }}</div>
|
||||
<div>도시 {{ npc.city?.name ?? '-' }}</div>
|
||||
</div>
|
||||
<button class="npc-action" :disabled="submitting" @click="possessGeneral(npc.id)">
|
||||
빙의
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="npc-footer">
|
||||
<button class="ghost" :disabled="npcLoading" @click="loadNpcCandidates()">더 보기</button>
|
||||
</div>
|
||||
</PanelCard>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.join-page {
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.join-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
border-bottom: 1px solid rgba(201, 164, 90, 0.4);
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.join-title {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.join-subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: rgba(232, 221, 196, 0.7);
|
||||
}
|
||||
|
||||
.join-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.join-tabs button {
|
||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
||||
padding: 6px 10px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.join-tabs button.active {
|
||||
background: rgba(201, 164, 90, 0.2);
|
||||
}
|
||||
|
||||
.join-error {
|
||||
border: 1px solid rgba(240, 90, 90, 0.6);
|
||||
padding: 8px 10px;
|
||||
color: rgba(240, 150, 150, 0.9);
|
||||
}
|
||||
|
||||
.join-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
}
|
||||
|
||||
.nation-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.nation-card {
|
||||
border: 1px solid rgba(201, 164, 90, 0.25);
|
||||
padding: 8px;
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.nation-name {
|
||||
font-weight: 600;
|
||||
padding: 4px 6px;
|
||||
color: #101010;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nation-message {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(232, 221, 196, 0.7);
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.form-field {
|
||||
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;
|
||||
}
|
||||
|
||||
.stat-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.stat-actions button {
|
||||
border: 1px solid rgba(201, 164, 90, 0.3);
|
||||
padding: 4px 8px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.stat-summary {
|
||||
margin-top: 10px;
|
||||
font-size: 0.75rem;
|
||||
color: rgba(232, 221, 196, 0.7);
|
||||
}
|
||||
|
||||
.stat-errors {
|
||||
margin-top: 4px;
|
||||
color: rgba(240, 150, 150, 0.9);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-actions button {
|
||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
||||
padding: 6px 12px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.form-actions .ghost,
|
||||
.join-tabs .ghost,
|
||||
.npc-footer .ghost {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.npc-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.npc-card {
|
||||
border: 1px solid rgba(201, 164, 90, 0.3);
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.npc-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.npc-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.npc-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
|
||||
gap: 4px;
|
||||
font-size: 0.7rem;
|
||||
color: rgba(232, 221, 196, 0.7);
|
||||
}
|
||||
|
||||
.npc-action {
|
||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
||||
padding: 4px 8px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.npc-footer {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: rgba(232, 221, 196, 0.6);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.ghost {
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -131,6 +131,8 @@
|
||||
- 지도 선택 연동: 클릭 시 선택 도시 패널/명령 패널에 연결
|
||||
- 레거시 맵 렌더링 보강: 테마/계절 배경, 도로 레이어, 성/이벤트 아이콘, 상태색 로직 이식
|
||||
- 지도 아이콘 베이스 경로: `VITE_GAME_ASSET_URL`로 레거시 이미지 경로 주입
|
||||
- Join/빙의 UI 구현: 장수 생성/빙의 탭, NPC 목록 로딩, 생성/빙의 후 세션 상태 갱신 및 메인 이동
|
||||
- 게임 API: `join.getConfig`, `join.createGeneral`, `join.listPossessCandidates`, `join.possessGeneral` 추가
|
||||
|
||||
## Next Frontend Tasks
|
||||
|
||||
@@ -140,7 +142,7 @@
|
||||
- MapViewer 비주얼 보강: 레거시 테마/아이콘/맵 배경 스타일 상세 이식
|
||||
- 레거시 이미지 서빙 위치 확정 및 SPA 배포 시 정적 경로 매핑
|
||||
- CommandSelectForm/MessagePanel 이식 마무리(예약/전송 플로우 연결)
|
||||
- Join/빙의 UI 구현 및 완료 후 상태 갱신(장수 생성 감지)
|
||||
- 유산 포인트/추가 옵션(도시·특기·턴타임) 이식 및 서버 검증 규칙 합의
|
||||
- 화면 라우트 매핑 표 및 데이터 계약 문서화
|
||||
|
||||
## Deliverables
|
||||
|
||||
Reference in New Issue
Block a user