merge: add secure troop management
# Conflicts: # app/game-engine/src/turn/types.ts # app/game-engine/src/turn/worldLoader.ts
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||
|
||||
export default defineConfig({
|
||||
testDir: '.',
|
||||
testMatch: 'troop.spec.ts',
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
timeout: 30_000,
|
||||
expect: {
|
||||
timeout: 5_000,
|
||||
},
|
||||
reporter: [['list'], ['html', { open: 'never', outputFolder: resolve(repositoryRoot, 'playwright-report') }]],
|
||||
outputDir: resolve(repositoryRoot, 'test-results/troop'),
|
||||
use: {
|
||||
baseURL: 'http://127.0.0.1:15120/che/',
|
||||
...devices['Desktop Chrome'],
|
||||
deviceScaleFactor: 1,
|
||||
colorScheme: 'dark',
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
webServer: {
|
||||
command:
|
||||
'VITE_APP_BASE_PATH=/che VITE_GAME_API_URL=/che/api/trpc pnpm --filter @sammo-ts/game-frontend dev --host 127.0.0.1 --port 15120',
|
||||
cwd: repositoryRoot,
|
||||
url: 'http://127.0.0.1:15120/che/',
|
||||
reuseExistingServer: false,
|
||||
timeout: 120_000,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,359 @@
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||
const imageRoots = [resolve(repositoryRoot, '../image/game'), resolve(repositoryRoot, '../../image/game')];
|
||||
|
||||
const readReferenceImage = async (filename: string): Promise<Buffer> => {
|
||||
for (const imageRoot of imageRoots) {
|
||||
try {
|
||||
return await readFile(resolve(imageRoot, filename));
|
||||
} catch {
|
||||
// The main checkout and nested feature worktrees have different parents.
|
||||
}
|
||||
}
|
||||
throw new Error(`Reference image not found: ${filename}`);
|
||||
};
|
||||
|
||||
type Member = { id: number; name: string; cityId: number; cityName: string };
|
||||
type TroopFixture = {
|
||||
id: number;
|
||||
name: string;
|
||||
nationId: number;
|
||||
turnTime: string;
|
||||
reservedCommands: string[];
|
||||
leader: {
|
||||
id: number;
|
||||
name: string;
|
||||
cityId: number;
|
||||
cityName: string;
|
||||
picture: string | null;
|
||||
imageServer: number;
|
||||
};
|
||||
members: Member[];
|
||||
};
|
||||
type FixtureState = {
|
||||
me: { id: number; troopId: number };
|
||||
permission: number;
|
||||
troops: TroopFixture[];
|
||||
failCreate?: boolean;
|
||||
};
|
||||
|
||||
const baseTroops = (): TroopFixture[] => [
|
||||
{
|
||||
id: 1,
|
||||
name: '백마대',
|
||||
nationId: 1,
|
||||
turnTime: '2026-07-25T08:20:30.000Z',
|
||||
reservedCommands: ['che_집합', 'che_이동'],
|
||||
leader: {
|
||||
id: 1,
|
||||
name: '공손찬',
|
||||
cityId: 1,
|
||||
cityName: '북평',
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
},
|
||||
members: [
|
||||
{ id: 1, name: '공손찬', cityId: 1, cityName: '북평' },
|
||||
{ id: 3, name: '조운', cityId: 1, cityName: '북평' },
|
||||
{ id: 4, name: '전예', cityId: 2, cityName: '계' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '청룡대',
|
||||
nationId: 1,
|
||||
turnTime: '2026-07-25T08:30:30.000Z',
|
||||
reservedCommands: ['che_징병'],
|
||||
leader: {
|
||||
id: 2,
|
||||
name: '관우',
|
||||
cityId: 2,
|
||||
cityName: '계',
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
},
|
||||
members: [{ id: 2, name: '관우', cityId: 2, cityName: '계' }],
|
||||
},
|
||||
];
|
||||
|
||||
const response = (data: unknown) => ({ result: { data } });
|
||||
const errorResponse = (path: string, message: string) => ({
|
||||
error: {
|
||||
message,
|
||||
code: -32000,
|
||||
data: { code: 'BAD_REQUEST', httpStatus: 400, path },
|
||||
},
|
||||
});
|
||||
const operationName = (route: Route): string => {
|
||||
const url = new URL(route.request().url());
|
||||
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6));
|
||||
};
|
||||
|
||||
const fulfillJson = async (route: Route, body: unknown) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
};
|
||||
|
||||
const gotoTroop = async (page: Page) => {
|
||||
const lobbyResponse = page.waitForResponse((response) => response.url().includes('/trpc/lobby.info'));
|
||||
await page.goto('troop');
|
||||
await lobbyResponse;
|
||||
};
|
||||
|
||||
const installApiFixture = async (page: Page, state: FixtureState) => {
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem('sammo-game-token', 'ga_playwright');
|
||||
window.localStorage.setItem('sammo-game-profile', 'che:default');
|
||||
});
|
||||
for (const filename of ['back_walnut.jpg', 'back_green.jpg']) {
|
||||
await page.route(`**/image/game/${filename}`, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'image/jpeg',
|
||||
body: await readReferenceImage(filename),
|
||||
});
|
||||
});
|
||||
}
|
||||
await page.route('**/image/icons/**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'image/png',
|
||||
body: Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
'base64'
|
||||
),
|
||||
});
|
||||
});
|
||||
await page.route('**/che/api/trpc/**', async (route) => {
|
||||
const operations = operationName(route).split(',');
|
||||
const results = operations.map((operation) => {
|
||||
if (operation === 'lobby.info') {
|
||||
return response({ myGeneral: { id: state.me.id, name: '테스트 장수' } });
|
||||
}
|
||||
if (operation === 'join.getConfig') {
|
||||
return response({});
|
||||
}
|
||||
if (operation === 'troop.getList') {
|
||||
return response({
|
||||
nation: { id: 1, name: '테스트국' },
|
||||
me: state.me,
|
||||
permission: state.permission,
|
||||
troops: state.troops,
|
||||
});
|
||||
}
|
||||
if (operation === 'troop.create') {
|
||||
if (state.failCreate) {
|
||||
state.failCreate = false;
|
||||
return errorResponse(operation, '부대 이름이 없습니다.');
|
||||
}
|
||||
const createdId = state.me.id;
|
||||
state.me.troopId = createdId;
|
||||
state.troops.push({
|
||||
id: createdId,
|
||||
name: '신규대',
|
||||
nationId: 1,
|
||||
turnTime: '2026-07-25T08:40:30.000Z',
|
||||
reservedCommands: [],
|
||||
leader: {
|
||||
id: createdId,
|
||||
name: '유비',
|
||||
cityId: 1,
|
||||
cityName: '북평',
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
},
|
||||
members: [{ id: createdId, name: '유비', cityId: 1, cityName: '북평' }],
|
||||
});
|
||||
return response({ ok: true, troopId: createdId, troopName: '신규대' });
|
||||
}
|
||||
if (operation === 'troop.rename') {
|
||||
state.troops[0]!.name = '백마의종';
|
||||
return response({ ok: true, troopName: '백마의종' });
|
||||
}
|
||||
if (operation === 'troop.kick') {
|
||||
state.troops[0]!.members = state.troops[0]!.members.filter((member) => member.id !== 3);
|
||||
return response({ ok: true });
|
||||
}
|
||||
return errorResponse(operation, `Unhandled fixture operation: ${operation}`);
|
||||
});
|
||||
await fulfillJson(route, results);
|
||||
});
|
||||
};
|
||||
|
||||
test('renders the legacy desktop grid with matching computed geometry and states', async ({ page }) => {
|
||||
await installApiFixture(page, {
|
||||
me: { id: 1, troopId: 1 },
|
||||
permission: 4,
|
||||
troops: baseTroops(),
|
||||
});
|
||||
await page.setViewportSize({ width: 1000, height: 800 });
|
||||
await gotoTroop(page);
|
||||
await expect(page.locator('.troopInfo').filter({ hasText: '백마대' })).toBeVisible();
|
||||
|
||||
const geometry = await page
|
||||
.locator('.troopItem')
|
||||
.first()
|
||||
.evaluate((item) => {
|
||||
const origin = item.getBoundingClientRect();
|
||||
const box = (selector: string) => {
|
||||
const rect = item.querySelector<HTMLElement>(selector)!.getBoundingClientRect();
|
||||
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
||||
};
|
||||
const members = item.querySelector<HTMLElement>('.troopMembers')!;
|
||||
const style = getComputedStyle(members);
|
||||
return {
|
||||
item: { x: origin.x, y: origin.y, width: origin.width, height: origin.height },
|
||||
info: box('.troopInfo'),
|
||||
icon: box('.troopLeaderIcon'),
|
||||
reserved: box('.troopReservedCommand'),
|
||||
members: box('.troopMembers'),
|
||||
action: box('.troopAction'),
|
||||
membersStyle: {
|
||||
paddingTop: style.paddingTop,
|
||||
paddingLeft: style.paddingLeft,
|
||||
textAlign: style.textAlign,
|
||||
fontFamily: style.fontFamily,
|
||||
fontSize: style.fontSize,
|
||||
lineHeight: style.lineHeight,
|
||||
},
|
||||
};
|
||||
});
|
||||
expect(geometry.item).toEqual({ x: 0, y: 32, width: 1000, height: 127.5 });
|
||||
expect(geometry.info.width).toBeCloseTo(130, 0);
|
||||
expect(geometry.info.height).toBeCloseTo(65, 0);
|
||||
expect(geometry.icon.x - geometry.info.x).toBeCloseTo(130, 0);
|
||||
expect(geometry.reserved.x - geometry.info.x).toBeCloseTo(260, 0);
|
||||
expect(geometry.members.x - geometry.info.x).toBeCloseTo(360, 0);
|
||||
expect(geometry.members.width).toBeCloseTo(639, 0);
|
||||
expect(geometry.members.height).toBeCloseTo(93, 0);
|
||||
expect(geometry.action.x - geometry.info.x).toBeCloseTo(65, 0);
|
||||
expect(geometry.action.y - geometry.info.y).toBeCloseTo(93, 0);
|
||||
expect(geometry.action.width).toBeCloseTo(934, 0);
|
||||
expect(geometry.membersStyle).toEqual({
|
||||
paddingTop: '7px',
|
||||
paddingLeft: '9.8px',
|
||||
textAlign: 'left',
|
||||
fontFamily: 'Pretendard, "Apple SD Gothic Neo", "Noto Sans KR", "Malgun Gothic"',
|
||||
fontSize: '14px',
|
||||
lineHeight: '21px',
|
||||
});
|
||||
|
||||
const kickButton = page.getByRole('button', { name: '부대원 추방...' }).first();
|
||||
await kickButton.hover();
|
||||
const hoverStyle = await kickButton.evaluate((button) => ({
|
||||
cursor: getComputedStyle(button).cursor,
|
||||
filter: getComputedStyle(button).filter,
|
||||
}));
|
||||
expect(hoverStyle.cursor).toBe('pointer');
|
||||
expect(hoverStyle.filter).not.toBe('none');
|
||||
|
||||
await page.locator('.troopMember').nth(1).hover();
|
||||
await expect(page.getByRole('tooltip')).toContainText('조운');
|
||||
expect(await page.getByRole('tooltip').evaluate((tooltip) => tooltip.getBoundingClientRect().width)).toBeCloseTo(
|
||||
500,
|
||||
0
|
||||
);
|
||||
await page.screenshot({ path: 'test-results/troop/desktop-leader.png', fullPage: true });
|
||||
});
|
||||
|
||||
test('matches the legacy 500px responsive placement', async ({ page }) => {
|
||||
await installApiFixture(page, {
|
||||
me: { id: 1, troopId: 1 },
|
||||
permission: 4,
|
||||
troops: baseTroops(),
|
||||
});
|
||||
await page.setViewportSize({ width: 500, height: 800 });
|
||||
await gotoTroop(page);
|
||||
await expect(page.locator('.troopInfo').filter({ hasText: '백마대' })).toBeVisible();
|
||||
|
||||
const geometry = await page
|
||||
.locator('.troopItem')
|
||||
.first()
|
||||
.evaluate((item) => {
|
||||
const origin = item.getBoundingClientRect();
|
||||
const relative = (selector: string) => {
|
||||
const rect = item.querySelector<HTMLElement>(selector)!.getBoundingClientRect();
|
||||
return { x: rect.x - origin.x, y: rect.y - origin.y, width: rect.width };
|
||||
};
|
||||
return {
|
||||
item: { width: origin.width, height: origin.height },
|
||||
info: relative('.troopInfo'),
|
||||
icon: relative('.troopLeaderIcon'),
|
||||
reserved: relative('.troopReservedCommand'),
|
||||
action: relative('.troopAction'),
|
||||
members: relative('.troopMembers'),
|
||||
};
|
||||
});
|
||||
expect(geometry.item).toEqual({ width: 500, height: 129 });
|
||||
expect(geometry.info).toMatchObject({ x: 0, y: 0, width: 130 });
|
||||
expect(geometry.icon).toMatchObject({ x: 130, y: 0, width: 130 });
|
||||
expect(geometry.reserved).toMatchObject({ x: 260, y: 0, width: 100 });
|
||||
expect(geometry.action).toMatchObject({ x: 360, y: 0, width: 140 });
|
||||
expect(geometry.members).toMatchObject({ x: 130, y: 93, width: 370 });
|
||||
await page.screenshot({ path: 'test-results/troop/mobile-leader.png', fullPage: true });
|
||||
});
|
||||
|
||||
test('shows API failure then creates a troop successfully', async ({ page }) => {
|
||||
const state: FixtureState = {
|
||||
me: { id: 7, troopId: 0 },
|
||||
permission: 0,
|
||||
troops: baseTroops(),
|
||||
failCreate: true,
|
||||
};
|
||||
await installApiFixture(page, state);
|
||||
await gotoTroop(page);
|
||||
|
||||
const input = page.getByRole('textbox', { name: '부대명' });
|
||||
await input.fill('실패대');
|
||||
await page.getByRole('button', { name: '부대 창설', exact: true }).click();
|
||||
await expect(page.getByRole('alert')).toContainText('부대 이름이 없습니다.');
|
||||
expect(state.me.troopId).toBe(0);
|
||||
|
||||
await input.fill('신규대');
|
||||
await page.getByRole('button', { name: '부대 창설', exact: true }).click();
|
||||
await expect(page.getByRole('status')).toContainText('신규대 부대가 생성되었습니다.');
|
||||
await expect(page.locator('.troopInfo').filter({ hasText: '신규대' })).toBeVisible();
|
||||
await expect(input).toBeHidden();
|
||||
});
|
||||
|
||||
test('renames and kicks through confirm dialogs, then refreshes state', async ({ page }) => {
|
||||
const state: FixtureState = {
|
||||
me: { id: 1, troopId: 1 },
|
||||
permission: 4,
|
||||
troops: baseTroops(),
|
||||
};
|
||||
await installApiFixture(page, state);
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
await gotoTroop(page);
|
||||
|
||||
await page.getByRole('button', { name: '부대명 변경...' }).first().click();
|
||||
await page.getByRole('textbox', { name: '새 부대명' }).fill('백마의종');
|
||||
await page.getByRole('button', { name: '변경', exact: true }).click();
|
||||
await expect(page.getByRole('status')).toContainText('부대명을 변경했습니다.');
|
||||
await expect(page.locator('.troopInfo').filter({ hasText: '백마의종' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: '부대원 추방...' }).click();
|
||||
await page.getByRole('combobox', { name: '추방할 부대원' }).selectOption('3');
|
||||
await page.getByRole('button', { name: '추방', exact: true }).click();
|
||||
await expect(page.getByRole('status')).toContainText('조운을 추방했습니다.');
|
||||
await expect(page.locator('.troopMembers').first()).not.toContainText('조운');
|
||||
});
|
||||
|
||||
test('does not render management controls for an unauthorized member', async ({ page }) => {
|
||||
await installApiFixture(page, {
|
||||
me: { id: 3, troopId: 1 },
|
||||
permission: 1,
|
||||
troops: baseTroops(),
|
||||
});
|
||||
await gotoTroop(page);
|
||||
await expect(page.getByRole('button', { name: '부대 탈퇴' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '부대원 추방...' })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: '부대명 변경...' })).toHaveCount(0);
|
||||
});
|
||||
@@ -7,6 +7,7 @@
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"test:e2e:troop": "playwright test --config e2e/playwright.config.mjs",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test": "node -e \"console.log('test not configured')\"",
|
||||
|
||||
@@ -3,7 +3,6 @@ import { createPinia } from 'pinia';
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
import './assets/main.css';
|
||||
import { useSessionStore } from './stores/session';
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
@@ -11,7 +10,4 @@ const pinia = createPinia();
|
||||
app.use(pinia);
|
||||
app.use(router);
|
||||
|
||||
const session = useSessionStore(pinia);
|
||||
void session.initialize();
|
||||
|
||||
app.mount('#app');
|
||||
|
||||
@@ -25,6 +25,7 @@ import HallOfFameView from '../views/HallOfFameView.vue';
|
||||
import DynastyListView from '../views/DynastyListView.vue';
|
||||
import DynastyDetailView from '../views/DynastyDetailView.vue';
|
||||
import SurveyView from '../views/SurveyView.vue';
|
||||
import TroopView from '../views/TroopView.vue';
|
||||
import { useSessionStore } from '../stores/session';
|
||||
|
||||
const routes = [
|
||||
@@ -60,6 +61,15 @@ const routes = [
|
||||
requiresGeneral: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/troop',
|
||||
name: 'troop',
|
||||
component: TroopView,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresGeneral: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/nation/cities',
|
||||
name: 'nation-cities',
|
||||
|
||||
@@ -162,6 +162,11 @@ export const useSessionStore = defineStore('session', {
|
||||
this.setSessionToken(storedToken);
|
||||
}
|
||||
|
||||
const storedGameToken = this.gameToken ?? readStorage(GAME_TOKEN_KEY);
|
||||
if (storedGameToken && storedGameToken !== this.gameToken) {
|
||||
this.setGameToken(storedGameToken);
|
||||
}
|
||||
|
||||
const storedProfile = this.profile ?? readStorage(PROFILE_KEY) ?? import.meta.env.VITE_GAME_PROFILE;
|
||||
if (storedProfile && storedProfile !== this.profile) {
|
||||
this.setProfile(storedProfile);
|
||||
|
||||
@@ -94,6 +94,7 @@ watch(
|
||||
<RouterLink class="ghost" to="/nation/cities">세력 도시</RouterLink>
|
||||
<RouterLink class="ghost" to="/nation/generals">세력 장수</RouterLink>
|
||||
<RouterLink class="ghost" to="/nation/personnel">인사부</RouterLink>
|
||||
<RouterLink class="ghost" to="/troop">부대 편성</RouterLink>
|
||||
<RouterLink class="ghost" to="/nation/affairs">내무부</RouterLink>
|
||||
<RouterLink class="ghost" to="/diplomacy">외교부</RouterLink>
|
||||
<RouterLink class="ghost" to="/chief-center">사령부</RouterLink>
|
||||
|
||||
@@ -0,0 +1,678 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
type TroopList = Awaited<ReturnType<typeof trpc.troop.getList.query>>;
|
||||
type Troop = TroopList['troops'][number];
|
||||
type Member = Troop['members'][number];
|
||||
type DialogKind = 'rename' | 'kick' | null;
|
||||
|
||||
const loading = ref(false);
|
||||
const data = ref<TroopList | null>(null);
|
||||
const errorMessage = ref('');
|
||||
const noticeMessage = ref('');
|
||||
const noticeKind = ref<'success' | 'error'>('success');
|
||||
const createName = ref('');
|
||||
const editName = ref('');
|
||||
const kickTargetId = ref(0);
|
||||
const dialogKind = ref<DialogKind>(null);
|
||||
const dialogTroopId = ref(0);
|
||||
const popupMember = ref<Member | null>(null);
|
||||
const popupTop = ref(0);
|
||||
|
||||
const me = computed(() => data.value?.me ?? null);
|
||||
|
||||
const getErrorMessage = (error: unknown): string => {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
if (typeof error === 'string') {
|
||||
return error;
|
||||
}
|
||||
return '요청을 처리하지 못했습니다.';
|
||||
};
|
||||
|
||||
const showNotice = (message: string, kind: 'success' | 'error') => {
|
||||
noticeMessage.value = message;
|
||||
noticeKind.value = kind;
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
if (loading.value) {
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
errorMessage.value = '';
|
||||
try {
|
||||
data.value = await trpc.troop.getList.query();
|
||||
} catch (error) {
|
||||
errorMessage.value = getErrorMessage(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const runAction = async (action: () => Promise<void>) => {
|
||||
errorMessage.value = '';
|
||||
try {
|
||||
await action();
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
errorMessage.value = message;
|
||||
showNotice(message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const makeTroop = async () => {
|
||||
const troopName = createName.value;
|
||||
await runAction(async () => {
|
||||
await trpc.troop.create.mutate({ troopName });
|
||||
createName.value = '';
|
||||
showNotice(`${troopName} 부대가 생성되었습니다.`, 'success');
|
||||
});
|
||||
};
|
||||
|
||||
const joinTroop = async (troop: Troop) => {
|
||||
await runAction(async () => {
|
||||
await trpc.troop.join.mutate({ troopId: troop.id });
|
||||
showNotice(` ${troop.name} 부대에 가입했습니다.`, 'success');
|
||||
});
|
||||
};
|
||||
|
||||
const exitTroop = async (troop: Troop) => {
|
||||
const isLeader = me.value?.id === troop.id;
|
||||
const prompt = isLeader ? `${troop.name} 부대를 해산하겠습니까?` : `${troop.name} 부대에서 탈퇴하겠습니까?`;
|
||||
if (!window.confirm(prompt)) {
|
||||
return;
|
||||
}
|
||||
await runAction(async () => {
|
||||
await trpc.troop.exit.mutate();
|
||||
showNotice(isLeader ? '부대를 해산했습니다.' : '부대에서 탈퇴했습니다.', 'success');
|
||||
});
|
||||
};
|
||||
|
||||
const openRename = (troop: Troop) => {
|
||||
dialogKind.value = 'rename';
|
||||
dialogTroopId.value = troop.id;
|
||||
editName.value = troop.name;
|
||||
};
|
||||
|
||||
const openKick = (troop: Troop) => {
|
||||
dialogKind.value = 'kick';
|
||||
dialogTroopId.value = troop.id;
|
||||
kickTargetId.value = troop.members.find((member) => member.id !== troop.id)?.id ?? 0;
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
dialogKind.value = null;
|
||||
dialogTroopId.value = 0;
|
||||
};
|
||||
|
||||
const hasFinalConsonant = (value: string): boolean => {
|
||||
const last = Array.from(value.trim()).at(-1);
|
||||
if (!last) {
|
||||
return false;
|
||||
}
|
||||
const code = last.codePointAt(0);
|
||||
return code !== undefined && code >= 0xac00 && code <= 0xd7a3 && (code - 0xac00) % 28 !== 0;
|
||||
};
|
||||
|
||||
const renameTroop = async (troop: Troop) => {
|
||||
const troopName = editName.value;
|
||||
const particle = hasFinalConsonant(troopName) ? '으로' : '로';
|
||||
if (!window.confirm(`${troop.name} 부대의 이름을 ${troopName}${particle} 바꾸시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
await runAction(async () => {
|
||||
await trpc.troop.rename.mutate({ troopId: troop.id, troopName });
|
||||
closeDialog();
|
||||
showNotice('부대명을 변경했습니다.', 'success');
|
||||
});
|
||||
};
|
||||
|
||||
const kickMember = async (troop: Troop) => {
|
||||
const member = troop.members.find((candidate) => candidate.id === kickTargetId.value);
|
||||
if (!member) {
|
||||
showNotice('잘못된 접근입니다.', 'error');
|
||||
return;
|
||||
}
|
||||
const particle = hasFinalConsonant(member.name) ? '을' : '를';
|
||||
if (!window.confirm(`${troop.name} 부대에서 ${member.name}${particle} 추방하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
await runAction(async () => {
|
||||
await trpc.troop.kick.mutate({ troopId: troop.id, targetGeneralId: member.id });
|
||||
closeDialog();
|
||||
showNotice(`${member.name}${particle} 추방했습니다.`, 'success');
|
||||
});
|
||||
};
|
||||
|
||||
const showMemberPopup = (event: MouseEvent, member: Member) => {
|
||||
const row = (event.currentTarget as HTMLElement).closest('.troopMembers') as HTMLElement | null;
|
||||
popupMember.value = member;
|
||||
popupTop.value = row ? row.offsetTop + row.offsetHeight : 0;
|
||||
};
|
||||
|
||||
const hideMemberPopup = () => {
|
||||
popupMember.value = null;
|
||||
};
|
||||
|
||||
const iconPath = (troop: Troop): string => {
|
||||
const picture = troop.leader?.picture || 'default.jpg';
|
||||
return troop.leader?.imageServer ? `${import.meta.env.BASE_URL}d_pic/${picture}` : `/image/icons/${picture}`;
|
||||
};
|
||||
|
||||
const formatTurn = (turnTime: string | null): string => {
|
||||
if (!turnTime) {
|
||||
return '--:--';
|
||||
}
|
||||
return turnTime.slice(14, 19);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
void refresh();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main id="container" class="legacy-troop-page">
|
||||
<header class="topBackBar bg0">
|
||||
<RouterLink class="btn legacyNavButton backLink" to="/">돌아가기</RouterLink>
|
||||
<button class="btn legacyNavButton reloadButton" type="button" :disabled="loading" @click="refresh">
|
||||
갱신
|
||||
</button>
|
||||
<h2>부대 편성</h2>
|
||||
<div></div>
|
||||
<div></div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
v-if="noticeMessage"
|
||||
class="notice"
|
||||
:class="noticeKind"
|
||||
:role="noticeKind === 'error' ? 'alert' : 'status'"
|
||||
>
|
||||
{{ noticeMessage }}
|
||||
</div>
|
||||
<div v-if="errorMessage && !noticeMessage" class="notice error" role="alert">{{ errorMessage }}</div>
|
||||
<div v-if="loading && !data" class="loading">불러오는 중...</div>
|
||||
|
||||
<div v-if="data" id="troopList" class="bg0">
|
||||
<div v-for="troop in data.troops" :key="troop.id" class="troopItem" :data-troop-id="troop.id">
|
||||
<div class="troopInfo">
|
||||
{{ troop.name }}<br />
|
||||
【 {{ troop.leader?.cityName ?? '알 수 없음' }} 】
|
||||
</div>
|
||||
<div class="troopTurn">【턴】 {{ formatTurn(troop.turnTime) }}</div>
|
||||
<div class="troopLeaderIcon">
|
||||
<img
|
||||
height="64"
|
||||
width="64"
|
||||
:src="iconPath(troop)"
|
||||
:alt="`${troop.leader?.name ?? '부대장'} 아이콘`"
|
||||
/>
|
||||
</div>
|
||||
<div class="troopLeaderName">{{ troop.leader?.name ?? '알 수 없음' }}</div>
|
||||
<div class="troopReservedCommand">
|
||||
<div v-for="(brief, index) in troop.reservedCommands" :key="`${troop.id}-${index}`">
|
||||
{{ `${index + 1}: ${brief}` }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="troopMembers">
|
||||
<template v-for="(member, index) in troop.members" :key="member.id">
|
||||
<template v-if="index !== 0">, </template>
|
||||
<span
|
||||
class="troopMember"
|
||||
:class="{
|
||||
troopLeader: member.id === troop.id,
|
||||
troopDiffCityMemeber: member.cityId !== troop.leader?.cityId,
|
||||
}"
|
||||
@mouseenter="showMemberPopup($event, member)"
|
||||
@mouseleave="hideMemberPopup"
|
||||
>
|
||||
{{ member.name
|
||||
}}<template v-if="member.cityId !== troop.leader?.cityId">
|
||||
({{ member.cityName }})</template
|
||||
>
|
||||
</span>
|
||||
</template>
|
||||
({{ troop.members.length }}명)
|
||||
</div>
|
||||
|
||||
<div class="troopAction">
|
||||
<div v-if="dialogKind === null || dialogTroopId !== troop.id" class="actionButtons">
|
||||
<button v-if="data.me.troopId === 0" class="btn btn-primary" @click="joinTroop(troop)">
|
||||
부대 탑승
|
||||
</button>
|
||||
<button
|
||||
v-if="data.me.troopId === troop.id"
|
||||
class="btn"
|
||||
:class="data.me.id === data.me.troopId ? 'btn-danger' : 'btn-primary'"
|
||||
@click="exitTroop(troop)"
|
||||
>
|
||||
{{ data.me.id === data.me.troopId ? '부대 해산' : '부대 탈퇴' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="data.me.troopId === troop.id && data.me.id === data.me.troopId"
|
||||
class="btn btn-secondary"
|
||||
@click="openKick(troop)"
|
||||
>
|
||||
부대원 추방...
|
||||
</button>
|
||||
<button v-if="data.permission >= 4" class="btn btn-info" @click="openRename(troop)">
|
||||
부대명 변경...
|
||||
</button>
|
||||
</div>
|
||||
<div v-else-if="dialogKind === 'rename'" class="subDialog renameDialog">
|
||||
<div class="subTitle bg1 center"><span>부대명 변경</span></div>
|
||||
<div class="subForm">
|
||||
<input v-model.trim="editName" class="formControl" type="text" aria-label="새 부대명" />
|
||||
</div>
|
||||
<div class="subBtnCancel">
|
||||
<button class="btn btn-secondary" @click="closeDialog">취소</button>
|
||||
</div>
|
||||
<div class="subBtnOK">
|
||||
<button class="btn btn-primary" @click="renameTroop(troop)">변경</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="subDialog kickDialog">
|
||||
<div class="subTitle bg1 center"><span>부대원 추방</span></div>
|
||||
<div class="subForm">
|
||||
<select v-model.number="kickTargetId" class="formControl" aria-label="추방할 부대원">
|
||||
<option
|
||||
v-for="member in troop.members.filter((candidate) => candidate.id !== troop.id)"
|
||||
:key="member.id"
|
||||
:value="member.id"
|
||||
>
|
||||
{{ member.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="subBtnCancel">
|
||||
<button class="btn btn-secondary" @click="closeDialog">취소</button>
|
||||
</div>
|
||||
<div class="subBtnOK">
|
||||
<button class="btn btn-primary" @click="kickMember(troop)">추방</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filler"><span class="dummy"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="data" class="additionalTroopOptions">
|
||||
<div v-if="data.me.troopId === 0" class="makeNewTroop">
|
||||
<div class="makeTitle bg1 center">부대 창설</div>
|
||||
<input v-model.trim="createName" class="formControl troopNameField" type="text" aria-label="부대명" />
|
||||
<button class="btn btn-secondary createButton" @click="makeTroop">부대 창설</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="bottomBar bg0">
|
||||
<RouterLink class="btn legacyNavButton backLink" to="/">돌아가기</RouterLink>
|
||||
<div></div>
|
||||
</footer>
|
||||
<div v-if="popupMember" id="generalPopup" :style="{ top: `${popupTop}px` }" role="tooltip">
|
||||
<strong>{{ popupMember.name }}</strong>
|
||||
<span>{{ popupMember.cityName }}</span>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.legacy-troop-page {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
margin: 0 auto;
|
||||
color: #fff;
|
||||
background: #000;
|
||||
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic';
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.bg0 {
|
||||
background-image: url('/image/game/back_walnut.jpg');
|
||||
}
|
||||
|
||||
.bg1 {
|
||||
background-image: url('/image/game/back_green.jpg');
|
||||
}
|
||||
|
||||
.center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.topBackBar {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
display: grid;
|
||||
grid-template-columns: 90px 90px 1fr 90px 90px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.topBackBar h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
line-height: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn.legacyNavButton {
|
||||
height: 32px;
|
||||
margin-right: 2px;
|
||||
border-color: #004f28;
|
||||
color: #fff;
|
||||
background: #00582c;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.notice {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #477a47;
|
||||
color: #d8f5d8;
|
||||
}
|
||||
|
||||
.notice.error {
|
||||
border-color: #9b4848;
|
||||
color: #ffd0d0;
|
||||
}
|
||||
|
||||
.loading {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#generalPopup {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
width: 500px;
|
||||
min-height: 58px;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid #999;
|
||||
background: #202020;
|
||||
}
|
||||
|
||||
.additionalTroopOptions {
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
.makeNewTroop {
|
||||
width: 250px;
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
border: 1px solid gray;
|
||||
}
|
||||
|
||||
.makeTitle {
|
||||
grid-column: 1/3;
|
||||
padding: 0.15em;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.troopDiffCityMemeber {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.troopLeader {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.troopMember {
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.troopItem {
|
||||
display: grid;
|
||||
border-right: 1px solid gray;
|
||||
}
|
||||
|
||||
.troopItem > div {
|
||||
border-top: 1px solid gray;
|
||||
border-left: 1px solid gray;
|
||||
}
|
||||
|
||||
.troopInfo {
|
||||
grid-column: 1/3;
|
||||
grid-row: 1/2;
|
||||
display: grid;
|
||||
align-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.troopTurn {
|
||||
grid-column: 1/3;
|
||||
grid-row: 2/3;
|
||||
display: grid;
|
||||
align-content: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.troopLeaderIcon {
|
||||
grid-column: 3/4;
|
||||
grid-row: 1/2;
|
||||
display: grid;
|
||||
align-content: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.troopLeaderIcon img {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.troopLeaderName {
|
||||
grid-column: 3/4;
|
||||
grid-row: 2/3;
|
||||
display: grid;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.troopReservedCommand {
|
||||
grid-column: 4/5;
|
||||
grid-row: 1/3;
|
||||
overflow: hidden;
|
||||
font-size: 85%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.troopMembers {
|
||||
text-align: left;
|
||||
padding: 0.5em 0.7em;
|
||||
}
|
||||
|
||||
.troopAction {
|
||||
grid-column: 6/7;
|
||||
grid-row: 1/3;
|
||||
}
|
||||
|
||||
.btn {
|
||||
min-height: 31px;
|
||||
padding: 0.2em 0.75em;
|
||||
border: 1px solid #777;
|
||||
border-radius: 4px;
|
||||
color: #eee;
|
||||
background: #555;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
|
||||
.btn:focus-visible {
|
||||
outline: 2px solid #8ab4f8;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
border-color: #0d6efd;
|
||||
background: #0d6efd;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
border-color: #dc3545;
|
||||
background: #dc3545;
|
||||
}
|
||||
|
||||
.btn-info {
|
||||
border-color: #0dcaf0;
|
||||
color: #111;
|
||||
background: #0dcaf0;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
border-color: #6c757d;
|
||||
background: #6c757d;
|
||||
}
|
||||
|
||||
.formControl {
|
||||
width: 100%;
|
||||
min-height: 31px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #777;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
color: #eee;
|
||||
background: #1b1b1b;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.subForm,
|
||||
.subBtnCancel,
|
||||
.subBtnOK {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.bottomBar {
|
||||
margin-top: 16px;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.bottomBar .legacyNavButton {
|
||||
width: 70px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 501px) {
|
||||
.legacy-troop-page {
|
||||
width: 1000px;
|
||||
}
|
||||
|
||||
#generalPopup {
|
||||
left: 260px;
|
||||
}
|
||||
|
||||
.troopItem {
|
||||
grid-template-rows: 65px 28px 34.5px;
|
||||
grid-template-columns: 65px 65px 130px 100px 1fr;
|
||||
}
|
||||
|
||||
.troopMembers {
|
||||
grid-column: 5/6;
|
||||
grid-row: 1/3;
|
||||
}
|
||||
|
||||
.troopItem:last-of-type {
|
||||
border-bottom: 1px solid gray;
|
||||
}
|
||||
|
||||
.filler {
|
||||
grid-column: 1/2;
|
||||
grid-row: 3/4;
|
||||
display: grid;
|
||||
align-content: center;
|
||||
justify-content: right;
|
||||
}
|
||||
|
||||
.dummy::after {
|
||||
content: '└';
|
||||
padding-right: 1.5ch;
|
||||
}
|
||||
|
||||
.troopAction {
|
||||
grid-column: 2/7;
|
||||
grid-row: 3/4;
|
||||
border-left-color: transparent !important;
|
||||
}
|
||||
|
||||
.actionButtons {
|
||||
display: grid;
|
||||
grid-template-columns: 110px 110px 110px;
|
||||
grid-template-rows: 33.5px;
|
||||
}
|
||||
|
||||
.subDialog {
|
||||
display: grid;
|
||||
grid-template-columns: 90px 140px 50px 50px;
|
||||
}
|
||||
|
||||
.subTitle {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.legacy-troop-page {
|
||||
width: 500px;
|
||||
}
|
||||
|
||||
#generalPopup {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.troopItem {
|
||||
grid-template-rows: 65px 28px auto;
|
||||
grid-template-columns: 65px 65px 130px 100px 0 140px;
|
||||
}
|
||||
|
||||
.troopMembers {
|
||||
grid-column: 3/7;
|
||||
grid-row: 3/4;
|
||||
}
|
||||
|
||||
.troopItem:last-of-type .troopMembers {
|
||||
border-bottom: 1px solid gray;
|
||||
}
|
||||
|
||||
.filler {
|
||||
grid-column: 1/3;
|
||||
grid-row: 3/4;
|
||||
border-top: 1px solid gray;
|
||||
}
|
||||
|
||||
.actionButtons {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.subDialog {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: 1fr 1fr 1fr;
|
||||
}
|
||||
|
||||
.subTitle,
|
||||
.subForm {
|
||||
grid-column: 1/3;
|
||||
display: grid;
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
.makeNewTroop {
|
||||
width: 250px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -38,7 +38,8 @@
|
||||
"src/**/*.d.ts",
|
||||
"src/**/*.tsx",
|
||||
"src/**/*.vue",
|
||||
"test/**/*.ts"
|
||||
"test/**/*.ts",
|
||||
"e2e/**/*.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user