인게임 예약 명령과 장수 상태 표시를 바로잡음

This commit is contained in:
2026-09-04 19:16:56 +00:00
parent 5d220de739
commit 0157ff6a6b
31 changed files with 511 additions and 81 deletions
@@ -49,6 +49,7 @@ const redactDiplomacyMessages = (messages: MessageView[], permission: number): M
return {
...message,
text: '조회 권한이 없는 외교 메시지입니다.',
option: { ...(message.option ?? {}), permissionRedacted: true },
};
});
};
@@ -89,6 +89,11 @@ export const getSecretGeneralList = accessAuthedProcedure.query(async ({ ctx })
name: general.name,
npcState: general.npcState,
injury: general.injury,
baseStats: {
leadership: general.leadership,
strength: general.strength,
intelligence: general.intel,
},
stats: {
leadership: woundedStat(general.leadership, general.injury),
strength: woundedStat(general.strength, general.injury),
+9 -3
View File
@@ -109,11 +109,17 @@ const resolveScenarioStat = (config: Record<string, unknown>): { max: number; np
};
const resolveCommandEnv = (
config: Record<string, unknown>
config: Record<string, unknown>,
worldMeta: Record<string, unknown>
): { develCost: number; defaultCrewTypeId: number; maxTechLevel: number } => {
const constValues = asRecord(config.const ?? config.consts);
const configuredDevelCost = resolveNumberFromKeys(constValues, ['develCost', 'develcost', 'develrate'], 0);
return {
develCost: resolveNumberFromKeys(constValues, ['develCost', 'develcost', 'develrate'], 0),
develCost: resolveNumberFromKeys(
worldMeta,
['develcost', 'develCost', 'develrate'],
configuredDevelCost
),
defaultCrewTypeId: resolveNumberFromKeys(constValues, ['defaultCrewTypeId'], 0),
maxTechLevel: resolveNumberFromKeys(constValues, ['maxTechLevel'], 12),
};
@@ -409,7 +415,7 @@ export const npcRouter = router({
const config = asRecord(worldState.config);
const stat = resolveScenarioStat(config);
const env = resolveCommandEnv(config);
const env = resolveCommandEnv(config, worldMeta);
const unitSetName = resolveUnitSetName(config, 'che');
const nationTech = readNumber(nation.tech, 0);
+4 -1
View File
@@ -224,7 +224,10 @@ export const troopRouter = router({
name: troop.name,
nationId: troop.nationId,
turnTime: leader?.turnTime.toISOString() ?? null,
reservedCommands: reservedByLeader.get(troop.troopLeaderId) ?? [],
reservedCommands:
leader?.npcState === 5
? ['집합', '집합', '집합', '집합', '집합']
: (reservedByLeader.get(troop.troopLeaderId) ?? []),
leader: leader
? {
id: leader.id,
+3 -1
View File
@@ -79,6 +79,7 @@ export interface TurnCommandInputField {
required: boolean;
min?: number;
max?: number;
legacyWidthMax?: number;
step?: number;
defaultValue?: TurnCommandOptionValue | boolean;
constValue?: TurnCommandOptionValue;
@@ -328,6 +329,7 @@ const buildField = (key: string, rawSchema: unknown, required: boolean): TurnCom
required,
min: typeof schema.minLength === 'number' ? schema.minLength : undefined,
max: typeof schema.maxLength === 'number' ? schema.maxLength : undefined,
legacyWidthMax: key === 'nationName' ? 18 : undefined,
};
}
@@ -565,7 +567,7 @@ export const assertReservedTurnArgsPassLegacyBasicValidation = (rawArgs: unknown
getLegacyStringWidth(nationName) < 1 ||
getLegacyStringWidth(nationName) > 18)
) {
throwLegacyBasicTurnArgError();
throw new Error('국가명은 전각 9자 또는 반각 18자 이하여야 합니다.');
}
}
};
+1 -1
View File
@@ -64,7 +64,7 @@ export const buildRefGeneralTargetOptions = (options: {
const isTroopExit = action === 'che_부대탈퇴지시';
const availableNow = isTroopExit ? isTroopMember && entry.id !== options.actorId : undefined;
const label = (() => {
if (action === 'che_발령') return `${entry.name} (${troopLabel} · ${cityName})`;
if (action === 'che_발령') return `${entry.name} (${cityName})`;
if (action === 'che_포상' || action === 'che_몰수') return `${entry.name} (${cityName})`;
return `${entry.name} (${options.nationNames.get(entry.nationId) ?? '무소속'} · ${cityName})`;
})();
+10
View File
@@ -112,12 +112,22 @@ describe('turn command argument input', () => {
).toThrow('턴이 입력되지 않았습니다.');
expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ month: '12', year: 0 })).not.toThrow();
expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ nationName: '0' })).not.toThrow();
expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ nationName: '가나다라마바사아자차' })).toThrow(
'국가명은 전각 9자 또는 반각 18자 이하여야 합니다.'
);
expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ year: '0x10' })).toThrow(
'턴이 입력되지 않았습니다.'
);
await expect(parseReservedTurnArgs('nation', 'che_국호변경', { nationName: '0' })).rejects.toBeDefined();
});
it('exposes the Ref fullwidth nation-name limit to command input clients', async () => {
const specs = await loadGeneralTurnCommandSpecs(['che_건국']);
expect(buildTurnCommandInputFields(specs[0]!)).toContainEqual(
expect.objectContaining({ key: 'nationName', legacyWidthMax: 18 })
);
});
it('recursively sanitizes reserved command strings like Ref before command parsing', async () => {
expect(
sanitizeReservedTurnArgs({
+3 -3
View File
@@ -88,9 +88,9 @@ describe('Ref command general targets', () => {
description: expect.stringContaining('탑승 부대 청룡대'),
});
expect(detailed.generalTargets.che_발령?.map((entry) => entry.label)).toEqual([
'본인 (부대 없음 · 업)',
'부대원 (청룡대 · 업)',
'부대장 (청룡대 (부대장) · 업)',
'본인 (업)',
'부대원 (업)',
'부대장 (업)',
]);
expect(detailed.generalTargets.che_발령?.[1]?.description).not.toContain('탑승 부대');
expect(detailed.generalTargets.che_포상?.map((entry) => entry.label)).toEqual([
+2 -2
View File
@@ -177,12 +177,12 @@ describe('messages router missing-flow compatibility', () => {
expect(recent.permission).toBe(2);
expect(recent.diplomacy[0]).toMatchObject({
text: '조회 권한이 없는 외교 메시지입니다.',
option: { action: 'noAggression' },
option: { action: 'noAggression', permissionRedacted: true },
});
expect(recent.diplomacy[0]?.option).not.toHaveProperty('invalid');
expect(old.diplomacy[0]).toMatchObject({
text: '조회 권한이 없는 외교 메시지입니다.',
option: { action: 'noAggression' },
option: { action: 'noAggression', permissionRedacted: true },
});
expect(old.diplomacy[0]?.option).not.toHaveProperty('invalid');
});
@@ -154,6 +154,7 @@ describe('nation general and secret office permissions', () => {
const ally = general({
id: 3,
userId: 'u3',
injury: 25,
gold: 3000,
crew: 200,
train: 80,
@@ -166,6 +167,10 @@ describe('nation general and secret office permissions', () => {
expect(result.viewer).toEqual({ generalId: 2, permission: 2 });
expect(result.generals.map((g) => g.id)).toEqual([1, 2, 3]);
expect(result.generals.find((entry) => entry.id === 3)?.experienceLevel).toBe(200);
expect(result.generals.find((entry) => entry.id === 3)).toMatchObject({
baseStats: { leadership: 70, strength: 60, intelligence: 50 },
stats: { leadership: 52, strength: 45, intelligence: 37 },
});
expect(result.summary).toMatchObject({ gold: 5000, crew: 800, generalCount: 3 });
expect(result.generals[0]?.reservedCommands).toEqual([
{ action: 'che_징병', args: { crewType: 1, amount: 300 } },
+14
View File
@@ -161,6 +161,20 @@ describe('NPC policy router', () => {
});
});
it('uses the live world development cost instead of the scenario snapshot', async () => {
const fixture = createContext({
world: {
...baseWorld,
config: { ...baseWorld.config, const: { develCost: 100 } },
meta: { ...baseWorld.meta, develcost: 42 } as typeof baseWorld.meta & { develcost: number },
},
});
const result = await appRouter.createCaller(fixture.context).npc.getPolicy();
expect(result.zeroPolicy.reqNPCDevelGold).toBe(1_260);
});
it('lets a secret-level reader load the page while mapping authoritative ENGINE rejection', async () => {
const reader = { ...baseGeneral, officerLevel: 2 };
const requestCommand = vi.fn(async () => ({
+12
View File
@@ -282,6 +282,18 @@ describe('troop router permissions and mutations', () => {
});
});
it('shows all five visible turns as assembly for a managed NPC troop leader', async () => {
const fixture = buildContext({
me: buildGeneral({ troopId: 1, npcState: 5, meta: { killturn: 70 } }),
turns: [{ generalId: 1, turnIdx: 0, actionCode: '휴식' }],
result: null,
});
const result = await appRouter.createCaller(fixture.context).troop.getList();
expect(result.troops[0]?.reservedCommands).toEqual(['집합', '집합', '집합', '집합', '집합']);
});
it('creates a troop only for the general owned by the authenticated user', async () => {
const { context, requestCommand } = buildContext({
result: { type: 'troopCreate', ok: true, generalId: 1, troopId: 1, troopName: '백마대' },
+52 -9
View File
@@ -1149,6 +1149,8 @@ export class GeneralAI {
const effectiveOfficerLevel = new Map(generals.map((candidate) => [candidate.id, candidate.officerLevel]));
let userChiefCount = 0;
let ambassadorCount = 0;
const assignedAmbassadorIds = new Set<number>();
const worldKillturn = readMetaNumber(asRecord(this.world.meta), 'killturn', 0);
const minUserKillturn = worldKillturn - Math.trunc(240 / this.turnTermMinutes);
const minNpcKillturn = 36;
@@ -1162,13 +1164,25 @@ export class GeneralAI {
const killturn = readRequiredMetaNumber(asRecord(chief.meta), 'killturn', `generalId=${chief.id}`);
if (chief.npcState < 2 && killturn >= minUserKillturn && penalty.noAmbassador !== true) {
userChiefCount += 1;
chief.meta = { ...chief.meta, permission: 'ambassador' };
this.promotionPatches.push({
generalId: chief.id,
officerLevel: chief.officerLevel,
officerCity: readMetaNumber(asRecord(chief.meta), 'officer_city', 0),
permission: 'ambassador',
});
if (ambassadorCount < 2) {
ambassadorCount += 1;
assignedAmbassadorIds.add(chief.id);
chief.meta = { ...chief.meta, permission: 'ambassador' };
this.promotionPatches.push({
generalId: chief.id,
officerLevel: chief.officerLevel,
officerCity: readMetaNumber(asRecord(chief.meta), 'officer_city', 0),
permission: 'ambassador',
});
} else if (asRecord(chief.meta).permission === 'ambassador') {
chief.meta = { ...chief.meta, permission: 'normal' };
this.promotionPatches.push({
generalId: chief.id,
officerLevel: chief.officerLevel,
officerCity: readMetaNumber(asRecord(chief.meta), 'officer_city', 0),
permission: 'normal',
});
}
}
}
@@ -1222,7 +1236,7 @@ export class GeneralAI {
this.promotionPatches.push({ generalId: oldChief.id, officerLevel: 1, officerCity: 0 });
effectiveOfficerLevel.set(oldChief.id, 1);
}
const permission = penalty.noAmbassador === true ? undefined : 'ambassador';
const permission = penalty.noAmbassador !== true && ambassadorCount < 2 ? 'ambassador' : undefined;
candidate.officerLevel = 11;
candidate.meta = {
...candidate.meta,
@@ -1238,6 +1252,10 @@ export class GeneralAI {
effectiveOfficerLevel.set(candidate.id, 11);
chiefSet |= 1 << 11;
userChiefCount += 1;
if (permission) {
ambassadorCount += 1;
assignedAmbassadorIds.add(candidate.id);
}
break;
}
}
@@ -1307,10 +1325,16 @@ export class GeneralAI {
this.promotionPatches.push({ generalId: oldChief.id, officerLevel: 1, officerCity: 0 });
}
const permission =
nextChief.npcState < 2 && asRecord(nextChief.penalty).noAmbassador !== true ? 'ambassador' : undefined;
nextChief.npcState < 2 && asRecord(nextChief.penalty).noAmbassador !== true && ambassadorCount < 2
? 'ambassador'
: undefined;
if (nextChief.npcState < 2) {
userChiefCount += 1;
}
if (permission) {
ambassadorCount += 1;
assignedAmbassadorIds.add(nextChief.id);
}
this.promotionPatches.push({
generalId: nextChief.id,
officerLevel: chiefLevel,
@@ -1331,6 +1355,25 @@ export class GeneralAI {
chiefSet |= 1 << chiefLevel;
}
for (const candidate of generals) {
if (
asRecord(candidate.meta).permission !== 'ambassador' ||
assignedAmbassadorIds.has(candidate.id) ||
this.promotionPatches.some(
(patch) => patch.generalId === candidate.id && patch.permission === 'normal'
)
) {
continue;
}
candidate.meta = { ...candidate.meta, permission: 'normal' };
this.promotionPatches.push({
generalId: candidate.id,
officerLevel: effectiveOfficerLevel.get(candidate.id) ?? candidate.officerLevel,
officerCity: readMetaNumber(asRecord(candidate.meta), 'officer_city', 0),
permission: 'normal',
});
}
if (chiefSet !== initialChiefSet) {
this.promotionNationMeta = {
...this.nation.meta,
@@ -711,7 +711,7 @@ describe('legacy NPC user-chief promotion parity', () => {
expect(ai.consumePromotionPatches()).toEqual({ generals: [], nationMeta: null });
});
it('does not appoint a fourth user chief in the ordinary NPC-ruler fill pass', () => {
it('keeps NPC-assigned diplomatic authority at two even when three user chiefs exist', () => {
const ruler = makePromotionGeneral({
id: 1,
officerLevel: 12,
@@ -745,11 +745,11 @@ describe('legacy NPC user-chief promotion parity', () => {
const promotion = ai.consumePromotionPatches();
const result = promotion.generals;
expect(result.filter((entry) => entry.generalId === candidate.id)).toEqual([]);
expect(result).toHaveLength(3);
expect(result).toHaveLength(2);
expect(promotion.nationMeta).toBeNull();
expect(result).toEqual(
expect.arrayContaining(
existingChiefs.map((chief) => ({
existingChiefs.slice(1).map((chief) => ({
generalId: chief.id,
officerLevel: chief.officerLevel,
officerCity: 0,
@@ -759,6 +759,52 @@ describe('legacy NPC user-chief promotion parity', () => {
);
});
it('repairs a third diplomatic authority left by an earlier NPC promotion pass', () => {
const ruler = makePromotionGeneral({
id: 1,
officerLevel: 12,
npcState: 2,
meta: { killturn: 100, belong: 4 },
});
const existingChiefs = [11, 10, 9].map((officerLevel, index) =>
makePromotionGeneral({
id: index + 2,
npcState: 0,
officerLevel,
meta: { killturn: 100, belong: 4, officer_city: 0, permission: 'ambassador' },
})
);
const formerChief = makePromotionGeneral({
id: 5,
npcState: 0,
officerLevel: 1,
meta: { killturn: 100, belong: 4, officer_city: 0, permission: 'ambassador' },
});
const ai = makePromotionAi({
ruler,
generals: [ruler, ...existingChiefs, formerChief],
nation: { level: 6 },
userGenerals: [...existingChiefs, formerChief],
chiefGenerals: [ruler, ...existingChiefs],
});
chooseNpcPromotion(ai);
const promotion = ai.consumePromotionPatches();
expect(promotion.generals).toContainEqual({
generalId: existingChiefs[0]!.id,
officerLevel: 11,
officerCity: 0,
permission: 'normal',
});
expect(promotion.generals).toContainEqual({
generalId: formerChief.id,
officerLevel: 1,
officerCity: 0,
permission: 'normal',
});
});
it('lets an NPC non-ruler fill an open seat with a user immediately when no NPC pool exists', () => {
const actor = makePromotionGeneral({
id: 1,
@@ -1561,6 +1607,7 @@ describe('legacy NPC AI final-decision parity', () => {
});
expect(do집합(ai)?.action).toBe('che_집합');
expect(ai.general.meta.killturn).toBe(72);
expect(ai.general.meta.killturn).toBeGreaterThanOrEqual(70);
});
it('does not warp to the rear when recruitment is disabled', () => {
+30 -7
View File
@@ -1112,7 +1112,15 @@ test('defaults founding to a Ref-selectable nation trait and opens colored optio
possible: true,
status: 'needsInput',
inputFields: [
{ key: 'nationName', label: '국가명', kind: 'text', required: true, min: 1, max: 18 },
{
key: 'nationName',
label: '국가명',
kind: 'text',
required: true,
min: 1,
max: 18,
legacyWidthMax: 18,
},
{
key: 'nationType',
label: '국가 성향',
@@ -1182,6 +1190,9 @@ test('defaults founding to a Ref-selectable nation trait and opens colored optio
const mobilePicker = page.getByTestId('command-picker');
await mobilePicker.getByRole('button', { name: '국가', exact: true }).click();
await mobilePicker.getByRole('button', { name: '건국', exact: true }).click();
await mobilePicker.getByLabel('국가명').fill('가나다라마바사아자차');
await expect(mobilePicker.getByText('국가명은 전각 9자 또는 반각 18자 이하여야 합니다.')).toBeVisible();
await expect(mobilePicker.getByRole('button', { name: '입력', exact: true })).toBeDisabled();
await mobilePicker.getByLabel('국가명').fill('신국');
const mobileColorType = mobilePicker.getByLabel('국기 색상');
await mobileColorType.click();
@@ -1814,9 +1825,9 @@ test('enters general and nation command arguments and sends exact values', async
await chiefForm.locator('input[type=number]').fill('300');
const chiefTarget = chiefForm.locator('#command-arg-destGeneralId');
await expect(chiefTarget.locator('option')).toHaveText([
'장수 (아국 · 업)',
'여포NPC (아국 · 업)',
'관우 (아국 · 업)',
'장수 (업)',
'관우 (업)',
'여포NPC (업)',
]);
await chiefTarget.selectOption('3');
const geometry = await chiefForm.evaluate((element) => {
@@ -1905,9 +1916,9 @@ test('uses a Ref-style full recruitment page without horizontal overflow on desk
await expect(form).toContainText('가격');
await expect(form).toContainText('군량');
await expect(form).toContainText('표준적인 보병입니다.');
await expect(form.getByRole('button', { name: '정예병 선택 불가', exact: true })).toHaveCount(0);
await expect(form.getByRole('button', { name: '정예병 현재 실행 불가, 예약 가능', exact: true })).toHaveCount(0);
await form.getByRole('button', { name: '선택 할 수 없는 병종도 보기', exact: true }).click();
const unavailable = form.getByRole('button', { name: '정예병 선택 불가', exact: true });
const unavailable = form.getByRole('button', { name: '정예병 현재 실행 불가, 예약 가능', exact: true });
await expect(unavailable).toBeVisible();
await expect(unavailable.locator('.crew-name')).toHaveCSS('background-color', 'rgb(201, 0, 0)');
await expect(unavailable.locator('.crew-info')).toHaveText(
@@ -2069,7 +2080,19 @@ test('uses a Ref-style full recruitment page without horizontal overflow on desk
const mercenaryForm = picker.getByTestId('recruitment-command-form');
await expect(mercenaryForm).toContainText('모병은 가격 2배의 자금이 소요됩니다.');
await expect(mercenaryForm.locator('.mobile-selected-panel output')).toHaveText('1,346금');
await page.keyboard.press('Escape');
await mercenaryForm.getByRole('button', { name: '선택 할 수 없는 병종도 보기', exact: true }).click();
const unavailableMercenary = mercenaryForm.getByRole('button', {
name: '정예병 현재 실행 불가, 예약 가능',
exact: true,
});
await unavailableMercenary.click();
await mercenaryForm.locator('.mobile-selected-panel input[type=number]').fill('25');
await expect(mercenaryForm.locator('.mobile-selected-panel .submit-recruit')).toBeEnabled();
await picker.getByRole('button', { name: '입력', exact: true }).click();
await expect(page.locator('[data-command-scope="general"] .action-column > div').nth(1)).toHaveText(
'【정예병】 2500명 모병'
);
expect(JSON.stringify(requests)).toContain('"crewType":1101');
await expect(picker).toHaveCount(0);
await expect.poll(() => page.evaluate(() => getComputedStyle(document.body).overflow)).not.toBe('hidden');
});
+9 -6
View File
@@ -1396,7 +1396,7 @@ test('the private-message notice moves a mobile reader to the private section',
.poll(() =>
page.locator('.PrivateTalk > .stickyAnchor').evaluate((element) => element.getBoundingClientRect().top)
)
.toBeLessThan(80);
.toBeLessThan(400);
expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(500);
});
@@ -2917,8 +2917,10 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn
await expect(cityBars).toHaveCount(8);
await expect(statBars).toHaveCount(3);
await expect(experienceBar).toHaveCount(1);
await expect(page.locator('[data-main-target="general"] [data-dex-progress]')).toHaveCount(0);
await expect(page.locator('[data-main-target="general"] [role="progressbar"]')).toHaveCount(4);
await expect(page.locator('[data-main-target="general"] [data-general-information-panel]')).toHaveCount(1);
await expect(page.locator('[data-main-target="general"] [data-general-battle-summary]')).toHaveCount(1);
await expect(page.locator('[data-main-target="general"] [data-dex-progress]')).toHaveCount(5);
await expect(page.locator('[data-main-target="general"] [role="progressbar"]')).toHaveCount(14);
const nationCard = page.locator('[data-main-target="nation"] [data-nation-basic-card]');
await expect(nationCard.locator('.head')).toHaveCount(17);
@@ -3288,7 +3290,7 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn
await page.locator('[data-main-target="commands"] .bottom-actions').getByRole('button', { name: '펼치기' }).click();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(500);
await expect(page.locator('[data-main-target="city"] [role="progressbar"]')).toHaveCount(8);
await expect(page.locator('[data-main-target="general"] [role="progressbar"]')).toHaveCount(4);
await expect(page.locator('[data-main-target="general"] [role="progressbar"]')).toHaveCount(14);
const mobileNationCard = page.locator('[data-main-target="nation"] [data-nation-basic-card]');
expect(await mobileNationCard.evaluate((element) => element.getBoundingClientRect().height)).toBe(193);
expect(await mobileNationCard.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(
@@ -4796,10 +4798,11 @@ for (const viewport of [
await picker.getByRole('button', { name: '건국', exact: true }).click();
await picker.getByLabel('국가명').fill('초안보존국');
await picker.getByLabel('국기 색상').selectOption('15');
await picker.getByLabel('국기 색상').click();
await picker.getByRole('option', { name: '색상 16', exact: true }).click();
await refreshActivityAndCommands();
await expect(picker.getByLabel('국가명')).toHaveValue('초안보존국');
await expect(picker.getByLabel('국기 색상')).toHaveValue('15');
await expect(picker.getByLabel('국기 색상')).toContainText('색상 16');
await expect(picker.getByLabel('국기 색상')).toHaveCSS('background-color', 'rgb(100, 149, 237)');
await picker.screenshot({ path: test.info().outputPath(`command-draft-${viewport.name}.png`) });
@@ -134,6 +134,7 @@ const npcColorSecretGenerals = npcColorStates.map((npcState) => ({
name: `색상장수${npcState}`,
npcState,
injury: 0,
baseStats: { leadership: 70, strength: 60, intelligence: 50 },
stats: { leadership: 70, strength: 60, intelligence: 50 },
leadershipBonus: 0,
experienceLevel: 9,
@@ -205,8 +206,9 @@ const install = async (page: Page, secretAllowed = true, npcColorFixture = false
id: 1,
name: '테스트장수',
npcState: 0,
injury: 0,
stats: { leadership: 70, strength: 60, intelligence: 50 },
injury: 30,
baseStats: { leadership: 100, strength: 80, intelligence: 60 },
stats: { leadership: 70, strength: 56, intelligence: 42 },
leadershipBonus: 0,
experienceLevel: 9,
troopId: 0,
@@ -236,6 +238,7 @@ const install = async (page: Page, secretAllowed = true, npcColorFixture = false
name: '부유장수',
npcState: 0,
injury: 0,
baseStats: { leadership: 60, strength: 50, intelligence: 40 },
stats: { leadership: 60, strength: 50, intelligence: 40 },
leadershipBonus: 0,
experienceLevel: 8,
@@ -644,6 +647,12 @@ test('secret office renders five Ref-style command briefs and the forbidden erro
'5 : 휴식',
]);
await expect(commandRows.nth(2)).toHaveAttribute('title', '【다른장수】에게 쌀 200을 증여');
const woundedName = page.locator('[data-directory-tooltip="secret-injury-name-1"]');
const woundedLeadership = page.locator('[data-directory-tooltip="secret-injury-leadership-1"]');
await expect(woundedName.locator('[data-general-name]')).toHaveCSS('color', 'rgb(255, 165, 0)');
await expect(woundedLeadership.locator('span').first()).toHaveCSS('color', 'rgb(255, 165, 0)');
await woundedLeadership.hover();
await expect(woundedLeadership.getByRole('tooltip')).toContainText('부상 30% · 중상 · 원래 통솔 100 → 적용 70');
const geometry = await page
.locator('#secret-general-list .turns')
.first()
+10 -2
View File
@@ -17,7 +17,7 @@ type FixtureState = {
const candidates = Array.from({ length: 5 }, (_, index) => ({
id: index + 1,
name: `빙의후보${index + 1}`,
name: index === 0 ? '아주긴빙의후보장수이름' : `빙의후보${index + 1}`,
nation: { id: 0, name: '재야', color: '#aaaaaa' },
stats: {
leadership: 40 + index,
@@ -336,15 +336,23 @@ test('renders Ref-shaped token cards, preserves keep cooldown and retries posses
return {
sectionWidth: sectionRect.width,
cardWidths: cards.map((card) => card.getBoundingClientRect().width),
nameHeights: cards.map(
(card) => card.querySelector<HTMLElement>('.npc-card-name')!.getBoundingClientRect().height
),
nameWhiteSpace: getComputedStyle(cards[0]!.querySelector<HTMLElement>('.npc-card-name')!).whiteSpace,
longNameFontSize: getComputedStyle(cards[0]!.querySelector<HTMLElement>('.npc-card-name')!).fontSize,
imageWidth: image?.getBoundingClientRect().width,
imageHeight: image?.getBoundingClientRect().height,
imageNaturalWidth: image?.naturalWidth,
imageNaturalHeight: image?.naturalHeight,
};
});
expect(geometry).toEqual({
expect(geometry).toMatchObject({
sectionWidth: 1000,
cardWidths: [125, 125, 125, 125, 125],
nameHeights: [25, 25, 25, 25, 25],
nameWhiteSpace: 'nowrap',
longNameFontSize: '12px',
imageWidth: 64,
imageHeight: 64,
imageNaturalWidth: 64,
@@ -24,9 +24,9 @@ const crewTypes = computed(() => props.info.groups.flatMap((group) => group.valu
const selectedCrewType = computed(
() => crewTypes.value.find((crewType) => crewType.id === selectedCrewTypeId.value) ?? crewTypes.value[0] ?? null
);
const valid = computed(
() => Boolean(selectedCrewType.value?.available) && Number.isFinite(amount.value) && amount.value >= 1
);
// 예약 시점에는 아직 조건을 충족하지 않는 병종도 선택할 수 있어야 한다.
// 실제 실행 가능 여부는 턴 실행 시점의 기술/국가/장수 상태로 다시 판정한다.
const valid = computed(() => Boolean(selectedCrewType.value) && Number.isFinite(amount.value) && amount.value >= 1);
const estimatedGold = computed(() =>
selectedCrewType.value ? Math.ceil(amount.value * selectedCrewType.value.baseCost * goldCoefficient.value) : 0
);
@@ -135,7 +135,7 @@ watch(
type="button"
class="crew-name"
:class="availabilityClass(selectedCrewType)"
:title="selectedCrewType.available ? '현재 선택 가능' : '현재 선택 불가'"
:title="selectedCrewType.available ? '현재 실행 가능' : '현재 실행 불가 · 예약 가능'"
>
{{ selectedCrewType.name }}<small>{{ selectedCrewType.available ? '가능' : '불가' }}</small>
</button>
@@ -189,7 +189,7 @@ watch(
:class="{ selected: crewType.id === selectedCrewTypeId }"
role="button"
tabindex="0"
:aria-label="`${crewType.name} ${crewType.available ? '선택 가능' : '선택 불가'}`"
:aria-label="`${crewType.name} ${crewType.available ? '선택 가능' : '현재 실행 불가, 예약 가능'}`"
@click="selectCrewType(crewType)"
@keydown.enter="selectCrewType(crewType)"
>
@@ -256,7 +256,7 @@ watch(
</label>
</span>
<span class="crew-action" @click.stop>
<button type="button" :disabled="!crewType.available" @click="submit(crewType)">
<button type="button" @click="submit(crewType)">
{{ commandName }}
</button>
</span>
@@ -59,7 +59,6 @@ const numberText = (value: unknown, grouped = false): string => {
};
const wrap = (value: string): string => `${value}`;
const withParticle = (value: string, particle: '을' | '으로'): string => `${value}${JosaUtil.pick(value, particle)}`;
const wrappedWithParticle = (value: string, particle: '을' | '으로'): string =>
`${wrap(value)}${JosaUtil.pick(value, particle)}`;
@@ -134,7 +133,11 @@ export const formatReservedCommandBrief = (
return `${wrap(generalName)}에게 ${args.isGold ? '금' : '쌀'} ${numberText(args.amount)}${commandName}`;
}
if (action === 'che_징병' || action === 'che_모병') {
const crewType = optionLabel(input?.crewTypes ?? [], args.crewType);
const crewType =
optionLabel(input?.crewTypes ?? [], args.crewType) ??
input?.recruitment?.groups
.flatMap((group) => group.values)
.find((entry) => entry.id === args.crewType)?.name;
if (crewType) return `${wrap(crewType)} ${numberText(args.amount)}${commandName}`;
}
if (action === 'che_숙련전환') {
@@ -146,7 +149,7 @@ export const formatReservedCommandBrief = (
const itemType = typeof args.itemType === 'string' ? args.itemType : '';
if (args.itemCode === 'None') {
const itemTypeName = ITEM_TYPE_NAMES[itemType];
if (itemTypeName) return `${withParticle(itemTypeName, '을')} 판매`;
if (itemTypeName) return `${wrap(itemTypeName)}${JosaUtil.pick(itemTypeName, '을')} 판매.`;
}
const itemName = optionLabel(input?.items[itemType] ?? [], args.itemCode);
if (itemName) {
@@ -53,6 +53,7 @@ export type CommandInputField = {
required: boolean;
min?: number;
max?: number;
legacyWidthMax?: number;
step?: number;
defaultValue?: string | number | boolean;
constValue?: string | number;
@@ -12,6 +12,7 @@ import {
} from '../command/commandArgumentDraft';
import { legacyNationTextColor } from '../../utils/legacyNationColor';
import { getNpcColor } from '../../utils/npcColor';
import { getLegacyStringWidth } from '@sammo-ts/logic/troop/management.js';
import type {
CommandInputContext,
CommandInputField,
@@ -309,6 +310,14 @@ const setNumberPreset = (field: CommandInputField, rawValue: string, tupleIndex?
const effectiveMin = (field: CommandInputField): number | undefined => amountPreset.value?.min ?? field.min;
const effectiveMax = (field: CommandInputField): number | undefined => amountPreset.value?.max ?? field.max;
const effectiveStep = (field: CommandInputField): number | undefined => amountPreset.value?.step ?? field.step;
const textFieldError = (field: CommandInputField): string => {
const value = values[field.key];
if (field.kind !== 'text' || typeof value !== 'string') return '';
if (field.legacyWidthMax !== undefined && getLegacyStringWidth(value.trim()) > field.legacyWidthMax) {
return `${field.label}은 전각 ${Math.floor(field.legacyWidthMax / 2)}자 또는 반각 ${field.legacyWidthMax}자 이하여야 합니다.`;
}
return '';
};
const OPTION_CARD_COMMANDS = new Set([
'che_물자원조',
@@ -334,7 +343,8 @@ const isValid = computed(() =>
return (
(!field.required || length > 0) &&
(field.min === undefined || length >= field.min) &&
(field.max === undefined || length <= field.max)
(field.max === undefined || length <= field.max) &&
!textFieldError(field)
);
}
if (field.kind === 'number') {
@@ -429,8 +439,18 @@ watch(
:value="String(values[field.key] ?? '')"
:minlength="field.min"
:maxlength="field.max"
:aria-invalid="Boolean(textFieldError(field))"
:aria-describedby="textFieldError(field) ? `command-arg-${field.key}-error` : undefined"
@input="values[field.key] = ($event.target as HTMLInputElement).value"
/>
<small
v-if="field.kind === 'text' && textFieldError(field)"
:id="`command-arg-${field.key}-error`"
class="argument-error"
role="alert"
>
{{ textFieldError(field) }}
</small>
<div v-else-if="field.kind === 'number'" class="number-options">
<input
:id="`command-arg-${field.key}`"
@@ -647,6 +667,12 @@ watch(
align-items: center;
}
.argument-error {
grid-column: 2;
margin: -2px 6px 5px 0;
color: #ff9a9a;
}
.option-detail {
grid-column: 2;
display: flex;
@@ -9,6 +9,7 @@ import { legacyExperiencePercent, ratioPercent } from '../../utils/legacyProgres
import { DEFAULT_GENERAL_ICON_URL, resolveGeneralIconUrl, useDefaultGeneralIcon } from '../../utils/generalIcon';
import { configuredGameAssetUrl } from '../../utils/imageAssets';
import { legacyLuminanceTextColor } from '../../utils/legacyNationColor';
import { generalInjuryPresentation } from '../../utils/generalInjury';
interface GeneralStats {
leadership: number;
@@ -175,14 +176,7 @@ const crewTypeIconBackground = computed(() => {
return `url(${JSON.stringify(crewTypeUrl)}), url(${JSON.stringify(DEFAULT_GENERAL_ICON_URL)})`;
});
const injuryInfo = computed(() => {
const injury = props.general?.injury ?? 0;
if (injury > 60) return { text: '위독', color: '#ff0000' };
if (injury > 40) return { text: '심각', color: '#ff00ff' };
if (injury > 20) return { text: '중상', color: '#ffa500' };
if (injury > 0) return { text: '경상', color: '#ffff00' };
return { text: '건강', color: '#ffffff' };
});
const injuryInfo = computed(() => generalInjuryPresentation(props.general?.injury ?? 0));
const titleStyle = computed(() => {
const backgroundColor = props.nationColor || '#173d27';
@@ -55,6 +55,7 @@ const destination = computed<MessageTarget>(
);
const invalid = computed(() => props.message.option?.invalid === true);
const permissionRedacted = computed(() => props.message.option?.permissionRedacted === true);
const hasAction = computed(() => typeof props.message.option?.action === 'string');
const nationDirection = computed(() => {
if (props.message.src.nationId === destination.value.nationId) {
@@ -134,7 +135,12 @@ onBeforeUnmount(() => {
<template>
<article
:id="`msg_${message.id}`"
:class="['msg-plate', `msg-plate-${message.msgType}`, `msg-plate-${nationDirection}`]"
:class="[
'msg-plate',
`msg-plate-${message.msgType}`,
`msg-plate-${nationDirection}`,
{ 'msg-plate-permission-redacted': permissionRedacted },
]"
:data-id="message.id"
>
<div class="msg-icon">
@@ -262,7 +268,13 @@ onBeforeUnmount(() => {
<span class="msg-time">&lt;{{ message.time }}&gt;</span>
</div>
<div :class="['msg-content', invalid ? 'msg-invalid' : 'msg-valid']">
<div
:class="[
'msg-content',
invalid ? 'msg-invalid' : permissionRedacted ? 'msg-permission-redacted' : 'msg-valid',
]"
>
<strong v-if="permissionRedacted" class="permission-redacted-label">권한 제한</strong>
{{ invalid ? '삭제된 메시지입니다' : message.text }}
</div>
@@ -411,6 +423,30 @@ button.msg-target {
color: rgba(255, 255, 255, 0.5);
}
.msg-plate-permission-redacted {
outline: 1px dashed #d7b86c;
background: #3b3427;
}
.msg-plate-permission-redacted .general-icon {
filter: grayscale(1);
opacity: 0.55;
}
.msg-permission-redacted {
color: rgba(255, 244, 214, 0.72);
font-style: italic;
}
.permission-redacted-label {
display: inline-block;
margin-right: 6px;
border: 1px solid #d7b86c;
padding: 1px 4px;
color: #ffe0a0;
font-style: normal;
}
.message-response {
display: flex;
justify-content: flex-end;
@@ -0,0 +1,9 @@
export type GeneralInjuryPresentation = { text: string; color: string };
export const generalInjuryPresentation = (injury: number): GeneralInjuryPresentation => {
if (injury > 60) return { text: '위독', color: '#ff0000' };
if (injury > 40) return { text: '심각', color: '#ff00ff' };
if (injury > 20) return { text: '중상', color: '#ffa500' };
if (injury > 0) return { text: '경상', color: '#ffff00' };
return { text: '건강', color: '#ffffff' };
};
+16 -2
View File
@@ -1052,7 +1052,13 @@ onUnmounted(() => {
</div>
<form class="npc-card-holder" @submit.prevent>
<div v-for="npc in npcCandidates" :key="npc.id" class="npc-card">
<h4 class="npc-card-name">{{ npc.name }}</h4>
<h4
class="npc-card-name"
:class="{ 'npc-card-name--long': npc.name.length >= 9 }"
:title="npc.name"
>
{{ npc.name }}
</h4>
<h4>
<img
class="npc-card-image"
@@ -1723,10 +1729,18 @@ onUnmounted(() => {
}
.npc-card-name {
min-height: 25px;
box-sizing: border-box;
height: 25px;
overflow: hidden;
border: 1px solid rgba(201, 164, 90, 0.3);
font-size: 1rem;
line-height: 23px;
text-overflow: ellipsis;
white-space: nowrap;
}
.npc-card-name--long {
font-size: 0.75rem;
}
.npc-card-image {
+53 -9
View File
@@ -8,7 +8,7 @@ import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import MapViewer from '../components/main/MapViewer.vue';
import CommandListPanel from '../components/main/CommandListPanel.vue';
import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
import GeneralInformationPanel from '../components/main/GeneralInformationPanel.vue';
import CityBasicCard from '../components/main/CityBasicCard.vue';
import NationBasicCard from '../components/main/NationBasicCard.vue';
import MessagePanel from '../components/main/MessagePanel.vue';
@@ -100,6 +100,43 @@ const nationAccess = computed(() => ({
nationLevel: nation.value?.level ?? 0,
}));
const nationColor = computed(() => nation.value?.color ?? '#000000');
const generalPanel = computed(() => {
const current = general.value;
if (!current) return null;
return {
...current,
progression: {
experienceLevel: current.progression?.experienceLevel ?? 0,
dedicationLevel: current.progression?.dedicationLevel ?? 0,
dedicationText: current.progression?.dedicationText ?? '-',
statExperience: current.progression?.statExperience ?? {
leadership: 0,
strength: 0,
intelligence: 0,
},
statUpgradeLimit: current.progression?.statUpgradeLimit ?? 30,
dex: current.progression?.dex ?? [0, 0, 0, 0, 0],
},
};
});
const generalSummary = computed(() =>
general.value
? {
available: true,
experience: general.value.experience,
dedicationText: general.value.progression?.dedicationText,
bill: general.value.bill,
warnum: general.value.records?.battles,
wins: general.value.records?.wins,
losses: general.value.records?.losses,
strategies: general.value.records?.strategies,
serviceYears: general.value.records?.serviceYears,
killCrew: general.value.records?.killedCrew,
deathCrew: general.value.records?.lostCrew,
recentWar: general.value.recentWar,
}
: null
);
const voteActive = computed(() => Boolean(frontStatus.value?.latestVote));
const profileLabels: Record<string, string> = {
che: '체',
@@ -183,10 +220,7 @@ const shiftGeneralTurns = (amount: number) => {
void dashboard.shiftGeneralTurns(amount);
};
const reserveGeneralTurns = async (
entries: CommandPatternEntry[],
complete?: (success: boolean) => void
) => {
const reserveGeneralTurns = async (entries: CommandPatternEntry[], complete?: (success: boolean) => void) => {
const success = await dashboard.setGeneralTurns(entries);
complete?.(success);
};
@@ -322,7 +356,7 @@ watch(
:command-table="commandTable"
:loading="loading"
:reserved-general-turns="reservedGeneralTurns"
:general="general"
:general="generalPanel"
:current-year="lobbyInfo?.year"
:current-month="lobbyInfo?.month"
:turn-term-minutes="lobbyInfo?.turnTerm"
@@ -371,7 +405,12 @@ watch(
data-mobile-panel-id="general"
>
<PanelCard title="장수 스탯" hide-header aria-label="장수 정보" data-main-target="general">
<GeneralBasicCard :general="general" :loading="loading" :nation-color="nation?.color" />
<GeneralInformationPanel
:general="generalPanel"
:summary="generalSummary"
:loading="loading"
:nation-color="nation?.color"
/>
</PanelCard>
</div>
@@ -498,7 +537,7 @@ watch(
:command-table="commandTable"
:loading="loading"
:reserved-general-turns="reservedGeneralTurns"
:general="general"
:general="generalPanel"
:current-year="lobbyInfo?.year"
:current-month="lobbyInfo?.month"
:turn-term-minutes="lobbyInfo?.turnTerm"
@@ -528,7 +567,12 @@ watch(
<NationBasicCard :nation="nation" :loading="loading" />
</PanelCard>
<PanelCard title="장수 스탯" hide-header aria-label="장수 정보" data-main-target="general">
<GeneralBasicCard :general="general" :loading="loading" :nation-color="nation?.color" />
<GeneralInformationPanel
:general="generalPanel"
:summary="generalSummary"
:loading="loading"
:nation-color="nation?.color"
/>
</PanelCard>
<MainNationMenu
class="nation-menu-middle"
@@ -4,7 +4,9 @@ import { computed, onMounted, ref } from 'vue';
import { formatReservedCommandBrief } from '../components/command/reservedCommandBrief';
import type { CommandTable } from '../components/command/types';
import LegacySortControls from '../components/ui/LegacySortControls.vue';
import DirectoryTooltip from '../components/directory/DirectoryTooltip.vue';
import { getNpcColor } from '../utils/npcColor';
import { generalInjuryPresentation } from '../utils/generalInjury';
import { trpc } from '../utils/trpc';
type Result = Awaited<ReturnType<typeof trpc.nation.getSecretGeneralList.query>>;
type ReservedCommand = Result['generals'][number]['reservedCommands'][number];
@@ -48,6 +50,18 @@ const generals = computed(() =>
const closeWindow = () => window.close();
const displayName = (general: { name: string; npcState: number }) =>
general.npcState > 0 && !/^[ⓜⓝ㉥]/u.test(general.name) ? `${general.name}` : general.name;
const injuryInfo = (injury: number) => generalInjuryPresentation(injury);
const injuryDescription = (general: Result['generals'][number]): string =>
general.injury > 0 ? `부상 ${general.injury}% · ${injuryInfo(general.injury).text}` : '';
const statInjuryDescription = (
general: Result['generals'][number],
label: string,
original: number,
effective: number
): string =>
general.injury > 0
? `부상 ${general.injury}% · ${injuryInfo(general.injury).text} · 원래 ${label} ${original} → 적용 ${effective}`
: '';
const commandBrief = (command: ReservedCommand): string =>
formatReservedCommandBrief('general', command.action, command.args, commandTable.value);
const updateSelectedSort = (value: number): void => {
@@ -229,15 +243,72 @@ onMounted(load);
:data-npc-state="general.npcState"
>
<td>
<span data-general-name :style="{ color: getNpcColor(general.npcState) }">{{
displayName(general)
}}</span
<DirectoryTooltip
:title="`부상 · ${injuryInfo(general.injury).text}`"
:description="injuryDescription(general)"
:test-id="`secret-injury-name-${general.id}`"
>
<span
data-general-name
:style="{
color:
general.injury > 0
? injuryInfo(general.injury).color
: getNpcColor(general.npcState),
}"
>{{ displayName(general) }}</span
> </DirectoryTooltip
><br />Lv {{ general.experienceLevel }}
</td>
<td>
{{ general.stats.leadership
}}<span v-if="general.leadershipBonus" class="bonus">+{{ general.leadershipBonus }}</span
>{{ general.stats.strength }}{{ general.stats.intelligence }}
<DirectoryTooltip
title="통솔 부상"
:description="
statInjuryDescription(
general,
'통솔',
general.baseStats.leadership,
general.stats.leadership
)
"
:test-id="`secret-injury-leadership-${general.id}`"
>
<span :style="{ color: injuryInfo(general.injury).color }">{{
general.stats.leadership
}}</span
><span v-if="general.leadershipBonus" class="bonus"
>+{{ general.leadershipBonus }}</span
>
</DirectoryTooltip>
<DirectoryTooltip
title="무력 부상"
:description="
statInjuryDescription(
general,
'무력',
general.baseStats.strength,
general.stats.strength
)
"
:test-id="`secret-injury-strength-${general.id}`"
><span :style="{ color: injuryInfo(general.injury).color }">{{
general.stats.strength
}}</span></DirectoryTooltip
><DirectoryTooltip
title="지력 부상"
:description="
statInjuryDescription(
general,
'지력',
general.baseStats.intelligence,
general.stats.intelligence
)
"
:test-id="`secret-injury-intelligence-${general.id}`"
><span :style="{ color: injuryInfo(general.injury).color }">{{
general.stats.intelligence
}}</span></DirectoryTooltip
>
</td>
<td>{{ general.troopName ?? '-' }}</td>
<td>{{ general.gold }}</td>
@@ -100,7 +100,37 @@ const table: CommandTable = {
{ value: 'che_명마_01_노기', label: '노기(+1)' },
],
},
recruitment: null,
recruitment: {
techLevel: 1,
leadership: 70,
fullLeadership: 70,
currentCrewTypeId: 1100,
currentCrewTypeName: '보병',
crew: 0,
gold: 1000,
groups: [
{
armType: 0,
armName: '보병',
values: [
{
id: 1200,
armType: 0,
name: '정예병',
available: false,
special: true,
attack: 10,
defence: 10,
speed: 10,
avoid: 10,
baseCost: 10,
baseRice: 10,
info: [],
},
],
},
],
},
},
};
@@ -112,6 +142,10 @@ void test('Ref getBrief를 상속하는 출병·계략·모병까지 실제 인
formatReservedCommandBrief('general', 'che_모병', { crewType: 1100, amount: 2400 }, table),
'【보병】 2400명 모병'
);
assert.equal(
formatReservedCommandBrief('general', 'che_모병', { crewType: 1200, amount: 2500 }, table),
'【정예병】 2500명 모병'
);
});
void test('개인·인사·국가 명령의 Ref brief 변형을 보존한다', () => {
@@ -131,7 +165,7 @@ void test('개인·인사·국가 명령의 Ref brief 변형을 보존한다', (
['che_증여', { destGeneralId: 8, amount: 200, isGold: true }, '【손권】에게 금 200을 증여'],
['che_징병', { crewType: 1100, amount: 3200 }, '【보병】 3200명 징병'],
['che_숙련전환', { srcArmType: 0, destArmType: 1 }, '【보병】숙련을 【궁병】숙련으로 전환'],
['che_장비매매', { itemType: 'horse', itemCode: 'None' }, '명마를 판매'],
['che_장비매매', { itemType: 'horse', itemCode: 'None' }, '명마를 판매.'],
['che_장비매매', { itemType: 'horse', itemCode: 'che_명마_01_노기' }, '【노기(+1)】를 구입'],
];
for (const [action, args, expected] of cases) {