fix: 사령부 모바일 현재 턴과 NPC 대상을 호환
daemon 진행 뒤 국가 명령을 현재 슬롯에 재기준화하고 Ref 명령별 장수 대상 필터를 적용한다. 모바일 고급 편집기 12턴과 500px 8수뇌 행렬을 실제 Chromium 기준으로 복원한다.
This commit is contained in:
@@ -28,13 +28,14 @@ import {
|
|||||||
repeatNationTurns,
|
repeatNationTurns,
|
||||||
setGeneralTurn,
|
setGeneralTurn,
|
||||||
setGeneralTurns,
|
setGeneralTurns,
|
||||||
setNationTurn,
|
setNationTurnAtCurrentPosition,
|
||||||
setNationTurns,
|
setNationTurnsAtCurrentPositions,
|
||||||
shiftGeneralTurns,
|
shiftGeneralTurns,
|
||||||
shiftNationTurns,
|
shiftNationTurns,
|
||||||
} from '../../turns/reservedTurns.js';
|
} from '../../turns/reservedTurns.js';
|
||||||
import { getOwnedGeneral } from '../shared/general.js';
|
import { getOwnedGeneral } from '../shared/general.js';
|
||||||
import type { GameApiContext, GeneralRow, WorldStateRow } from '../../context.js';
|
import type { GameApiContext, GeneralRow, WorldStateRow } from '../../context.js';
|
||||||
|
import { buildRefGeneralTargetOptions } from '../../turns/commandTargets.js';
|
||||||
|
|
||||||
const zPushAmount = z
|
const zPushAmount = z
|
||||||
.number()
|
.number()
|
||||||
@@ -181,9 +182,15 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
|||||||
orderBy: { id: 'asc' },
|
orderBy: { id: 'asc' },
|
||||||
}),
|
}),
|
||||||
ctx.db.general.findMany({
|
ctx.db.general.findMany({
|
||||||
where: { npcState: { lt: 2 } },
|
select: {
|
||||||
select: { id: true, name: true, nationId: true, cityId: true },
|
id: true,
|
||||||
orderBy: { id: 'asc' },
|
name: true,
|
||||||
|
nationId: true,
|
||||||
|
cityId: true,
|
||||||
|
npcState: true,
|
||||||
|
officerLevel: true,
|
||||||
|
},
|
||||||
|
orderBy: [{ npcState: 'asc' }, { name: 'asc' }, { id: 'asc' }],
|
||||||
}),
|
}),
|
||||||
environmentPromise,
|
environmentPromise,
|
||||||
loadBattleSimTraitOptions(),
|
loadBattleSimTraitOptions(),
|
||||||
@@ -192,7 +199,13 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const nationById = new Map(nations.map((entry) => [entry.id, entry]));
|
const nationById = new Map(nations.map((entry) => [entry.id, entry]));
|
||||||
const cityById = new Map(cities.map((entry) => [entry.id, entry]));
|
const generalTargetOptions = buildRefGeneralTargetOptions({
|
||||||
|
actorId: general.id,
|
||||||
|
actorNationId: general.nationId,
|
||||||
|
generals,
|
||||||
|
nationNames: new Map(nations.map((entry) => [entry.id, entry.name])),
|
||||||
|
cityNames: new Map(cities.map((entry) => [entry.id, entry.name])),
|
||||||
|
});
|
||||||
const items: TurnCommandInputOptions['items'] = {
|
const items: TurnCommandInputOptions['items'] = {
|
||||||
horse: [{ value: 'None', label: '판매/해제' }],
|
horse: [{ value: 'None', label: '판매/해제' }],
|
||||||
weapon: [{ value: 'None', label: '판매/해제' }],
|
weapon: [{ value: 'None', label: '판매/해제' }],
|
||||||
@@ -226,12 +239,8 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
|||||||
label: entry.name,
|
label: entry.name,
|
||||||
color: entry.color,
|
color: entry.color,
|
||||||
})),
|
})),
|
||||||
generals: generals.map((entry) => ({
|
generals: generalTargetOptions.generals,
|
||||||
value: entry.id,
|
generalTargets: generalTargetOptions.generalTargets,
|
||||||
label: `${entry.name} (${nationById.get(entry.nationId)?.name ?? '무소속'} · ${
|
|
||||||
cityById.get(entry.cityId)?.name ?? '재야'
|
|
||||||
})`,
|
|
||||||
})),
|
|
||||||
crewTypes: (environment.unitSet.crewTypes ?? [])
|
crewTypes: (environment.unitSet.crewTypes ?? [])
|
||||||
.filter((entry) => !entry.requirements.some((requirement) => requirement.type === 'Impossible'))
|
.filter((entry) => !entry.requirements.some((requirement) => requirement.type === 'Impossible'))
|
||||||
.map((entry) => ({ value: entry.id, label: entry.name })),
|
.map((entry) => ({ value: entry.id, label: entry.name })),
|
||||||
@@ -446,7 +455,7 @@ export const turnsRouter = router({
|
|||||||
await assertReservedTurnPermission(worldState, general, 'nation', input.action, args);
|
await assertReservedTurnPermission(worldState, general, 'nation', input.action, args);
|
||||||
|
|
||||||
const snapshot = await mutateReservedTurns(() =>
|
const snapshot = await mutateReservedTurns(() =>
|
||||||
setNationTurn(
|
setNationTurnAtCurrentPosition(
|
||||||
ctx.db,
|
ctx.db,
|
||||||
general.nationId,
|
general.nationId,
|
||||||
general.officerLevel,
|
general.officerLevel,
|
||||||
@@ -559,7 +568,13 @@ export const turnsRouter = router({
|
|||||||
await assertReservedTurnPermission(worldState, general, 'nation', update.action, update.args);
|
await assertReservedTurnPermission(worldState, general, 'nation', update.action, update.args);
|
||||||
}
|
}
|
||||||
const snapshot = await mutateReservedTurns(() =>
|
const snapshot = await mutateReservedTurns(() =>
|
||||||
setNationTurns(ctx.db, general.nationId, general.officerLevel, updates, input.expectedRevision)
|
setNationTurnsAtCurrentPositions(
|
||||||
|
ctx.db,
|
||||||
|
general.nationId,
|
||||||
|
general.officerLevel,
|
||||||
|
updates,
|
||||||
|
input.expectedRevision
|
||||||
|
)
|
||||||
);
|
);
|
||||||
return { ok: true, ...snapshot };
|
return { ok: true, ...snapshot };
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export interface TurnCommandInputOptions {
|
|||||||
cities: TurnCommandOption[];
|
cities: TurnCommandOption[];
|
||||||
nations: TurnCommandOption[];
|
nations: TurnCommandOption[];
|
||||||
generals: TurnCommandOption[];
|
generals: TurnCommandOption[];
|
||||||
|
generalTargets?: Record<string, TurnCommandOption[]>;
|
||||||
crewTypes: TurnCommandOption[];
|
crewTypes: TurnCommandOption[];
|
||||||
armTypes: TurnCommandOption[];
|
armTypes: TurnCommandOption[];
|
||||||
nationTypes: TurnCommandOption[];
|
nationTypes: TurnCommandOption[];
|
||||||
|
|||||||
@@ -771,6 +771,7 @@ export const buildTurnCommandTable = async (options: {
|
|||||||
cities: [],
|
cities: [],
|
||||||
nations: [],
|
nations: [],
|
||||||
generals: [],
|
generals: [],
|
||||||
|
generalTargets: {},
|
||||||
crewTypes: [],
|
crewTypes: [],
|
||||||
armTypes: [],
|
armTypes: [],
|
||||||
nationTypes: [],
|
nationTypes: [],
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type { TurnCommandOption } from './commandInput.js';
|
||||||
|
|
||||||
|
export interface GeneralTargetSource {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
nationId: number;
|
||||||
|
cityId: number;
|
||||||
|
npcState: number;
|
||||||
|
officerLevel: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RefGeneralTargetOptions {
|
||||||
|
generals: TurnCommandOption[];
|
||||||
|
generalTargets: Record<string, TurnCommandOption[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAME_NATION_GENERAL_COMMANDS = ['che_증여'] as const;
|
||||||
|
const SAME_NATION_NATION_COMMANDS = ['che_발령', 'che_포상', 'che_몰수', 'che_부대탈퇴지시'] as const;
|
||||||
|
|
||||||
|
/** Ref 각 처리 화면의 SELECT 조건을 공통 command table의 명령별 option으로 투영한다. */
|
||||||
|
export const buildRefGeneralTargetOptions = (options: {
|
||||||
|
actorId: number;
|
||||||
|
actorNationId: number;
|
||||||
|
generals: readonly GeneralTargetSource[];
|
||||||
|
nationNames: ReadonlyMap<number, string>;
|
||||||
|
cityNames: ReadonlyMap<number, string>;
|
||||||
|
}): RefGeneralTargetOptions => {
|
||||||
|
const toOption = (entry: GeneralTargetSource): TurnCommandOption => ({
|
||||||
|
value: entry.id,
|
||||||
|
label: `${entry.name} (${options.nationNames.get(entry.nationId) ?? '무소속'} · ${
|
||||||
|
options.cityNames.get(entry.cityId) ?? '재야'
|
||||||
|
})`,
|
||||||
|
});
|
||||||
|
const project = (predicate: (entry: GeneralTargetSource) => boolean): TurnCommandOption[] =>
|
||||||
|
options.generals.filter(predicate).map(toOption);
|
||||||
|
|
||||||
|
const sameNation = project((entry) => entry.nationId === options.actorNationId);
|
||||||
|
const generalTargets: Record<string, TurnCommandOption[]> = {};
|
||||||
|
for (const action of SAME_NATION_GENERAL_COMMANDS) generalTargets[action] = sameNation;
|
||||||
|
for (const action of SAME_NATION_NATION_COMMANDS) generalTargets[action] = sameNation;
|
||||||
|
|
||||||
|
generalTargets.che_선양 = project(
|
||||||
|
(entry) => entry.nationId !== 0 && entry.nationId === options.actorNationId && entry.id !== options.actorId
|
||||||
|
);
|
||||||
|
generalTargets.che_등용 = project(
|
||||||
|
(entry) => entry.npcState < 2 && entry.officerLevel !== 12 && entry.id !== options.actorId
|
||||||
|
);
|
||||||
|
generalTargets.che_장수대상임관 = project((entry) => entry.id !== options.actorId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 기존 profile의 공통 fallback은 유저장 목록을 유지한다.
|
||||||
|
generals: project((entry) => entry.npcState < 2),
|
||||||
|
generalTargets,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -396,6 +396,32 @@ export const setNationTurns = async (
|
|||||||
return { revision, turns: serializeTurnList(turns) };
|
return { revision, turns: serializeTurnList(turns) };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 국가 턴 입력은 화면을 연 뒤 daemon이 선두 턴을 소비했더라도 사용자가 고른
|
||||||
|
* 슬롯 번호를 현재 큐에 적용한다. 큐 lease가 실제로 잡혀 있는 충돌은 그대로
|
||||||
|
* 거절하고, revision이 앞으로 진행한 경우에만 새 revision으로 재기준화한다.
|
||||||
|
*/
|
||||||
|
export const setNationTurnsAtCurrentPositions = async (
|
||||||
|
db: DatabaseClient,
|
||||||
|
nationId: number,
|
||||||
|
officerLevel: number,
|
||||||
|
updates: readonly ReservedTurnUpdate[],
|
||||||
|
expectedRevision: number
|
||||||
|
): Promise<ReservedTurnSnapshot> => {
|
||||||
|
let revision = expectedRevision;
|
||||||
|
for (let attempt = 0; attempt < 8; attempt += 1) {
|
||||||
|
try {
|
||||||
|
return await setNationTurns(db, nationId, officerLevel, updates, revision);
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof ReservedTurnRevisionConflictError) || error.currentRevision === revision) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
revision = error.currentRevision;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new ReservedTurnRevisionConflictError(revision, revision);
|
||||||
|
};
|
||||||
|
|
||||||
export const setNationTurn = async (
|
export const setNationTurn = async (
|
||||||
db: DatabaseClient,
|
db: DatabaseClient,
|
||||||
nationId: number,
|
nationId: number,
|
||||||
@@ -407,6 +433,23 @@ export const setNationTurn = async (
|
|||||||
): Promise<ReservedTurnSnapshot> =>
|
): Promise<ReservedTurnSnapshot> =>
|
||||||
setNationTurns(db, nationId, officerLevel, [{ turnIndices: [turnIndex], action, args }], expectedRevision);
|
setNationTurns(db, nationId, officerLevel, [{ turnIndices: [turnIndex], action, args }], expectedRevision);
|
||||||
|
|
||||||
|
export const setNationTurnAtCurrentPosition = async (
|
||||||
|
db: DatabaseClient,
|
||||||
|
nationId: number,
|
||||||
|
officerLevel: number,
|
||||||
|
turnIndex: number,
|
||||||
|
action: string,
|
||||||
|
args: unknown,
|
||||||
|
expectedRevision: number
|
||||||
|
): Promise<ReservedTurnSnapshot> =>
|
||||||
|
setNationTurnsAtCurrentPositions(
|
||||||
|
db,
|
||||||
|
nationId,
|
||||||
|
officerLevel,
|
||||||
|
[{ turnIndices: [turnIndex], action, args }],
|
||||||
|
expectedRevision
|
||||||
|
);
|
||||||
|
|
||||||
export const shiftNationTurns = async (
|
export const shiftNationTurns = async (
|
||||||
db: DatabaseClient,
|
db: DatabaseClient,
|
||||||
nationId: number,
|
nationId: number,
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { buildRefGeneralTargetOptions, type GeneralTargetSource } from '../src/turns/commandTargets.js';
|
||||||
|
|
||||||
|
const general = (overrides: Partial<GeneralTargetSource>): GeneralTargetSource => ({
|
||||||
|
id: 1,
|
||||||
|
name: '본인',
|
||||||
|
nationId: 1,
|
||||||
|
cityId: 10,
|
||||||
|
npcState: 0,
|
||||||
|
officerLevel: 5,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Ref command general targets', () => {
|
||||||
|
const sources = [
|
||||||
|
general({}),
|
||||||
|
general({ id: 2, name: '아국유저', officerLevel: 12 }),
|
||||||
|
general({ id: 3, name: '아국NPC', npcState: 2, officerLevel: 0 }),
|
||||||
|
general({ id: 4, name: '타국유저', nationId: 2, cityId: 20, officerLevel: 0 }),
|
||||||
|
general({ id: 5, name: '타국NPC', nationId: 2, cityId: 20, npcState: 3, officerLevel: 0 }),
|
||||||
|
];
|
||||||
|
const result = buildRefGeneralTargetOptions({
|
||||||
|
actorId: 1,
|
||||||
|
actorNationId: 1,
|
||||||
|
generals: sources,
|
||||||
|
nationNames: new Map([
|
||||||
|
[1, '아국'],
|
||||||
|
[2, '타국'],
|
||||||
|
]),
|
||||||
|
cityNames: new Map([
|
||||||
|
[10, '업'],
|
||||||
|
[20, '허창'],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
const ids = (action: string) => result.generalTargets[action]?.map((entry) => entry.value);
|
||||||
|
|
||||||
|
it('includes user and NPC generals of the same nation for every Ref nation personnel command', () => {
|
||||||
|
for (const action of ['che_발령', 'che_포상', 'che_몰수', 'che_부대탈퇴지시']) {
|
||||||
|
expect(ids(action)).toEqual([1, 2, 3]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves the distinct Ref filters for gift, abdication, recruitment, and target-based joining', () => {
|
||||||
|
expect(ids('che_증여')).toEqual([1, 2, 3]);
|
||||||
|
expect(ids('che_선양')).toEqual([2, 3]);
|
||||||
|
expect(ids('che_등용')).toEqual([4]);
|
||||||
|
expect(ids('che_장수대상임관')).toEqual([2, 3, 4, 5]);
|
||||||
|
expect(result.generals.map((entry) => entry.value)).toEqual([1, 2, 4]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
setGeneralTurns,
|
setGeneralTurns,
|
||||||
setNationTurn,
|
setNationTurn,
|
||||||
setNationTurns,
|
setNationTurns,
|
||||||
|
setNationTurnsAtCurrentPositions,
|
||||||
shiftGeneralTurns,
|
shiftGeneralTurns,
|
||||||
shiftNationTurns,
|
shiftNationTurns,
|
||||||
ReservedTurnRevisionConflictError,
|
ReservedTurnRevisionConflictError,
|
||||||
@@ -195,7 +196,7 @@ const buildDb = () => {
|
|||||||
},
|
},
|
||||||
} as unknown as DatabaseClient;
|
} as unknown as DatabaseClient;
|
||||||
|
|
||||||
return { db };
|
return { db, nationTurns, nationRevisions };
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('reservedTurns', () => {
|
describe('reservedTurns', () => {
|
||||||
@@ -339,6 +340,61 @@ describe('reservedTurns', () => {
|
|||||||
expect(noOpPush).toEqual(repeated);
|
expect(noOpPush).toEqual(repeated);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rebases stale nation slot input onto the current queue after a turn advances', async () => {
|
||||||
|
const { db, nationTurns, nationRevisions } = buildDb();
|
||||||
|
const seeded = await setNationTurns(
|
||||||
|
db,
|
||||||
|
6,
|
||||||
|
12,
|
||||||
|
[
|
||||||
|
{ turnIndices: [0], action: 'che_증축', args: {} },
|
||||||
|
{ turnIndices: [1], action: 'che_감축', args: {} },
|
||||||
|
{ turnIndices: [2], action: 'che_천도', args: { destCityId: 3 } },
|
||||||
|
],
|
||||||
|
0
|
||||||
|
);
|
||||||
|
expect(seeded.revision).toBe(1);
|
||||||
|
|
||||||
|
// daemon이 한 턴을 소비한 뒤의 현재 큐를 모사한다.
|
||||||
|
nationRevisions.set('6:12', 2);
|
||||||
|
nationTurns.set('6:12', [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
nationId: 6,
|
||||||
|
officerLevel: 12,
|
||||||
|
turnIdx: 0,
|
||||||
|
actionCode: 'che_감축',
|
||||||
|
arg: {},
|
||||||
|
createdAt: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
nationId: 6,
|
||||||
|
officerLevel: 12,
|
||||||
|
turnIdx: 1,
|
||||||
|
actionCode: 'che_천도',
|
||||||
|
arg: { destCityId: 3 },
|
||||||
|
createdAt: new Date(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await setNationTurnsAtCurrentPositions(
|
||||||
|
db,
|
||||||
|
6,
|
||||||
|
12,
|
||||||
|
[{ turnIndices: [2], action: 'che_포상', args: { destGeneralId: 77, amount: 100, isGold: true } }],
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.revision).toBe(3);
|
||||||
|
expect(result.turns[0]?.action).toBe('che_감축');
|
||||||
|
expect(result.turns[1]?.action).toBe('che_천도');
|
||||||
|
expect(result.turns[2]).toMatchObject({
|
||||||
|
action: 'che_포상',
|
||||||
|
args: { destGeneralId: 77, amount: 100, isGold: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects an API writer while the daemon holds the queue lease without touching turns', async () => {
|
it('rejects an API writer while the daemon holds the queue lease without touching turns', async () => {
|
||||||
const deleteMany = vi.fn(async () => ({}));
|
const deleteMany = vi.fn(async () => ({}));
|
||||||
const createMany = vi.fn(async () => ({}));
|
const createMany = vi.fn(async () => ({}));
|
||||||
|
|||||||
@@ -1020,7 +1020,22 @@ describe('appRouter', () => {
|
|||||||
|
|
||||||
expect(response.turns[0]?.args).toEqual({ isGold: true, amount: 1, destGeneralId: 7 });
|
expect(response.turns[0]?.args).toEqual({ isGold: true, amount: 1, destGeneralId: 7 });
|
||||||
expect(response.turns[2]?.args).toEqual({ isGold: false, amount: 2, destGeneralId: 8 });
|
expect(response.turns[2]?.args).toEqual({ isGold: false, amount: 2, destGeneralId: 8 });
|
||||||
expect(nationWrites).toHaveLength(1);
|
|
||||||
|
const rebased = await caller.turns.reserved.setNationBulk({
|
||||||
|
generalId: general.id,
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
turnList: [2],
|
||||||
|
action: 'che_포상',
|
||||||
|
args: { isGold: true, amount: 3, destGeneralId: 9 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// 첫 요청 뒤 턴이 진행한 화면의 stale revision을 그대로 보낸 상황입니다.
|
||||||
|
expectedRevision: 0,
|
||||||
|
});
|
||||||
|
expect(rebased.revision).toBe(2);
|
||||||
|
expect(rebased.turns[2]?.args).toEqual({ isGold: true, amount: 3, destGeneralId: 9 });
|
||||||
|
expect(nationWrites).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('enforces only legacy reservation permissions without applying full execution constraints', async () => {
|
it('enforces only legacy reservation permissions without applying full execution constraints', async () => {
|
||||||
|
|||||||
@@ -52,6 +52,18 @@ const inputOptions = {
|
|||||||
{ value: 1, label: '장수 (아국 · 업)' },
|
{ value: 1, label: '장수 (아국 · 업)' },
|
||||||
{ value: 2, label: '관우 (아국 · 업)' },
|
{ value: 2, label: '관우 (아국 · 업)' },
|
||||||
],
|
],
|
||||||
|
generalTargets: {
|
||||||
|
che_포상: [
|
||||||
|
{ value: 1, label: '장수 (아국 · 업)' },
|
||||||
|
{ value: 2, label: '관우 (아국 · 업)' },
|
||||||
|
{ value: 3, label: '여포NPC (아국 · 업)' },
|
||||||
|
],
|
||||||
|
che_몰수: [
|
||||||
|
{ value: 1, label: '장수 (아국 · 업)' },
|
||||||
|
{ value: 2, label: '관우 (아국 · 업)' },
|
||||||
|
{ value: 3, label: '여포NPC (아국 · 업)' },
|
||||||
|
],
|
||||||
|
},
|
||||||
crewTypes: [{ value: 1100, label: '보병' }],
|
crewTypes: [{ value: 1100, label: '보병' }],
|
||||||
armTypes: [{ value: 1, label: '보병' }],
|
armTypes: [{ value: 1, label: '보병' }],
|
||||||
nationTypes: [{ value: 'che_중립', label: '중립' }],
|
nationTypes: [{ value: 'che_중립', label: '중립' }],
|
||||||
@@ -366,8 +378,8 @@ const chiefCenter = {
|
|||||||
maxTurns: 12,
|
maxTurns: 12,
|
||||||
chiefs: [12, 10, 8, 6, 11, 9, 7, 5].map((officerLevel) => ({
|
chiefs: [12, 10, 8, 6, 11, 9, 7, 5].map((officerLevel) => ({
|
||||||
officerLevel,
|
officerLevel,
|
||||||
name: officerLevel === 5 ? '장수' : null,
|
name: officerLevel === 5 ? '장수' : `수뇌${officerLevel}`,
|
||||||
npcState: officerLevel === 5 ? 0 : null,
|
npcState: officerLevel === 8 ? 2 : 0,
|
||||||
turnTime: null,
|
turnTime: null,
|
||||||
revision: 0,
|
revision: 0,
|
||||||
turns: turns(12),
|
turns: turns(12),
|
||||||
@@ -723,6 +735,92 @@ test('shows every Ref chief command in the exact category and command order', as
|
|||||||
await mobilePicker.screenshot({ path: test.info().outputPath('ref-chief-command-list-mobile-500.png') });
|
await mobilePicker.screenshot({ path: test.info().outputPath('ref-chief-command-list-mobile-500.png') });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('shows all 12 advanced chief turns before the actions and uses the full mobile chief matrix', async ({ page }) => {
|
||||||
|
await install(page);
|
||||||
|
await page.setViewportSize({ width: 500, height: 900 });
|
||||||
|
await page.goto('/che/chief-center');
|
||||||
|
|
||||||
|
const editor = page.locator('[data-command-scope="nation"]:visible');
|
||||||
|
await editor.getByRole('button', { name: '고급 모드', exact: true }).click();
|
||||||
|
await expect(editor.locator('.index-column > button')).toHaveCount(12);
|
||||||
|
await expect(editor.locator('.index-column > button').last()).toHaveText('12');
|
||||||
|
await expect(editor.locator('.advanced-actions')).toContainText('선택한 턴을');
|
||||||
|
await expect(editor.locator('.advanced-actions')).toContainText('명령 선택');
|
||||||
|
|
||||||
|
const frame = page.locator('.chief-overview-frame');
|
||||||
|
await expect(frame.locator('.chief-overview-row')).toHaveCount(2);
|
||||||
|
await expect(frame.locator('.overview-turn-index')).toHaveCount(4);
|
||||||
|
for (const gutter of await frame.locator('.overview-turn-index').all()) {
|
||||||
|
await expect(gutter.locator('span').filter({ hasText: /\d+/u })).toHaveText(
|
||||||
|
Array.from({ length: 12 }, (_, index) => String(index + 1))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await expect(frame.locator('.compact-name')).toHaveCount(8);
|
||||||
|
|
||||||
|
const geometry = await page.locator('.chief-page').evaluate((element) => {
|
||||||
|
const editorElement = element.querySelector<HTMLElement>('[data-command-scope="nation"]')!;
|
||||||
|
const queue = editorElement.querySelector<HTMLElement>('.queue-grid')!;
|
||||||
|
const lastTurn = editorElement.querySelectorAll<HTMLElement>('.action-column > div')[11]!;
|
||||||
|
const actions = editorElement.querySelector<HTMLElement>('.advanced-actions')!;
|
||||||
|
const overviewFrame = element.querySelector<HTMLElement>('.chief-overview-frame')!;
|
||||||
|
const firstOverviewRow = element.querySelector<HTMLElement>('.chief-overview-row')!;
|
||||||
|
const overviewRows = [...element.querySelectorAll<HTMLElement>('.chief-overview-row')];
|
||||||
|
const gutters = [...firstOverviewRow.querySelectorAll<HTMLElement>('.overview-turn-index')];
|
||||||
|
const cards = [...firstOverviewRow.querySelectorAll<HTMLElement>('.chief-card')];
|
||||||
|
const names = [...overviewFrame.querySelectorAll<HTMLElement>('.compact-name')];
|
||||||
|
const frameRect = overviewFrame.getBoundingClientRect();
|
||||||
|
const editorRect = editorElement.getBoundingClientRect();
|
||||||
|
const actionsRect = actions.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
editorBottom: editorRect.bottom,
|
||||||
|
editorHeight: editorRect.height,
|
||||||
|
queueBottom: queue.getBoundingClientRect().bottom,
|
||||||
|
lastTurnBottom: lastTurn.getBoundingClientRect().bottom,
|
||||||
|
actionsTop: actionsRect.top,
|
||||||
|
actionsBottom: actionsRect.bottom,
|
||||||
|
frameTop: frameRect.top,
|
||||||
|
frameWidth: frameRect.width,
|
||||||
|
rowWidth: firstOverviewRow.getBoundingClientRect().width,
|
||||||
|
rowEdges: overviewRows.map((item) => ({
|
||||||
|
top: item.getBoundingClientRect().top - frameRect.top,
|
||||||
|
bottom: item.getBoundingClientRect().bottom - frameRect.top,
|
||||||
|
})),
|
||||||
|
gutterWidths: gutters.map((item) => item.getBoundingClientRect().width),
|
||||||
|
gutterEdges: gutters.map((item) => ({
|
||||||
|
left: item.getBoundingClientRect().left - frameRect.left,
|
||||||
|
right: item.getBoundingClientRect().right - frameRect.left,
|
||||||
|
})),
|
||||||
|
cardWidths: cards.map((item) => item.getBoundingClientRect().width),
|
||||||
|
namesInsideFrame: names.every((item) => {
|
||||||
|
const rect = item.getBoundingClientRect();
|
||||||
|
return rect.top >= frameRect.top && rect.bottom <= frameRect.bottom && rect.height > 0;
|
||||||
|
}),
|
||||||
|
documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(geometry.lastTurnBottom).toBeLessThanOrEqual(geometry.actionsTop);
|
||||||
|
expect(geometry.queueBottom).toBeLessThanOrEqual(geometry.actionsTop);
|
||||||
|
expect(geometry.actionsBottom).toBeLessThanOrEqual(geometry.editorBottom);
|
||||||
|
expect(geometry.frameTop).toBeGreaterThanOrEqual(geometry.editorBottom);
|
||||||
|
expect(geometry.editorHeight).toBeGreaterThanOrEqual(404);
|
||||||
|
expect(geometry.frameWidth).toBe(500);
|
||||||
|
expect(geometry.rowWidth).toBe(500);
|
||||||
|
expect(geometry.rowEdges).toEqual([
|
||||||
|
{ top: 0, bottom: 155 },
|
||||||
|
{ top: 155, bottom: 310 },
|
||||||
|
]);
|
||||||
|
expect(geometry.gutterWidths).toEqual([12, 12]);
|
||||||
|
expect(geometry.gutterEdges).toEqual([
|
||||||
|
{ left: 0, right: 12 },
|
||||||
|
{ left: 488, right: 500 },
|
||||||
|
]);
|
||||||
|
expect(geometry.cardWidths).toEqual([119, 119, 119, 119]);
|
||||||
|
expect(geometry.namesInsideFrame).toBe(true);
|
||||||
|
expect(geometry.documentOverflow).toBeLessThanOrEqual(0);
|
||||||
|
await page.screenshot({ path: test.info().outputPath('chief-advanced-mobile-500.png'), fullPage: true });
|
||||||
|
});
|
||||||
|
|
||||||
test('enters general and nation command arguments and sends exact values', async ({ page }) => {
|
test('enters general and nation command arguments and sends exact values', async ({ page }) => {
|
||||||
const requests = await install(page);
|
const requests = await install(page);
|
||||||
await page.setViewportSize({ width: 1200, height: 900 });
|
await page.setViewportSize({ width: 1200, height: 900 });
|
||||||
@@ -811,7 +909,13 @@ test('enters general and nation command arguments and sends exact values', async
|
|||||||
const chiefForm = chiefPicker.getByTestId('command-argument-form');
|
const chiefForm = chiefPicker.getByTestId('command-argument-form');
|
||||||
await chiefForm.getByRole('button', { name: '쌀' }).click();
|
await chiefForm.getByRole('button', { name: '쌀' }).click();
|
||||||
await chiefForm.locator('input[type=number]').fill('300');
|
await chiefForm.locator('input[type=number]').fill('300');
|
||||||
await chiefForm.locator('select').selectOption('2');
|
const chiefTarget = chiefForm.locator('select');
|
||||||
|
await expect(chiefTarget.locator('option')).toHaveText([
|
||||||
|
'장수 (아국 · 업)',
|
||||||
|
'관우 (아국 · 업)',
|
||||||
|
'여포NPC (아국 · 업)',
|
||||||
|
]);
|
||||||
|
await chiefTarget.selectOption('3');
|
||||||
const geometry = await chiefForm.evaluate((element) => {
|
const geometry = await chiefForm.evaluate((element) => {
|
||||||
const row = element.querySelector('.argument-row');
|
const row = element.querySelector('.argument-row');
|
||||||
const rect = element.getBoundingClientRect();
|
const rect = element.getBoundingClientRect();
|
||||||
@@ -829,7 +933,7 @@ test('enters general and nation command arguments and sends exact values', async
|
|||||||
expect(JSON.stringify(requests)).toContain('"destCityId":2');
|
expect(JSON.stringify(requests)).toContain('"destCityId":2');
|
||||||
expect(JSON.stringify(requests)).toContain('"isGold":false');
|
expect(JSON.stringify(requests)).toContain('"isGold":false');
|
||||||
expect(JSON.stringify(requests)).toContain('"amount":300');
|
expect(JSON.stringify(requests)).toContain('"amount":300');
|
||||||
expect(JSON.stringify(requests)).toContain('"destGeneralId":2');
|
expect(JSON.stringify(requests)).toContain('"destGeneralId":3');
|
||||||
|
|
||||||
expect(mapGeometry.width).toBeGreaterThan(650);
|
expect(mapGeometry.width).toBeGreaterThan(650);
|
||||||
expect(mapGeometry.height / mapGeometry.width).toBeCloseTo(5 / 7, 2);
|
expect(mapGeometry.height / mapGeometry.width).toBeCloseTo(5 / 7, 2);
|
||||||
|
|||||||
@@ -649,7 +649,9 @@ const clickOutsideMenu = (event: Event) => {
|
|||||||
class="command-picker"
|
class="command-picker"
|
||||||
:class="{ 'recruitment-picker': isRecruitmentCommand }"
|
:class="{ 'recruitment-picker': isRecruitmentCommand }"
|
||||||
data-testid="command-picker"
|
data-testid="command-picker"
|
||||||
:style="isRecruitmentCommand || quickTarget === null || props.compact ? undefined : { top: quickPickerTop }"
|
:style="
|
||||||
|
isRecruitmentCommand || quickTarget === null || props.compact ? undefined : { top: quickPickerTop }
|
||||||
|
"
|
||||||
:role="isRecruitmentCommand ? 'dialog' : undefined"
|
:role="isRecruitmentCommand ? 'dialog' : undefined"
|
||||||
:aria-modal="isRecruitmentCommand ? 'true' : undefined"
|
:aria-modal="isRecruitmentCommand ? 'true' : undefined"
|
||||||
:aria-label="
|
:aria-label="
|
||||||
@@ -1102,12 +1104,15 @@ const clickOutsideMenu = (event: Event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mobile.compact .editor-layout {
|
.mobile.compact .editor-layout {
|
||||||
height: 360px;
|
min-height: 370px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 109px 391px;
|
grid-template-columns: 109px 391px;
|
||||||
|
grid-template-rows: auto auto;
|
||||||
}
|
}
|
||||||
.mobile.compact .control-pad {
|
.mobile.compact .control-pad {
|
||||||
order: initial;
|
order: initial;
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1 / -1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -1115,6 +1120,8 @@ const clickOutsideMenu = (event: Event) => {
|
|||||||
}
|
}
|
||||||
.mobile.compact .queue-area {
|
.mobile.compact .queue-area {
|
||||||
order: initial;
|
order: initial;
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 1;
|
||||||
padding-top: 10px;
|
padding-top: 10px;
|
||||||
}
|
}
|
||||||
.mobile.compact .queue-grid {
|
.mobile.compact .queue-grid {
|
||||||
@@ -1145,9 +1152,8 @@ const clickOutsideMenu = (event: Event) => {
|
|||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
.mobile.compact .advanced-actions {
|
.mobile.compact .advanced-actions {
|
||||||
right: 0;
|
position: static;
|
||||||
bottom: 0;
|
grid-column: 2;
|
||||||
left: 109px;
|
grid-row: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ export type CommandTable = {
|
|||||||
cities: CommandOption[];
|
cities: CommandOption[];
|
||||||
nations: CommandOption[];
|
nations: CommandOption[];
|
||||||
generals: CommandOption[];
|
generals: CommandOption[];
|
||||||
|
generalTargets?: Record<string, CommandOption[]>;
|
||||||
crewTypes: CommandOption[];
|
crewTypes: CommandOption[];
|
||||||
armTypes: CommandOption[];
|
armTypes: CommandOption[];
|
||||||
nationTypes: CommandOption[];
|
nationTypes: CommandOption[];
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ const visibleFields = computed(() => props.fields.filter((entry) => entry.kind !
|
|||||||
const optionsFor = (field: CommandInputField): CommandOption[] => {
|
const optionsFor = (field: CommandInputField): CommandOption[] => {
|
||||||
if (field.options) return field.options;
|
if (field.options) return field.options;
|
||||||
if (!field.optionSource) return [];
|
if (!field.optionSource) return [];
|
||||||
|
if (field.optionSource === 'generals') {
|
||||||
|
return props.options.generalTargets?.[props.commandKey] ?? props.options.generals;
|
||||||
|
}
|
||||||
if (field.optionSource === 'items') {
|
if (field.optionSource === 'items') {
|
||||||
return props.options.items[String(values.itemType ?? '')] ?? [];
|
return props.options.items[String(values.itemType ?? '')] ?? [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -352,22 +352,34 @@ const repeatTurns = async (amount: number) => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="chief-overview-frame">
|
<div class="chief-overview-frame">
|
||||||
<div class="chief-overview">
|
<div class="chief-overview">
|
||||||
<template v-for="chief in chiefViews" :key="chief.officerLevel">
|
<div
|
||||||
<ChiefTurnCard
|
v-for="(rowChiefs, rowIndex) in [chiefViews.slice(0, 4), chiefViews.slice(4, 8)]"
|
||||||
v-if="chief.name"
|
:key="rowIndex"
|
||||||
:officer-level-text="chief.officerLevelText"
|
class="chief-overview-row"
|
||||||
:name="chief.name"
|
>
|
||||||
:npc-state="chief.npcState"
|
<div class="overview-turn-index legacy-bg0" :aria-label="`${rowIndex + 1}행 턴 번호`">
|
||||||
:rows="chief.rows"
|
<span></span><span v-for="idx in data.maxTurns" :key="idx">{{ idx }}</span>
|
||||||
:compact="true"
|
</div>
|
||||||
:selected="chief.officerLevel === selectedChief?.officerLevel"
|
<template v-for="chief in rowChiefs" :key="chief.officerLevel">
|
||||||
:is-me="chief.officerLevel === data.me.officerLevel"
|
<ChiefTurnCard
|
||||||
:clickable="true"
|
v-if="chief.name"
|
||||||
:turn-time-label="chief.rows[0]?.time"
|
:officer-level-text="chief.officerLevelText"
|
||||||
@select="selectedChiefLevel = chief.officerLevel"
|
:name="chief.name"
|
||||||
/>
|
:npc-state="chief.npcState"
|
||||||
<div v-else class="empty-chief-slot" aria-hidden="true"></div>
|
:rows="chief.rows"
|
||||||
</template>
|
:compact="true"
|
||||||
|
:selected="chief.officerLevel === selectedChief?.officerLevel"
|
||||||
|
:is-me="chief.officerLevel === data.me.officerLevel"
|
||||||
|
:clickable="true"
|
||||||
|
:turn-time-label="chief.rows[0]?.time"
|
||||||
|
@select="selectedChiefLevel = chief.officerLevel"
|
||||||
|
/>
|
||||||
|
<div v-else class="empty-chief-slot" aria-hidden="true"></div>
|
||||||
|
</template>
|
||||||
|
<div class="overview-turn-index legacy-bg0" :aria-label="`${rowIndex + 1}행 턴 번호`">
|
||||||
|
<span></span><span v-for="idx in data.maxTurns" :key="idx">{{ idx }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -750,20 +762,25 @@ const repeatTurns = async (amount: number) => {
|
|||||||
.chief-overview-frame {
|
.chief-overview-frame {
|
||||||
width: 500px;
|
width: 500px;
|
||||||
height: 310px;
|
height: 310px;
|
||||||
margin-top: -3px;
|
margin-top: 0;
|
||||||
margin-bottom: 11px;
|
margin-bottom: 11px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.chief-overview {
|
.chief-overview {
|
||||||
width: 445px;
|
width: 500px;
|
||||||
height: 310px;
|
height: 310px;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.chief-overview-row {
|
||||||
|
width: 500px;
|
||||||
|
height: 155px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 111.25px);
|
grid-template-columns: 12px repeat(4, 119px) 12px;
|
||||||
grid-auto-rows: 155px;
|
|
||||||
}
|
}
|
||||||
.chief-overview :deep(.chief-card) {
|
.chief-overview :deep(.chief-card) {
|
||||||
width: 111.25px;
|
width: 119px;
|
||||||
height: 155px;
|
height: 155px;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-left: 1px solid #fff;
|
border-left: 1px solid #fff;
|
||||||
@@ -787,13 +804,34 @@ const repeatTurns = async (amount: number) => {
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
height: 20px !important;
|
height: 20px !important;
|
||||||
min-height: 20px !important;
|
min-height: 20px !important;
|
||||||
grid-template-rows: none;
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: 10px 10px;
|
||||||
|
line-height: 10px;
|
||||||
|
}
|
||||||
|
.chief-overview :deep(.compact-name),
|
||||||
|
.chief-overview :deep(.compact-meta) {
|
||||||
|
height: 10px;
|
||||||
|
line-height: 10px;
|
||||||
}
|
}
|
||||||
.chief-overview :deep(.row-time),
|
.chief-overview :deep(.row-time),
|
||||||
.chief-overview :deep(.row-action) {
|
.chief-overview :deep(.row-action) {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
}
|
}
|
||||||
|
.overview-turn-index {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 20px repeat(12, 11.25px);
|
||||||
|
width: 12px;
|
||||||
|
height: 155px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.55rem;
|
||||||
|
line-height: 11.25px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.overview-turn-index span {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
.mobile-readonly {
|
.mobile-readonly {
|
||||||
width: 404px;
|
width: 404px;
|
||||||
height: 420px;
|
height: 420px;
|
||||||
@@ -879,7 +917,7 @@ const repeatTurns = async (amount: number) => {
|
|||||||
|
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.chief-overview {
|
.chief-overview {
|
||||||
grid-template-columns: repeat(4, 111.25px);
|
width: 500px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -891,8 +929,8 @@ const repeatTurns = async (amount: number) => {
|
|||||||
.chief-grid-row > .empty-chief-slot {
|
.chief-grid-row > .empty-chief-slot {
|
||||||
height: 384px;
|
height: 384px;
|
||||||
}
|
}
|
||||||
.chief-overview > .empty-chief-slot {
|
.chief-overview-row > .empty-chief-slot {
|
||||||
width: 111.25px;
|
width: 119px;
|
||||||
height: 155px;
|
height: 155px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user