feat: add user icon library and in-game selection
This commit is contained in:
@@ -40,6 +40,10 @@ type FixtureState = {
|
||||
nationNoticeInput?: string;
|
||||
settingMutations: Array<Record<string, unknown>>;
|
||||
accessPages: string[];
|
||||
iconChoices?: Array<{ id: string; picture: string; imageServer: number; createdAt: string }>;
|
||||
adjustIconInputs?: Array<Record<string, unknown>>;
|
||||
joinConfig?: Record<string, unknown>;
|
||||
createGeneralInputs?: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
type TrpcRequestPayload = {
|
||||
@@ -79,6 +83,9 @@ const myGeneral = (state: FixtureState) => ({
|
||||
myset: state.myset,
|
||||
},
|
||||
penalties: {},
|
||||
iconChoices: state.iconChoices ?? [],
|
||||
canChangeIcon: true,
|
||||
iconChangeAvailableAt: null,
|
||||
});
|
||||
|
||||
const battleCenter = (state: FixtureState) => ({
|
||||
@@ -168,8 +175,13 @@ const install = async (page: Page, state: FixtureState) => {
|
||||
const jsonInput =
|
||||
payload?.json ?? payload?.input?.json ?? (payload as Record<string, unknown> | undefined) ?? {};
|
||||
if (operation === 'auth.status') return response({ ok: true });
|
||||
if (operation === 'lobby.info') return response({ myGeneral: { id: 7, name: '검증장수' } });
|
||||
if (operation === 'join.getConfig') return response({});
|
||||
if (operation === 'lobby.info')
|
||||
return response({ myGeneral: state.joinConfig ? null : { id: 7, name: '검증장수' } });
|
||||
if (operation === 'join.getConfig') return response(state.joinConfig ?? {});
|
||||
if (operation === 'join.createGeneral') {
|
||||
state.createGeneralInputs?.push(jsonInput);
|
||||
return response({ generalId: 9 });
|
||||
}
|
||||
if (operation === 'general.me') {
|
||||
state.generalMeQueries = (state.generalMeQueries ?? 0) + 1;
|
||||
return response(myGeneral(state));
|
||||
@@ -307,6 +319,10 @@ const install = async (page: Page, state: FixtureState) => {
|
||||
state.myset = Math.max(0, state.myset - 1);
|
||||
return response({ ok: true });
|
||||
}
|
||||
if (operation === 'general.adjustIcon') {
|
||||
state.adjustIconInputs?.push(jsonInput);
|
||||
return response({ ok: true, generalId: 7, updated: true });
|
||||
}
|
||||
if (operation === 'public.recordAccess') {
|
||||
const pageName = typeof jsonInput.page === 'string' ? jsonInput.page : null;
|
||||
if (pageName) state.accessPages.push(pageName);
|
||||
@@ -552,6 +568,87 @@ test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in plac
|
||||
await persistParityArtifact(page, 'core-my-page-mobile', mobile);
|
||||
});
|
||||
|
||||
test('내 정보에서 사람 장수의 등록 전콘을 골라 변경한다', async ({ page }) => {
|
||||
const iconId = '3f804277-584f-4f44-b39c-9ecf40d1ed31';
|
||||
const state: FixtureState = {
|
||||
permission: 'member',
|
||||
myset: 1,
|
||||
settingMutations: [],
|
||||
accessPages: [],
|
||||
iconChoices: [
|
||||
{
|
||||
id: iconId,
|
||||
picture: 'mine.png',
|
||||
imageServer: 1,
|
||||
createdAt: '2026-08-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
adjustIconInputs: [],
|
||||
};
|
||||
await install(page, state);
|
||||
await page.goto('my-page');
|
||||
await expect(page.getByText('전용 아이콘 변경 (24시간에 1회)')).toBeVisible();
|
||||
await page.locator('.general-icon-choice input').check();
|
||||
page.once('dialog', async (dialog) => dialog.accept());
|
||||
await page.getByRole('button', { name: '아이콘 변경' }).click();
|
||||
await expect.poll(() => state.adjustIconInputs?.length ?? 0).toBe(1);
|
||||
expect(state.adjustIconInputs?.[0]).toMatchObject({ iconId });
|
||||
expect(state.adjustIconInputs?.[0]?.clientRequestId).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
);
|
||||
});
|
||||
|
||||
test('장수 생성에서 등록 전콘을 골라 생성 요청에 전달한다', async ({ page }) => {
|
||||
const firstIconId = '3f804277-584f-4f44-b39c-9ecf40d1ed31';
|
||||
const secondIconId = 'f6af46a2-809a-481d-b66d-0f7bbb706780';
|
||||
const state: FixtureState = {
|
||||
permission: 'member',
|
||||
myset: 1,
|
||||
settingMutations: [],
|
||||
accessPages: [],
|
||||
createGeneralInputs: [],
|
||||
joinConfig: {
|
||||
rules: { stat: { total: 150, min: 30, max: 70 }, allowCustomName: true },
|
||||
user: {
|
||||
id: 'user-1',
|
||||
displayName: '생성장수',
|
||||
canCreateGeneral: true,
|
||||
preferredPicture: 'first.png',
|
||||
icons: [
|
||||
{
|
||||
id: firstIconId,
|
||||
picture: 'first.png',
|
||||
imageServer: 1,
|
||||
createdAt: '2026-07-31T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: secondIconId,
|
||||
picture: 'second.png',
|
||||
imageServer: 1,
|
||||
createdAt: '2026-08-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
personalities: [{ key: 'Random', name: '???', info: '무작위 성격' }],
|
||||
nations: [],
|
||||
selectionPool: { enabled: false },
|
||||
npcPossession: { enabled: false },
|
||||
inherit: null,
|
||||
},
|
||||
};
|
||||
await install(page, state);
|
||||
await page.goto('join');
|
||||
|
||||
const choices = page.getByRole('radiogroup', { name: '전용 아이콘 선택' }).getByRole('radio');
|
||||
await expect(choices).toHaveCount(2);
|
||||
await expect(choices.nth(0)).toBeChecked();
|
||||
await choices.nth(1).check();
|
||||
await page.getByRole('button', { name: '장수 생성', exact: true }).last().click();
|
||||
|
||||
await expect.poll(() => state.createGeneralInputs?.length ?? 0).toBe(1);
|
||||
expect(state.createGeneralInputs?.[0]).toMatchObject({ pic: true, iconId: secondIconId });
|
||||
});
|
||||
|
||||
test('내 정보 즉시행동은 timeout 재시도 ID를 유지하고 성공 후 새 ID를 만든다', async ({ page }) => {
|
||||
const state: FixtureState = {
|
||||
permission: 'head',
|
||||
|
||||
@@ -43,6 +43,7 @@ const error = ref<string | null>(null);
|
||||
const submitting = ref(false);
|
||||
|
||||
const joinConfig = ref<JoinConfig | null>(null);
|
||||
const accountIcons = computed(() => joinConfig.value?.user.icons ?? []);
|
||||
const activeTab = ref<'create' | 'possess'>('create');
|
||||
const pendingJoinStorageKey = 'sammo-join-create-pending-action';
|
||||
const pendingPossessStorageKey = 'sammo-npc-possess-pending-action';
|
||||
@@ -54,6 +55,7 @@ const form = ref<JoinForm>({
|
||||
intel: 0,
|
||||
character: 'Random',
|
||||
pic: true,
|
||||
iconId: undefined,
|
||||
inheritBonusStat: [0, 0, 0],
|
||||
});
|
||||
|
||||
@@ -401,6 +403,7 @@ const loadConfig = async () => {
|
||||
form.value = pending.input;
|
||||
} else {
|
||||
form.value.name = config.rules.allowCustomName ? config.user.displayName || '' : '무작위';
|
||||
form.value.iconId = config.user.icons.find((icon) => icon.picture === config.user.preferredPicture)?.id;
|
||||
applyBalancedStats();
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -644,6 +647,23 @@ onUnmounted(() => {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="accountIcons.length" class="icon-choice">
|
||||
<div class="bonus-title">전용 아이콘 선택</div>
|
||||
<label class="icon-option"> <input v-model="form.pic" type="checkbox" /> 전용 아이콘 사용 </label>
|
||||
<div v-if="form.pic" class="icon-list" role="radiogroup" aria-label="전용 아이콘 선택">
|
||||
<label v-for="icon in accountIcons" :key="icon.id" class="icon-card">
|
||||
<input v-model="form.iconId" type="radio" :value="icon.id" />
|
||||
<img
|
||||
:src="resolveGeneralIconUrl({ picture: icon.picture, imageServer: icon.imageServer })"
|
||||
width="64"
|
||||
height="64"
|
||||
alt=""
|
||||
@error="useDefaultGeneralIcon"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-actions">
|
||||
<button @click="applyRandomStats">랜덤형</button>
|
||||
<button @click="applyFocusedStats('leadership')">통솔형</button>
|
||||
@@ -1381,4 +1401,17 @@ onUnmounted(() => {
|
||||
.ghost {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.icon-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.icon-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -40,6 +40,7 @@ const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const screenMode = ref<ScreenMode>('auto');
|
||||
const customCss = ref('');
|
||||
const selectedIconId = ref('');
|
||||
const cssSaving = ref(false);
|
||||
const session = useSessionStore();
|
||||
let cssTimer: number | null = null;
|
||||
@@ -132,6 +133,7 @@ const items = computed<Array<{ key: ItemSlotKey; name: string; code: string | nu
|
||||
{ key: 'book', name: '서적', code: data.value?.general.items.book ?? null },
|
||||
{ key: 'item', name: '도구', code: data.value?.general.items.item ?? null },
|
||||
]);
|
||||
const iconChoices = computed(() => data.value?.iconChoices ?? []);
|
||||
|
||||
const autorunUser = computed(() => asRecord(world.value?.meta.autorun_user));
|
||||
const showAutoNationTurn = computed(() => Boolean(asRecord(autorunUser.value.options).chief));
|
||||
@@ -205,6 +207,10 @@ const loadPage = async (resetImmediateActionIds = true) => {
|
||||
dieOnPrestartStatus.value = prestartStatus;
|
||||
if (general) {
|
||||
Object.assign(form, general.settings);
|
||||
selectedIconId.value =
|
||||
iconChoices.value.find((icon) => icon.picture === general.general.picture)?.id ??
|
||||
iconChoices.value[0]?.id ??
|
||||
'';
|
||||
}
|
||||
await Promise.all(logTypes.map((type) => loadLog(type)));
|
||||
if (resetImmediateActionIds) {
|
||||
@@ -217,6 +223,20 @@ const loadPage = async (resetImmediateActionIds = true) => {
|
||||
}
|
||||
};
|
||||
|
||||
const changeGeneralIcon = async () => {
|
||||
if (!selectedIconId.value) return;
|
||||
if (!confirm('선택한 전용 아이콘으로 바꿀까요? 변경 후 24시간 동안 다시 바꿀 수 없습니다.')) return;
|
||||
try {
|
||||
await trpc.general.adjustIcon.mutate({
|
||||
iconId: selectedIconId.value,
|
||||
clientRequestId: crypto.randomUUID(),
|
||||
});
|
||||
await loadPage();
|
||||
} catch (cause) {
|
||||
alert(`실패했습니다: ${errorText(cause)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const saveSettings = async () => {
|
||||
if (!canSave.value) return;
|
||||
try {
|
||||
@@ -427,6 +447,25 @@ onMounted(() => {
|
||||
휴가 신청
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="data?.canChangeIcon && iconChoices.length" class="action-line general-icon-action">
|
||||
전용 아이콘 변경 (24시간에 1회)<br />
|
||||
<span v-if="data.iconChangeAvailableAt" class="hint">
|
||||
다음 변경 가능: {{ formatSeoulDateTime(data.iconChangeAvailableAt) }}
|
||||
</span>
|
||||
<div class="general-icon-list" role="radiogroup" aria-label="장수 전용 아이콘 선택">
|
||||
<label v-for="icon in iconChoices" :key="icon.id" class="general-icon-choice">
|
||||
<input v-model="selectedIconId" type="radio" :value="icon.id" />
|
||||
<img
|
||||
:src="resolveGeneralIconUrl(icon)"
|
||||
width="64"
|
||||
height="64"
|
||||
alt=""
|
||||
@error="useDefaultGeneralIcon"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<button class="action-button" type="button" @click="changeGeneralIcon">아이콘 변경</button>
|
||||
</div>
|
||||
<div v-if="actionAvailability.dieOnPrestart" class="action-line">
|
||||
가오픈 기간 내 장수 삭제 ({{ formatDieOnPrestartAvailableAt }} 부터)<br />
|
||||
<button class="action-button" @click="dieOnPrestart">장수 삭제</button>
|
||||
@@ -759,6 +798,18 @@ dt {
|
||||
min-height: 32px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.general-icon-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
margin: 6px 0;
|
||||
}
|
||||
.general-icon-choice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
@media (max-width: 991px) {
|
||||
.legacy-page {
|
||||
width: 500px;
|
||||
|
||||
@@ -15,6 +15,7 @@ type PendingSelectionAction = {
|
||||
operation: 'create' | 'reselect';
|
||||
uniqueName: string;
|
||||
personality?: string;
|
||||
iconId?: string;
|
||||
clientRequestId: string;
|
||||
};
|
||||
|
||||
@@ -26,6 +27,7 @@ const reservation = ref<Reservation | null>(null);
|
||||
const selectedUniqueName = ref<string | null>(null);
|
||||
const nations = ref<Nation[]>([]);
|
||||
const personality = ref('Random');
|
||||
const selectedIconId = ref('');
|
||||
const loading = ref(true);
|
||||
const submitting = ref(false);
|
||||
const error = ref('');
|
||||
@@ -66,7 +68,8 @@ const readPendingAction = (): PendingSelectionAction | null => {
|
||||
if (
|
||||
(value.operation !== 'create' && value.operation !== 'reselect') ||
|
||||
typeof value.uniqueName !== 'string' ||
|
||||
typeof value.clientRequestId !== 'string'
|
||||
typeof value.clientRequestId !== 'string' ||
|
||||
(value.iconId !== undefined && typeof value.iconId !== 'string')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
@@ -79,13 +82,15 @@ const readPendingAction = (): PendingSelectionAction | null => {
|
||||
const getPendingAction = (
|
||||
operation: PendingSelectionAction['operation'],
|
||||
uniqueName: string,
|
||||
requestedPersonality?: string
|
||||
requestedPersonality?: string,
|
||||
requestedIconId?: string
|
||||
): PendingSelectionAction => {
|
||||
const current = readPendingAction();
|
||||
if (
|
||||
current?.operation === operation &&
|
||||
current.uniqueName === uniqueName &&
|
||||
current.personality === requestedPersonality
|
||||
current.personality === requestedPersonality &&
|
||||
current.iconId === requestedIconId
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
@@ -93,6 +98,7 @@ const getPendingAction = (
|
||||
operation,
|
||||
uniqueName,
|
||||
...(requestedPersonality ? { personality: requestedPersonality } : {}),
|
||||
...(requestedIconId ? { iconId: requestedIconId } : {}),
|
||||
clientRequestId: crypto.randomUUID(),
|
||||
};
|
||||
window.sessionStorage.setItem(pendingActionStorageKey, JSON.stringify(next));
|
||||
@@ -200,11 +206,12 @@ const createGeneral = async (): Promise<void> => {
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
const pending = getPendingAction('create', candidate.uniqueName, personality.value);
|
||||
const pending = getPendingAction('create', candidate.uniqueName, personality.value, selectedIconId.value);
|
||||
try {
|
||||
await trpc.join.selectPoolGeneral.mutate({
|
||||
uniqueName: candidate.uniqueName,
|
||||
personality: personality.value,
|
||||
...(selectedIconId.value ? { iconId: selectedIconId.value } : {}),
|
||||
clientRequestId: pending.clientRequestId,
|
||||
});
|
||||
clearPendingAction(pending);
|
||||
@@ -432,6 +439,24 @@ onBeforeUnmount(() => {
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="config?.user.icons.length">
|
||||
<th class="legacy-bg1">전콘 선택</th>
|
||||
<td class="pool-icon-choice">
|
||||
<label>
|
||||
<input v-model="selectedIconId" type="radio" value="" /> 선택한 장수 전콘
|
||||
</label>
|
||||
<label v-for="icon in config.user.icons" :key="icon.id">
|
||||
<input v-model="selectedIconId" type="radio" :value="icon.id" />
|
||||
<img
|
||||
:src="resolveGeneralIconUrl(icon)"
|
||||
width="64"
|
||||
height="64"
|
||||
alt="내 전용 아이콘"
|
||||
@error="useDefaultGeneralIcon"
|
||||
/>
|
||||
</label>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" class="join-guidance">
|
||||
임의의 도시에서 재야로 시작하며 건국과 임관은 게임 내에서 실행합니다.
|
||||
|
||||
Reference in New Issue
Block a user