merge: 최신 main을 1분 부하 최적화에 통합한다
This commit is contained in:
@@ -340,6 +340,10 @@ docs에서 확인해 주세요. 존재하지 않는 명령을 오래된 report
|
|||||||
- Vue component는 PascalCase, composable은 `useX`, 변수/함수는 camelCase,
|
- Vue component는 PascalCase, composable은 `useX`, 변수/함수는 camelCase,
|
||||||
type/class는 PascalCase를 사용해 주세요.
|
type/class는 PascalCase를 사용해 주세요.
|
||||||
- 한국어 domain identifier와 설명은 의미가 더 명확할 때 유지해 주세요.
|
- 한국어 domain identifier와 설명은 의미가 더 명확할 때 유지해 주세요.
|
||||||
|
- toast·dialog·aria-label·로그처럼 사용자에게 노출하는 동적 한국어 문구에 조사를
|
||||||
|
붙일 때는 `@sammo-ts/common/util/JosaUtil`의 `put()` 또는 `pick()`을 사용해 주세요.
|
||||||
|
`` `${value}을(를)` ``, `` `${value}이(가)` ``, `` `${value}(으)로` `` 같은 병기 표기를
|
||||||
|
실제 제품 문구에 남기지 말고, 받침 있는 값과 없는 값의 최종 표시 문구를 검증해 주세요.
|
||||||
- action/command/전투 코드에는 한국 독자가 side effect와 ref 근거를 이해할
|
- action/command/전투 코드에는 한국 독자가 side effect와 ref 근거를 이해할
|
||||||
수 있는 주석을 남기되 코드의 반복 설명은 피해 주세요.
|
수 있는 주석을 남기되 코드의 반복 설명은 피해 주세요.
|
||||||
- 기능 이관과 무관한 대규모 formatting/refactor를 같은 변경에 섞지 말아 주세요.
|
- 기능 이관과 무관한 대규모 formatting/refactor를 같은 변경에 섞지 말아 주세요.
|
||||||
|
|||||||
@@ -1,12 +1,43 @@
|
|||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { asRecord, type TurnDaemonCommandResult } from '@sammo-ts/common';
|
import { asRecord, type RankDataType, type TurnDaemonCommandResult } from '@sammo-ts/common';
|
||||||
import { isValidTroopNameWidth, normalizeTroopName, resolveTroopSecretPermission } from '@sammo-ts/logic';
|
import {
|
||||||
|
getBillByLevel,
|
||||||
|
isValidTroopNameWidth,
|
||||||
|
normalizeTroopName,
|
||||||
|
resolveTroopSecretPermission,
|
||||||
|
} from '@sammo-ts/logic';
|
||||||
|
|
||||||
import { accessAuthedProcedure, engineAuthedProcedure, router } from '../../trpc.js';
|
import { accessAuthedProcedure, engineAuthedProcedure, router } from '../../trpc.js';
|
||||||
|
import {
|
||||||
|
loadCrewTypeDisplayNames,
|
||||||
|
loadItemDisplayNames,
|
||||||
|
resolveDedicationLevelName,
|
||||||
|
resolveOfficerLevelName,
|
||||||
|
sanitizeInternalDisplayCode,
|
||||||
|
} from '../../services/gameDisplayNames.js';
|
||||||
|
import {
|
||||||
|
resolveGeneralTypeCall,
|
||||||
|
resolveLeadershipBonus,
|
||||||
|
resolveRefreshScoreText,
|
||||||
|
resolveRemainingMinutes,
|
||||||
|
} from '../../services/generalBasicCardProjection.js';
|
||||||
|
import { loadTraitNames } from '../nation/shared.js';
|
||||||
import { getMyGeneral } from '../shared/general.js';
|
import { getMyGeneral } from '../shared/general.js';
|
||||||
|
|
||||||
|
const TROOP_PANEL_RECORD_TYPES = [
|
||||||
|
'firenum',
|
||||||
|
'warnum',
|
||||||
|
'killnum',
|
||||||
|
'deathnum',
|
||||||
|
'killcrew',
|
||||||
|
'deathcrew',
|
||||||
|
] as const satisfies readonly RankDataType[];
|
||||||
|
|
||||||
|
const readNumber = (value: unknown, fallback = 0): number =>
|
||||||
|
typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||||
|
|
||||||
const troopNameSchema = z
|
const troopNameSchema = z
|
||||||
.string()
|
.string()
|
||||||
.refine(isValidTroopNameWidth, '부대 이름은 전각 9자 또는 반각 18자 이하여야 합니다.');
|
.refine(isValidTroopNameWidth, '부대 이름은 전각 9자 또는 반각 18자 이하여야 합니다.');
|
||||||
@@ -42,7 +73,7 @@ export const troopRouter = router({
|
|||||||
const [nation, troops, generals, cities, worldState] = await Promise.all([
|
const [nation, troops, generals, cities, worldState] = await Promise.all([
|
||||||
ctx.db.nation.findUnique({
|
ctx.db.nation.findUnique({
|
||||||
where: { id: me.nationId },
|
where: { id: me.nationId },
|
||||||
select: { id: true, name: true, meta: true },
|
select: { id: true, name: true, color: true, level: true, meta: true },
|
||||||
}),
|
}),
|
||||||
ctx.db.troop.findMany({
|
ctx.db.troop.findMany({
|
||||||
where: { nationId: me.nationId },
|
where: { nationId: me.nationId },
|
||||||
@@ -55,49 +86,140 @@ export const troopRouter = router({
|
|||||||
name: true,
|
name: true,
|
||||||
cityId: true,
|
cityId: true,
|
||||||
troopId: true,
|
troopId: true,
|
||||||
|
npcState: true,
|
||||||
picture: true,
|
picture: true,
|
||||||
imageServer: true,
|
imageServer: true,
|
||||||
turnTime: true,
|
turnTime: true,
|
||||||
|
recentWarTime: true,
|
||||||
leadership: true,
|
leadership: true,
|
||||||
strength: true,
|
strength: true,
|
||||||
intel: true,
|
intel: true,
|
||||||
|
officerLevel: true,
|
||||||
|
gold: true,
|
||||||
|
rice: true,
|
||||||
|
crew: true,
|
||||||
|
train: true,
|
||||||
|
atmos: true,
|
||||||
|
injury: true,
|
||||||
experience: true,
|
experience: true,
|
||||||
|
dedication: true,
|
||||||
|
age: true,
|
||||||
|
crewTypeId: true,
|
||||||
|
weaponCode: true,
|
||||||
|
bookCode: true,
|
||||||
|
horseCode: true,
|
||||||
|
itemCode: true,
|
||||||
|
personalCode: true,
|
||||||
|
specialCode: true,
|
||||||
|
special2Code: true,
|
||||||
meta: true,
|
meta: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
ctx.db.city.findMany({
|
ctx.db.city.findMany({
|
||||||
select: { id: true, name: true },
|
select: { id: true, name: true },
|
||||||
}),
|
}),
|
||||||
ctx.db.worldState.findFirst({ select: { config: true } }),
|
ctx.db.worldState.findFirst({ select: { tickSeconds: true, config: true, meta: true } }),
|
||||||
]);
|
]);
|
||||||
if (!nation) {
|
if (!nation) {
|
||||||
throw new TRPCError({ code: 'NOT_FOUND', message: '국가 정보를 찾을 수 없습니다.' });
|
throw new TRPCError({ code: 'NOT_FOUND', message: '국가 정보를 찾을 수 없습니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const permission = resolveTroopSecretPermission(me, nation.meta, false);
|
||||||
const troopLeaderIds = troops.map((troop) => troop.troopLeaderId);
|
const troopLeaderIds = troops.map((troop) => troop.troopLeaderId);
|
||||||
const turns =
|
const generalIds = generals.map((general) => general.id);
|
||||||
|
const [turns, rankRows, accessRows] = await Promise.all([
|
||||||
troopLeaderIds.length === 0
|
troopLeaderIds.length === 0
|
||||||
? []
|
? []
|
||||||
: await ctx.db.generalTurn.findMany({
|
: ctx.db.generalTurn.findMany({
|
||||||
where: { generalId: { in: troopLeaderIds }, turnIdx: { lt: 5 } },
|
where: { generalId: { in: troopLeaderIds }, turnIdx: { lt: 5 } },
|
||||||
select: { generalId: true, turnIdx: true, actionCode: true },
|
select: { generalId: true, turnIdx: true, actionCode: true },
|
||||||
orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }],
|
orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }],
|
||||||
});
|
}),
|
||||||
|
permission < 1 || generalIds.length === 0
|
||||||
|
? []
|
||||||
|
: ctx.db.rankData.findMany({
|
||||||
|
where: {
|
||||||
|
generalId: { in: generalIds },
|
||||||
|
type: { in: [...TROOP_PANEL_RECORD_TYPES] },
|
||||||
|
},
|
||||||
|
select: { generalId: true, type: true, value: true },
|
||||||
|
}),
|
||||||
|
permission < 1 || generalIds.length === 0
|
||||||
|
? []
|
||||||
|
: ctx.db.generalAccessLog.findMany({
|
||||||
|
where: { generalId: { in: generalIds } },
|
||||||
|
select: { generalId: true, refreshScore: true, refreshScoreTotal: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
const cityNames = new Map(cities.map((city) => [city.id, city.name]));
|
const cityNames = new Map(cities.map((city) => [city.id, city.name]));
|
||||||
const generalMap = new Map(generals.map((general) => [general.id, general]));
|
const generalMap = new Map(generals.map((general) => [general.id, general]));
|
||||||
const reservedByLeader = new Map<number, string[]>();
|
const reservedByLeader = new Map<number, string[]>();
|
||||||
|
const firstActionByLeader = new Map<number, string>();
|
||||||
|
const rankValueMap = new Map<number, Map<RankDataType, number>>();
|
||||||
|
const accessByGeneral = new Map(accessRows.map((row) => [row.generalId, row]));
|
||||||
const worldConfig = asRecord(worldState?.config);
|
const worldConfig = asRecord(worldState?.config);
|
||||||
const constValues = asRecord(worldConfig.const ?? worldConfig.consts);
|
const constValues = asRecord(worldConfig.const ?? worldConfig.consts);
|
||||||
|
const scenarioStat = asRecord(worldConfig.stat);
|
||||||
|
const chiefStatMin = readNumber(scenarioStat.chiefMin, 70);
|
||||||
|
const statGradeLevel = readNumber(constValues.statGradeLevel, 5);
|
||||||
|
const retirementYear = readNumber(constValues.retirementYear, 70);
|
||||||
|
const maxDedicationLevel = Math.max(0, Math.trunc(readNumber(constValues.maxDedLevel, 30)));
|
||||||
const statUpgradeLimit =
|
const statUpgradeLimit =
|
||||||
typeof constValues.upgradeLimit === 'number' && Number.isFinite(constValues.upgradeLimit)
|
typeof constValues.upgradeLimit === 'number' && Number.isFinite(constValues.upgradeLimit)
|
||||||
? constValues.upgradeLimit
|
? constValues.upgradeLimit
|
||||||
: 30;
|
: 30;
|
||||||
for (const turn of turns) {
|
for (const turn of turns) {
|
||||||
|
if (!firstActionByLeader.has(turn.generalId)) {
|
||||||
|
firstActionByLeader.set(turn.generalId, turn.actionCode);
|
||||||
|
}
|
||||||
const list = reservedByLeader.get(turn.generalId) ?? [];
|
const list = reservedByLeader.get(turn.generalId) ?? [];
|
||||||
// Ref 부대 편성은 앞쪽 슬롯이 집합인지 여부만 공개하고 다른 명령은 가립니다.
|
// Ref 부대 편성은 앞쪽 슬롯이 집합인지 여부만 공개하고 다른 명령은 가립니다.
|
||||||
list.push(turn.actionCode === 'che_집합' ? '집합' : '-');
|
list.push(turn.actionCode === 'che_집합' ? '집합' : '-');
|
||||||
reservedByLeader.set(turn.generalId, list);
|
reservedByLeader.set(turn.generalId, list);
|
||||||
}
|
}
|
||||||
|
for (const row of rankRows) {
|
||||||
|
const values = rankValueMap.get(row.generalId) ?? new Map<RankDataType, number>();
|
||||||
|
values.set(row.type as (typeof TROOP_PANEL_RECORD_TYPES)[number], row.value);
|
||||||
|
rankValueMap.set(row.generalId, values);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [personalityNames, domesticNames, warNames, crewTypeNames, itemNames] =
|
||||||
|
permission < 1
|
||||||
|
? [new Map(), new Map(), new Map(), new Map(), new Map()]
|
||||||
|
: await Promise.all([
|
||||||
|
loadTraitNames(
|
||||||
|
generals.map((general) => general.personalCode),
|
||||||
|
'personality'
|
||||||
|
),
|
||||||
|
loadTraitNames(
|
||||||
|
generals.map((general) => general.specialCode),
|
||||||
|
'domestic'
|
||||||
|
),
|
||||||
|
loadTraitNames(
|
||||||
|
generals.map((general) => general.special2Code),
|
||||||
|
'war'
|
||||||
|
),
|
||||||
|
loadCrewTypeDisplayNames(worldState, ctx.profile.id),
|
||||||
|
loadItemDisplayNames(
|
||||||
|
generals.flatMap((general) => [
|
||||||
|
general.weaponCode,
|
||||||
|
general.bookCode,
|
||||||
|
general.horseCode,
|
||||||
|
general.itemCode,
|
||||||
|
])
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
const traitName = (code: string, names: Map<string, { name: string }>): string =>
|
||||||
|
names.get(code)?.name ?? sanitizeInternalDisplayCode(code);
|
||||||
|
const itemName = (code: string): string => itemNames.get(code) ?? sanitizeInternalDisplayCode(code);
|
||||||
|
const worldMeta = asRecord(worldState?.meta);
|
||||||
|
const rawLastExecuted = worldMeta.lastTurnTime ?? worldMeta.turntime;
|
||||||
|
const lastExecuted =
|
||||||
|
rawLastExecuted instanceof Date
|
||||||
|
? rawLastExecuted
|
||||||
|
: typeof rawLastExecuted === 'string'
|
||||||
|
? new Date(rawLastExecuted)
|
||||||
|
: null;
|
||||||
|
|
||||||
const mappedTroops = troops
|
const mappedTroops = troops
|
||||||
.map((troop) => {
|
.map((troop) => {
|
||||||
@@ -123,19 +245,52 @@ export const troopRouter = router({
|
|||||||
.map((general) => {
|
.map((general) => {
|
||||||
const meta = asRecord(general.meta);
|
const meta = asRecord(general.meta);
|
||||||
const metaNumber = (key: string): number => {
|
const metaNumber = (key: string): number => {
|
||||||
const value = meta[key];
|
return readNumber(meta[key]);
|
||||||
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
||||||
};
|
};
|
||||||
|
const stats = {
|
||||||
|
leadership: general.leadership,
|
||||||
|
strength: general.strength,
|
||||||
|
intelligence: general.intel,
|
||||||
|
};
|
||||||
|
const storedDedicationLevel = metaNumber('dedlevel');
|
||||||
|
const dedicationLevel =
|
||||||
|
storedDedicationLevel > 0
|
||||||
|
? storedDedicationLevel
|
||||||
|
: Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(Math.ceil(Math.sqrt(general.dedication) / 10), maxDedicationLevel)
|
||||||
|
);
|
||||||
|
const rankValue = (
|
||||||
|
type: (typeof TROOP_PANEL_RECORD_TYPES)[number],
|
||||||
|
fallbackKeys: string[] = []
|
||||||
|
): number => {
|
||||||
|
const stored = rankValueMap.get(general.id)?.get(type);
|
||||||
|
if (stored !== undefined) return stored;
|
||||||
|
for (const key of fallbackKeys) {
|
||||||
|
const fallback = meta[key];
|
||||||
|
if (typeof fallback === 'number' && Number.isFinite(fallback)) return fallback;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
const officerCityId = readNumber(
|
||||||
|
meta.officerCity ?? meta.officer_city ?? meta.officerCityId
|
||||||
|
);
|
||||||
|
const access = accessByGeneral.get(general.id);
|
||||||
|
const refreshScore = access?.refreshScore ?? 0;
|
||||||
|
const refreshScoreTotal = access?.refreshScoreTotal ?? 0;
|
||||||
|
const firstAction = firstActionByLeader.get(troop.troopLeaderId);
|
||||||
|
const troopStatus: 'inactive' | 'present' | 'away' =
|
||||||
|
firstAction !== undefined && firstAction !== 'che_집합'
|
||||||
|
? 'inactive'
|
||||||
|
: leader?.cityId === general.cityId
|
||||||
|
? 'present'
|
||||||
|
: 'away';
|
||||||
return {
|
return {
|
||||||
id: general.id,
|
id: general.id,
|
||||||
name: general.name,
|
name: general.name,
|
||||||
cityId: general.cityId,
|
cityId: general.cityId,
|
||||||
cityName: cityNames.get(general.cityId) ?? '알 수 없음',
|
cityName: cityNames.get(general.cityId) ?? '알 수 없음',
|
||||||
stats: {
|
stats,
|
||||||
leadership: general.leadership,
|
|
||||||
strength: general.strength,
|
|
||||||
intelligence: general.intel,
|
|
||||||
},
|
|
||||||
experience: general.experience,
|
experience: general.experience,
|
||||||
progression: {
|
progression: {
|
||||||
experienceLevel: metaNumber('explevel'),
|
experienceLevel: metaNumber('explevel'),
|
||||||
@@ -147,6 +302,114 @@ export const troopRouter = router({
|
|||||||
statUpgradeLimit,
|
statUpgradeLimit,
|
||||||
dex: [1, 2, 3, 4, 5].map((index) => metaNumber(`dex${index}`)),
|
dex: [1, 2, 3, 4, 5].map((index) => metaNumber(`dex${index}`)),
|
||||||
},
|
},
|
||||||
|
panel:
|
||||||
|
permission < 1
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
general: {
|
||||||
|
id: general.id,
|
||||||
|
name: general.name,
|
||||||
|
picture: general.picture,
|
||||||
|
imageServer: general.imageServer,
|
||||||
|
npcState: general.npcState,
|
||||||
|
officerLevel: general.officerLevel,
|
||||||
|
officerLevelText: resolveOfficerLevelName(
|
||||||
|
general.officerLevel,
|
||||||
|
nation.level
|
||||||
|
),
|
||||||
|
officerCityName:
|
||||||
|
general.officerLevel >= 2 && general.officerLevel <= 4
|
||||||
|
? (cityNames.get(officerCityId) ?? null)
|
||||||
|
: null,
|
||||||
|
generalType: resolveGeneralTypeCall(
|
||||||
|
stats,
|
||||||
|
chiefStatMin,
|
||||||
|
statGradeLevel
|
||||||
|
),
|
||||||
|
leadershipBonus: resolveLeadershipBonus(
|
||||||
|
general.officerLevel,
|
||||||
|
nation.level
|
||||||
|
),
|
||||||
|
stats,
|
||||||
|
gold: general.gold,
|
||||||
|
rice: general.rice,
|
||||||
|
crew: general.crew,
|
||||||
|
train: general.train,
|
||||||
|
atmos: general.atmos,
|
||||||
|
injury: general.injury,
|
||||||
|
experience: general.experience,
|
||||||
|
dedication: general.dedication,
|
||||||
|
age: general.age,
|
||||||
|
retirementYear,
|
||||||
|
turnTime: general.turnTime.toISOString(),
|
||||||
|
defenceTrain: readNumber(meta.defence_train, 80),
|
||||||
|
killTurn: readNumber(meta.killturn ?? meta.killTurn),
|
||||||
|
remainingMinutes: resolveRemainingMinutes(
|
||||||
|
general.turnTime,
|
||||||
|
lastExecuted,
|
||||||
|
worldState?.tickSeconds ?? 0
|
||||||
|
),
|
||||||
|
troopId: general.troopId,
|
||||||
|
troop: {
|
||||||
|
name: troop.name,
|
||||||
|
status: troopStatus,
|
||||||
|
leaderCityName:
|
||||||
|
leader && leader.cityId !== general.cityId
|
||||||
|
? (cityNames.get(leader.cityId) ?? null)
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
refreshScore: {
|
||||||
|
current: refreshScore,
|
||||||
|
total: refreshScoreTotal,
|
||||||
|
text: resolveRefreshScoreText(refreshScoreTotal),
|
||||||
|
},
|
||||||
|
crewTypeId: general.crewTypeId,
|
||||||
|
crewTypeName: crewTypeNames.get(general.crewTypeId) ?? '-',
|
||||||
|
traits: {
|
||||||
|
personal: traitName(general.personalCode, personalityNames),
|
||||||
|
specialDomestic: traitName(general.specialCode, domesticNames),
|
||||||
|
specialWar: traitName(general.special2Code, warNames),
|
||||||
|
},
|
||||||
|
progression: {
|
||||||
|
experienceLevel: metaNumber('explevel'),
|
||||||
|
dedicationLevel,
|
||||||
|
dedicationText: resolveDedicationLevelName(
|
||||||
|
dedicationLevel,
|
||||||
|
maxDedicationLevel
|
||||||
|
),
|
||||||
|
statExperience: {
|
||||||
|
leadership: metaNumber('leadership_exp'),
|
||||||
|
strength: metaNumber('strength_exp'),
|
||||||
|
intelligence: metaNumber('intel_exp'),
|
||||||
|
},
|
||||||
|
statUpgradeLimit,
|
||||||
|
dex: [1, 2, 3, 4, 5].map((index) => metaNumber(`dex${index}`)),
|
||||||
|
},
|
||||||
|
itemNames: {
|
||||||
|
horse: itemName(general.horseCode),
|
||||||
|
weapon: itemName(general.weaponCode),
|
||||||
|
book: itemName(general.bookCode),
|
||||||
|
item: itemName(general.itemCode),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
summary: {
|
||||||
|
available: true,
|
||||||
|
experience: general.experience,
|
||||||
|
dedicationText: resolveDedicationLevelName(
|
||||||
|
dedicationLevel,
|
||||||
|
maxDedicationLevel
|
||||||
|
),
|
||||||
|
bill: getBillByLevel(dedicationLevel),
|
||||||
|
warnum: rankValue('warnum', ['rank_warnum', 'warnum']),
|
||||||
|
wins: rankValue('killnum', ['rank_killnum', 'killnum']),
|
||||||
|
losses: rankValue('deathnum', ['rank_deathnum', 'deathnum']),
|
||||||
|
strategies: rankValue('firenum', ['rank_firenum', 'firenum']),
|
||||||
|
serviceYears: metaNumber('belong'),
|
||||||
|
killCrew: rankValue('killcrew', ['rank_killcrew', 'killcrew']),
|
||||||
|
deathCrew: rankValue('deathcrew', ['rank_deathcrew', 'deathcrew']),
|
||||||
|
recentWar: general.recentWarTime?.toISOString() ?? null,
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -157,9 +420,9 @@ export const troopRouter = router({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
nation: { id: nation.id, name: nation.name },
|
nation: { id: nation.id, name: nation.name, color: nation.color },
|
||||||
me: { id: me.id, troopId: me.troopId },
|
me: { id: me.id, troopId: me.troopId },
|
||||||
permission: resolveTroopSecretPermission(me, nation.meta, false),
|
permission,
|
||||||
troops: mappedTroops,
|
troops: mappedTroops,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -190,6 +190,38 @@ describe('buildTurnCommandTable', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps every default general and chief argument command inside the shared frontend field contract', async () => {
|
||||||
|
const table = await buildTurnCommandTable({
|
||||||
|
worldState: buildWorldState(),
|
||||||
|
general: buildGeneral(),
|
||||||
|
city: buildCity(),
|
||||||
|
nation: buildNation(),
|
||||||
|
nationGenerals: null,
|
||||||
|
});
|
||||||
|
const supportedKinds = new Set(['text', 'number', 'boolean', 'select', 'numberTuple', 'hidden']);
|
||||||
|
|
||||||
|
for (const [scope, groups] of [
|
||||||
|
['general', table.general],
|
||||||
|
['nation', table.nation],
|
||||||
|
] as const) {
|
||||||
|
for (const command of groups.flatMap((group) => group.values)) {
|
||||||
|
if (!command.reqArg) continue;
|
||||||
|
expect(command.inputFields.length, `${scope}:${command.key}`).toBeGreaterThan(0);
|
||||||
|
expect(new Set(command.inputFields.map((field) => field.key)).size, `${scope}:${command.key}`).toBe(
|
||||||
|
command.inputFields.length
|
||||||
|
);
|
||||||
|
for (const field of command.inputFields) {
|
||||||
|
expect(supportedKinds.has(field.kind), `${scope}:${command.key}:${field.key}`).toBe(true);
|
||||||
|
if (field.kind === 'select') {
|
||||||
|
expect(Boolean(field.options?.length || field.optionSource), `${scope}:${command.key}:${field.key}`).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('projects the Ref availability boundaries for force move, retirement, and resignation', async () => {
|
it('projects the Ref availability boundaries for force move, retirement, and resignation', async () => {
|
||||||
const buildTable = (general: GeneralRow, nation: NationRow | null = buildNation()) =>
|
const buildTable = (general: GeneralRow, nation: NationRow | null = buildNation()) =>
|
||||||
buildTurnCommandTable({
|
buildTurnCommandTable({
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { RankDataType } from '@sammo-ts/common';
|
||||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||||
import type { RedisConnector } from '@sammo-ts/infra';
|
import type { RedisConnector } from '@sammo-ts/infra';
|
||||||
|
|
||||||
@@ -78,11 +79,15 @@ const buildContext = (options: {
|
|||||||
requestId?: string;
|
requestId?: string;
|
||||||
transaction?: ReturnType<typeof vi.fn>;
|
transaction?: ReturnType<typeof vi.fn>;
|
||||||
turns?: Array<{ generalId: number; turnIdx: number; actionCode: string }>;
|
turns?: Array<{ generalId: number; turnIdx: number; actionCode: string }>;
|
||||||
|
rankRows?: Array<{ generalId: number; type: RankDataType; value: number }>;
|
||||||
|
accessRows?: Array<{ generalId: number; refreshScore: number; refreshScoreTotal: number }>;
|
||||||
result: Awaited<ReturnType<TurnDaemonTransport['requestCommand']>>;
|
result: Awaited<ReturnType<TurnDaemonTransport['requestCommand']>>;
|
||||||
}) => {
|
}) => {
|
||||||
const me = options.me ?? buildGeneral();
|
const me = options.me ?? buildGeneral();
|
||||||
const requestCommand = vi.fn(async () => options.result);
|
const requestCommand = vi.fn(async () => options.result);
|
||||||
const generalTurnFindMany = vi.fn(async () => options.turns ?? []);
|
const generalTurnFindMany = vi.fn(async () => options.turns ?? []);
|
||||||
|
const rankDataFindMany = vi.fn(async () => options.rankRows ?? []);
|
||||||
|
const generalAccessLogFindMany = vi.fn(async () => options.accessRows ?? []);
|
||||||
const db = {
|
const db = {
|
||||||
...(options.transaction ? { $transaction: options.transaction } : {}),
|
...(options.transaction ? { $transaction: options.transaction } : {}),
|
||||||
general: {
|
general: {
|
||||||
@@ -99,7 +104,9 @@ const buildContext = (options: {
|
|||||||
},
|
},
|
||||||
nation: {
|
nation: {
|
||||||
findUnique: vi.fn(async ({ where }: { where: { id: number } }) =>
|
findUnique: vi.fn(async ({ where }: { where: { id: number } }) =>
|
||||||
where.id === me.nationId ? { id: me.nationId, name: '테스트국', meta: options.nationMeta ?? {} } : null
|
where.id === me.nationId
|
||||||
|
? { id: me.nationId, name: '테스트국', color: '#123456', level: 4, meta: options.nationMeta ?? {} }
|
||||||
|
: null
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
troop: {
|
troop: {
|
||||||
@@ -111,8 +118,19 @@ const buildContext = (options: {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
city: { findMany: vi.fn(async () => [{ id: 1, name: '북평' }]) },
|
city: { findMany: vi.fn(async () => [{ id: 1, name: '북평' }]) },
|
||||||
worldState: { findFirst: vi.fn(async () => ({ config: { const: { upgradeLimit: 20 } } })) },
|
worldState: {
|
||||||
|
findFirst: vi.fn(async () => ({
|
||||||
|
tickSeconds: 300,
|
||||||
|
config: {
|
||||||
|
stat: { chiefMin: 70 },
|
||||||
|
const: { upgradeLimit: 20, statGradeLevel: 5, retirementYear: 70, maxDedLevel: 30 },
|
||||||
|
},
|
||||||
|
meta: { lastTurnTime: '2026-01-01T00:00:00.000Z' },
|
||||||
|
})),
|
||||||
|
},
|
||||||
generalTurn: { findMany: generalTurnFindMany },
|
generalTurn: { findMany: generalTurnFindMany },
|
||||||
|
rankData: { findMany: rankDataFindMany },
|
||||||
|
generalAccessLog: { findMany: generalAccessLogFindMany },
|
||||||
};
|
};
|
||||||
const accessTokenStore = new RedisAccessTokenStore(
|
const accessTokenStore = new RedisAccessTokenStore(
|
||||||
{
|
{
|
||||||
@@ -136,15 +154,24 @@ const buildContext = (options: {
|
|||||||
flushStore: new InMemoryFlushStore(),
|
flushStore: new InMemoryFlushStore(),
|
||||||
gameTokenSecret: 'test-secret',
|
gameTokenSecret: 'test-secret',
|
||||||
};
|
};
|
||||||
return { context, requestCommand, generalTurnFindMany };
|
return { context, requestCommand, generalTurnFindMany, rankDataFindMany, generalAccessLogFindMany };
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('troop router permissions and mutations', () => {
|
describe('troop router permissions and mutations', () => {
|
||||||
it('returns the Ref general progress inputs for same-nation troop popups', async () => {
|
it('returns the shared general information panel source for authorized same-nation troop popups', async () => {
|
||||||
const me = buildGeneral({
|
const me = buildGeneral({
|
||||||
troopId: 1,
|
troopId: 1,
|
||||||
|
officerLevel: 2,
|
||||||
|
dedication: 900,
|
||||||
|
crewTypeId: 1100,
|
||||||
|
recentWarTime: new Date('2026-01-01T00:12:34.000Z'),
|
||||||
meta: {
|
meta: {
|
||||||
explevel: 4,
|
explevel: 4,
|
||||||
|
dedlevel: 3,
|
||||||
|
officerCity: 1,
|
||||||
|
defence_train: 90,
|
||||||
|
killturn: 7,
|
||||||
|
belong: 11,
|
||||||
leadership_exp: 7,
|
leadership_exp: 7,
|
||||||
strength_exp: 8,
|
strength_exp: 8,
|
||||||
intel_exp: 9,
|
intel_exp: 9,
|
||||||
@@ -155,9 +182,23 @@ describe('troop router permissions and mutations', () => {
|
|||||||
dex5: 12_650,
|
dex5: 12_650,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const fixture = buildContext({ me, result: null });
|
const fixture = buildContext({
|
||||||
|
me,
|
||||||
|
turns: [{ generalId: 1, turnIdx: 0, actionCode: 'che_집합' }],
|
||||||
|
rankRows: [
|
||||||
|
{ generalId: 1, type: 'warnum', value: 17 },
|
||||||
|
{ generalId: 1, type: 'killnum', value: 11 },
|
||||||
|
{ generalId: 1, type: 'deathnum', value: 6 },
|
||||||
|
{ generalId: 1, type: 'firenum', value: 5 },
|
||||||
|
{ generalId: 1, type: 'killcrew', value: 1234 },
|
||||||
|
{ generalId: 1, type: 'deathcrew', value: 432 },
|
||||||
|
],
|
||||||
|
accessRows: [{ generalId: 1, refreshScore: 13, refreshScoreTotal: 800 }],
|
||||||
|
result: null,
|
||||||
|
});
|
||||||
|
|
||||||
await expect(appRouter.createCaller(fixture.context).troop.getList()).resolves.toMatchObject({
|
await expect(appRouter.createCaller(fixture.context).troop.getList()).resolves.toMatchObject({
|
||||||
|
nation: { id: 1, name: '테스트국', color: '#123456' },
|
||||||
troops: [
|
troops: [
|
||||||
{
|
{
|
||||||
members: [
|
members: [
|
||||||
@@ -170,6 +211,35 @@ describe('troop router permissions and mutations', () => {
|
|||||||
statUpgradeLimit: 20,
|
statUpgradeLimit: 20,
|
||||||
dex: [350, 1_375, 3_500, 7_125, 12_650],
|
dex: [350, 1_375, 3_500, 7_125, 12_650],
|
||||||
},
|
},
|
||||||
|
panel: {
|
||||||
|
general: {
|
||||||
|
name: '부대장',
|
||||||
|
officerLevelText: '종사',
|
||||||
|
officerCityName: '북평',
|
||||||
|
generalType: '평범',
|
||||||
|
defenceTrain: 90,
|
||||||
|
killTurn: 7,
|
||||||
|
troop: { name: '백마대', status: 'present' },
|
||||||
|
refreshScore: { current: 13, total: 800, text: '열심' },
|
||||||
|
progression: {
|
||||||
|
dedicationLevel: 3,
|
||||||
|
dedicationText: '28품관',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
summary: {
|
||||||
|
experience: 0,
|
||||||
|
dedicationText: '28품관',
|
||||||
|
bill: 1_000,
|
||||||
|
warnum: 17,
|
||||||
|
wins: 11,
|
||||||
|
losses: 6,
|
||||||
|
strategies: 5,
|
||||||
|
serviceYears: 11,
|
||||||
|
killCrew: 1_234,
|
||||||
|
deathCrew: 432,
|
||||||
|
recentWar: '2026-01-01T00:12:34.000Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -177,6 +247,17 @@ describe('troop router permissions and mutations', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps detailed panel data out of the permission-zero troop response', async () => {
|
||||||
|
const fixture = buildContext({ me: buildGeneral({ troopId: 1, officerLevel: 1 }), result: null });
|
||||||
|
|
||||||
|
const response = await appRouter.createCaller(fixture.context).troop.getList();
|
||||||
|
|
||||||
|
expect(response.permission).toBe(0);
|
||||||
|
expect(response.troops[0]?.members[0]?.panel).toBeNull();
|
||||||
|
expect(fixture.rankDataFindMany).not.toHaveBeenCalled();
|
||||||
|
expect(fixture.generalAccessLogFindMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('returns only the first five Ref-redacted troop command labels without exposing action codes', async () => {
|
it('returns only the first five Ref-redacted troop command labels without exposing action codes', async () => {
|
||||||
const fixture = buildContext({
|
const fixture = buildContext({
|
||||||
me: buildGeneral({ troopId: 1 }),
|
me: buildGeneral({ troopId: 1 }),
|
||||||
|
|||||||
@@ -1781,6 +1781,28 @@ test('내 정보에서 사람 장수의 등록 전콘을 골라 변경한다', a
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('내 정보 아이템 파기 확인은 이름의 받침에 맞는 조사를 쓴다', async ({ page }) => {
|
||||||
|
const state: FixtureState = {
|
||||||
|
permission: 'member',
|
||||||
|
myset: 1,
|
||||||
|
richMyInfo: true,
|
||||||
|
settingMutations: [],
|
||||||
|
accessPages: [],
|
||||||
|
};
|
||||||
|
const prompts: string[] = [];
|
||||||
|
page.on('dialog', async (dialog) => {
|
||||||
|
prompts.push(dialog.message());
|
||||||
|
await dialog.dismiss();
|
||||||
|
});
|
||||||
|
await install(page, state);
|
||||||
|
await page.goto('my-page');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '명마', exact: true }).click();
|
||||||
|
await page.getByRole('button', { name: '효경전', exact: true }).click();
|
||||||
|
|
||||||
|
expect(prompts).toEqual(['명마를 버리시겠습니까?', '효경전을 버리시겠습니까?']);
|
||||||
|
});
|
||||||
|
|
||||||
test('장수 생성에서 등록 전콘을 골라 생성 요청에 전달한다', async ({ page }) => {
|
test('장수 생성에서 등록 전콘을 골라 생성 요청에 전달한다', async ({ page }) => {
|
||||||
const firstIconId = '3f804277-584f-4f44-b39c-9ecf40d1ed31';
|
const firstIconId = '3f804277-584f-4f44-b39c-9ecf40d1ed31';
|
||||||
const secondIconId = 'f6af46a2-809a-481d-b66d-0f7bbb706780';
|
const secondIconId = 'f6af46a2-809a-481d-b66d-0f7bbb706780';
|
||||||
|
|||||||
@@ -3909,6 +3909,11 @@ for (const viewport of [
|
|||||||
path: '/inputOptions/context/actorGold',
|
path: '/inputOptions/context/actorGold',
|
||||||
value: 10_000 + refreshIndex,
|
value: 10_000 + refreshIndex,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
op: 'replace',
|
||||||
|
path: '/inputOptions/items/weapon/1/label',
|
||||||
|
value: `청룡언월도 갱신 ${refreshIndex}`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
await emitReadModelInvalidation(
|
await emitReadModelInvalidation(
|
||||||
page,
|
page,
|
||||||
@@ -3952,16 +3957,232 @@ for (const viewport of [
|
|||||||
await picker.getByRole('button', { name: '명령 다시 선택', exact: true }).click();
|
await picker.getByRole('button', { name: '명령 다시 선택', exact: true }).click();
|
||||||
await picker.getByRole('button', { name: '장비 매매', exact: true }).click();
|
await picker.getByRole('button', { name: '장비 매매', exact: true }).click();
|
||||||
await picker.getByLabel('장비 종류', { exact: true }).selectOption('weapon');
|
await picker.getByLabel('장비 종류', { exact: true }).selectOption('weapon');
|
||||||
await picker.getByLabel('장비', { exact: true }).selectOption('청룡언월도');
|
const equipment = picker.getByLabel('장비', { exact: true });
|
||||||
|
await equipment.selectOption('청룡언월도');
|
||||||
|
const optionLabelBeforeRefresh = await equipment
|
||||||
|
.locator('option[value="청룡언월도"]')
|
||||||
|
.textContent();
|
||||||
|
await equipment.evaluate((element) => {
|
||||||
|
const select = element as HTMLSelectElement;
|
||||||
|
const valueDescriptor = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value');
|
||||||
|
if (!valueDescriptor?.get || !valueDescriptor.set) throw new Error('native select value accessors missing');
|
||||||
|
const probe = {
|
||||||
|
node: select,
|
||||||
|
valueWrites: 0,
|
||||||
|
mutations: 0,
|
||||||
|
observer: null as MutationObserver | null,
|
||||||
|
};
|
||||||
|
Object.defineProperty(select, 'value', {
|
||||||
|
configurable: true,
|
||||||
|
get: () => valueDescriptor.get?.call(select),
|
||||||
|
set: (value: string) => {
|
||||||
|
probe.valueWrites += 1;
|
||||||
|
valueDescriptor.set?.call(select, value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
probe.observer = new MutationObserver((records) => {
|
||||||
|
probe.mutations += records.length;
|
||||||
|
});
|
||||||
|
probe.observer.observe(select, {
|
||||||
|
attributes: true,
|
||||||
|
characterData: true,
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
});
|
||||||
|
select.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerType: 'touch' }));
|
||||||
|
select.focus();
|
||||||
|
Object.defineProperty(window, '__nativeCommandSelectProbe', {
|
||||||
|
configurable: true,
|
||||||
|
value: probe,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const focusedGeometryBefore = await equipment.evaluate((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
return {
|
||||||
|
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
||||||
|
color: style.color,
|
||||||
|
backgroundColor: style.backgroundColor,
|
||||||
|
fontFamily: style.fontFamily,
|
||||||
|
fontSize: style.fontSize,
|
||||||
|
lineHeight: style.lineHeight,
|
||||||
|
outline: style.outline,
|
||||||
|
};
|
||||||
|
});
|
||||||
await refreshActivityAndCommands();
|
await refreshActivityAndCommands();
|
||||||
await expect(picker.getByLabel('장비 종류', { exact: true })).toHaveValue('weapon');
|
await expect(picker.getByLabel('장비 종류', { exact: true })).toHaveValue('weapon');
|
||||||
await expect(picker.getByLabel('장비', { exact: true })).toHaveValue('청룡언월도');
|
await expect(equipment).toHaveValue('청룡언월도');
|
||||||
|
expect(await equipment.locator('option[value="청룡언월도"]').textContent()).toBe(optionLabelBeforeRefresh);
|
||||||
|
expect(
|
||||||
|
await equipment.evaluate((element) => {
|
||||||
|
const probe = (
|
||||||
|
window as unknown as {
|
||||||
|
__nativeCommandSelectProbe: {
|
||||||
|
node: HTMLSelectElement;
|
||||||
|
valueWrites: number;
|
||||||
|
mutations: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).__nativeCommandSelectProbe;
|
||||||
|
return {
|
||||||
|
sameNode: probe.node === element,
|
||||||
|
focused: document.activeElement === element,
|
||||||
|
valueWrites: probe.valueWrites,
|
||||||
|
mutations: probe.mutations,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
).toEqual({ sameNode: true, focused: true, valueWrites: 0, mutations: 0 });
|
||||||
|
expect(
|
||||||
|
await equipment.evaluate((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
return {
|
||||||
|
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
||||||
|
color: style.color,
|
||||||
|
backgroundColor: style.backgroundColor,
|
||||||
|
fontFamily: style.fontFamily,
|
||||||
|
fontSize: style.fontSize,
|
||||||
|
lineHeight: style.lineHeight,
|
||||||
|
outline: style.outline,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
).toEqual(focusedGeometryBefore);
|
||||||
|
await picker.screenshot({ path: test.info().outputPath(`native-select-refresh-${viewport.name}.png`) });
|
||||||
|
|
||||||
|
await picker.getByRole('button', { name: '명령 다시 선택', exact: true }).click();
|
||||||
|
await picker.getByRole('button', { name: '장비 매매', exact: true }).click();
|
||||||
|
await picker.getByLabel('장비 종류', { exact: true }).selectOption('weapon');
|
||||||
|
await expect(picker.getByLabel('장비', { exact: true }).locator('option[value="청룡언월도"]')).toHaveText(
|
||||||
|
`청룡언월도 갱신 ${refreshIndex}`
|
||||||
|
);
|
||||||
await expect
|
await expect
|
||||||
.poll(() => page.evaluate(() => document.documentElement.scrollWidth))
|
.poll(() => page.evaluate(() => document.documentElement.scrollWidth))
|
||||||
.toBeLessThanOrEqual(viewport.width);
|
.toBeLessThanOrEqual(viewport.width);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('keeps an Android Chromium native command select untouched while a turn signal refreshes options', async ({
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: 390, height: 844 },
|
||||||
|
screen: { width: 390, height: 844 },
|
||||||
|
deviceScaleFactor: 2,
|
||||||
|
hasTouch: true,
|
||||||
|
isMobile: true,
|
||||||
|
userAgent:
|
||||||
|
'Mozilla/5.0 (Linux; Android 15; Mobile) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Mobile Safari/537.36',
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const mobilePage = await context.newPage();
|
||||||
|
const state: NavigationFixture = {
|
||||||
|
officerLevel: 5,
|
||||||
|
permission: 2,
|
||||||
|
nationLevel: 3,
|
||||||
|
stage: 0,
|
||||||
|
npcMode: 1,
|
||||||
|
generalMeCalls: 0,
|
||||||
|
operations: [],
|
||||||
|
draftCommandTable: true,
|
||||||
|
reservedTurns: Array.from({ length: 30 }, (_, index) => ({ index, action: '휴식', args: {} })),
|
||||||
|
};
|
||||||
|
await installRealtimeHarness(mobilePage);
|
||||||
|
await installFixture(mobilePage, state);
|
||||||
|
await waitForMain(mobilePage);
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
mobilePage.evaluate(
|
||||||
|
() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
|
await mobilePage.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||||||
|
const picker = mobilePage.getByTestId('command-picker');
|
||||||
|
await picker.getByRole('button', { name: '국가', exact: true }).click();
|
||||||
|
await picker.getByRole('button', { name: '장비 매매', exact: true }).click();
|
||||||
|
await picker.getByLabel('장비 종류', { exact: true }).selectOption('weapon');
|
||||||
|
const equipment = picker.getByLabel('장비', { exact: true });
|
||||||
|
await equipment.selectOption('청룡언월도');
|
||||||
|
await equipment.evaluate((element) => {
|
||||||
|
const select = element as HTMLSelectElement;
|
||||||
|
const valueDescriptor = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value');
|
||||||
|
if (!valueDescriptor?.get || !valueDescriptor.set) throw new Error('native select value accessors missing');
|
||||||
|
const probe = { node: select, valueWrites: 0, mutations: 0 };
|
||||||
|
Object.defineProperty(select, 'value', {
|
||||||
|
configurable: true,
|
||||||
|
get: () => valueDescriptor.get?.call(select),
|
||||||
|
set: (value: string) => {
|
||||||
|
probe.valueWrites += 1;
|
||||||
|
valueDescriptor.set?.call(select, value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
new MutationObserver((records) => {
|
||||||
|
probe.mutations += records.length;
|
||||||
|
}).observe(select, { attributes: true, characterData: true, childList: true, subtree: true });
|
||||||
|
select.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerType: 'touch' }));
|
||||||
|
select.focus();
|
||||||
|
Object.defineProperty(window, '__nativeCommandSelectProbe', { configurable: true, value: probe });
|
||||||
|
});
|
||||||
|
|
||||||
|
const callsBefore = state.generalMeCalls;
|
||||||
|
state.commandTableRevision = 'Z'.repeat(22);
|
||||||
|
state.commandTableOperations = [
|
||||||
|
{
|
||||||
|
op: 'replace',
|
||||||
|
path: '/inputOptions/items/weapon/1/label',
|
||||||
|
value: '청룡언월도 최신 조건',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
await emitReadModelInvalidation(
|
||||||
|
mobilePage,
|
||||||
|
readModelInvalidation({ commands: true, records: true, frontStatus: true })
|
||||||
|
);
|
||||||
|
await expect.poll(() => state.generalMeCalls).toBe(callsBefore + 1);
|
||||||
|
expect(
|
||||||
|
await equipment.evaluate((element) => {
|
||||||
|
const probe = (
|
||||||
|
window as unknown as {
|
||||||
|
__nativeCommandSelectProbe: {
|
||||||
|
node: HTMLSelectElement;
|
||||||
|
valueWrites: number;
|
||||||
|
mutations: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).__nativeCommandSelectProbe;
|
||||||
|
return {
|
||||||
|
sameNode: probe.node === element,
|
||||||
|
focused: document.activeElement === element,
|
||||||
|
value: (element as HTMLSelectElement).value,
|
||||||
|
option: (element as HTMLSelectElement).selectedOptions[0]?.textContent,
|
||||||
|
valueWrites: probe.valueWrites,
|
||||||
|
mutations: probe.mutations,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
sameNode: true,
|
||||||
|
focused: true,
|
||||||
|
value: '청룡언월도',
|
||||||
|
option: '청룡언월도',
|
||||||
|
valueWrites: 0,
|
||||||
|
mutations: 0,
|
||||||
|
});
|
||||||
|
await picker.screenshot({ path: test.info().outputPath('native-select-refresh-android-chromium.png') });
|
||||||
|
expect(
|
||||||
|
await mobilePage.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
|
||||||
|
).toBeLessThanOrEqual(1);
|
||||||
|
|
||||||
|
await picker.getByRole('button', { name: '명령 다시 선택', exact: true }).click();
|
||||||
|
await picker.getByRole('button', { name: '장비 매매', exact: true }).click();
|
||||||
|
await picker.getByLabel('장비 종류', { exact: true }).selectOption('weapon');
|
||||||
|
await expect(picker.getByLabel('장비', { exact: true }).locator('option[value="청룡언월도"]')).toHaveText(
|
||||||
|
'청룡언월도 최신 조건'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
for (const viewport of [
|
for (const viewport of [
|
||||||
{ name: 'desktop', width: 1200, height: 900 },
|
{ name: 'desktop', width: 1200, height: 900 },
|
||||||
{ name: 'mobile', width: 500, height: 900 },
|
{ name: 'mobile', width: 500, height: 900 },
|
||||||
|
|||||||
@@ -397,7 +397,7 @@ test('암행부 행을 도시별로 나누고 수뇌의 인사부 즉시 임명
|
|||||||
return { borderTopWidth: style.borderTopWidth, backgroundColor: style.backgroundColor };
|
return { borderTopWidth: style.borderTopWidth, backgroundColor: style.backgroundColor };
|
||||||
});
|
});
|
||||||
expect(disabledStyle).toEqual({ borderTopWidth: '0px', backgroundColor: 'rgba(0, 0, 0, 0)' });
|
expect(disabledStyle).toEqual({ borderTopWidth: '0px', backgroundColor: 'rgba(0, 0, 0, 0)' });
|
||||||
const appointButton = page.getByRole('button', { name: '장료을(를) 허창 태수로 임명' });
|
const appointButton = page.getByRole('button', { name: '장료를 허창 태수로 임명' });
|
||||||
await appointButton.hover();
|
await appointButton.hover();
|
||||||
expect(await appointButton.evaluate((button) => getComputedStyle(button).cursor)).toBe('pointer');
|
expect(await appointButton.evaluate((button) => getComputedStyle(button).cursor)).toBe('pointer');
|
||||||
await appointButton.focus();
|
await appointButton.focus();
|
||||||
@@ -406,6 +406,7 @@ test('암행부 행을 도시별로 나누고 수뇌의 인사부 즉시 임명
|
|||||||
await page.screenshot({ path: testInfo.outputPath('nation-city-integrated-desktop.png'), fullPage: true });
|
await page.screenshot({ path: testInfo.outputPath('nation-city-integrated-desktop.png'), fullPage: true });
|
||||||
await appointButton.click();
|
await appointButton.click();
|
||||||
await expect.poll(() => state.appointmentInputs).toEqual([{ destGeneralId: 21, destCityId: 1, officerLevel: 4 }]);
|
await expect.poll(() => state.appointmentInputs).toEqual([{ destGeneralId: 21, destCityId: 1, officerLevel: 4 }]);
|
||||||
|
await expect(page.getByTestId('game-toast')).toContainText('장료를 허창 태수로 임명했습니다.');
|
||||||
await expect(page.locator('.city[data-city-id="1"] .officer-4-value')).toHaveText('장료');
|
await expect(page.locator('.city[data-city-id="1"] .officer-4-value')).toHaveText('장료');
|
||||||
await expect(page.locator('.city[data-city-id="1"] .officer-4-value')).toHaveClass(/effective-officer/u);
|
await expect(page.locator('.city[data-city-id="1"] .officer-4-value')).toHaveClass(/effective-officer/u);
|
||||||
await expect(page.locator('.city[data-city-id="1"] tr[data-general-id="21"] .mode-4')).toBeDisabled();
|
await expect(page.locator('.city[data-city-id="1"] tr[data-general-id="21"] .mode-4')).toBeDisabled();
|
||||||
@@ -425,7 +426,7 @@ test('수뇌 대상은 재확인하고 일반 장수에게는 임명 버튼을
|
|||||||
await page.getByRole('button', { name: '암행부 연동' }).click();
|
await page.getByRole('button', { name: '암행부 연동' }).click();
|
||||||
await page.getByRole('button', { name: '인사부 연동' }).click();
|
await page.getByRole('button', { name: '인사부 연동' }).click();
|
||||||
|
|
||||||
const chiefButton = page.getByRole('button', { name: '순욱을(를) 허창 태수로 임명' });
|
const chiefButton = page.getByRole('button', { name: '순욱을 허창 태수로 임명' });
|
||||||
expect(await chiefButton.evaluate((button) => getComputedStyle(button).color)).toBe('rgb(255, 0, 0)');
|
expect(await chiefButton.evaluate((button) => getComputedStyle(button).color)).toBe('rgb(255, 0, 0)');
|
||||||
page.once('dialog', async (dialog) => {
|
page.once('dialog', async (dialog) => {
|
||||||
expect(dialog.message()).toBe('수뇌입니다. 임명할까요?');
|
expect(dialog.message()).toBe('수뇌입니다. 임명할까요?');
|
||||||
|
|||||||
@@ -22,6 +22,17 @@ const readReferenceImage = async (filename: string): Promise<Buffer> => {
|
|||||||
throw new Error(`Reference image not found: ${filename}`);
|
throw new Error(`Reference image not found: ${filename}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const readReferenceIcon = async (filename: string): Promise<Buffer> => {
|
||||||
|
for (const gameImageRoot of imageRoots) {
|
||||||
|
try {
|
||||||
|
return await readFile(resolve(gameImageRoot, '..', 'icons', filename));
|
||||||
|
} catch {
|
||||||
|
// The main checkout and nested feature worktrees have different parents.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`Reference icon not found: ${filename}`);
|
||||||
|
};
|
||||||
|
|
||||||
type Member = {
|
type Member = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -35,22 +46,137 @@ type Member = {
|
|||||||
statUpgradeLimit: number;
|
statUpgradeLimit: number;
|
||||||
dex: number[];
|
dex: number[];
|
||||||
};
|
};
|
||||||
|
panel: {
|
||||||
|
general: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
picture: string | null;
|
||||||
|
imageServer: number;
|
||||||
|
npcState: number;
|
||||||
|
officerLevel: number;
|
||||||
|
officerLevelText: string;
|
||||||
|
officerCityName: string | null;
|
||||||
|
generalType: string;
|
||||||
|
leadershipBonus: number;
|
||||||
|
stats: { leadership: number; strength: number; intelligence: number };
|
||||||
|
gold: number;
|
||||||
|
rice: number;
|
||||||
|
crew: number;
|
||||||
|
train: number;
|
||||||
|
atmos: number;
|
||||||
|
injury: number;
|
||||||
|
experience: number;
|
||||||
|
dedication: number;
|
||||||
|
age: number;
|
||||||
|
retirementYear: number;
|
||||||
|
turnTime: string;
|
||||||
|
defenceTrain: number;
|
||||||
|
killTurn: number;
|
||||||
|
remainingMinutes: number;
|
||||||
|
troopId: number;
|
||||||
|
troop: { name: string; status: 'present' };
|
||||||
|
refreshScore: { current: number; total: number; text: string };
|
||||||
|
crewTypeId: number;
|
||||||
|
crewTypeName: string;
|
||||||
|
traits: { personal: string; specialDomestic: string; specialWar: string };
|
||||||
|
progression: {
|
||||||
|
experienceLevel: number;
|
||||||
|
dedicationLevel: number;
|
||||||
|
dedicationText: string;
|
||||||
|
statExperience: { leadership: number; strength: number; intelligence: number };
|
||||||
|
statUpgradeLimit: number;
|
||||||
|
dex: number[];
|
||||||
|
};
|
||||||
|
itemNames: { horse: string; weapon: string; book: string; item: string };
|
||||||
|
};
|
||||||
|
summary: {
|
||||||
|
available: true;
|
||||||
|
experience: number;
|
||||||
|
dedicationText: string;
|
||||||
|
bill: number;
|
||||||
|
warnum: number;
|
||||||
|
wins: number;
|
||||||
|
losses: number;
|
||||||
|
strategies: number;
|
||||||
|
serviceYears: number;
|
||||||
|
killCrew: number;
|
||||||
|
deathCrew: number;
|
||||||
|
recentWar: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const member = (id: number, name: string, cityId: number, cityName: string): Member => ({
|
const member = (id: number, name: string, cityId: number, cityName: string, troopName = '백마대'): Member => {
|
||||||
|
const stats = { leadership: 70, strength: 60, intelligence: 50 };
|
||||||
|
const progression = {
|
||||||
|
experienceLevel: 4,
|
||||||
|
dedicationLevel: 3,
|
||||||
|
dedicationText: '28품관',
|
||||||
|
statExperience: { leadership: 7, strength: 8, intelligence: 9 },
|
||||||
|
statUpgradeLimit: 20,
|
||||||
|
dex: [350, 1_375, 3_500, 7_125, 1_275_975],
|
||||||
|
};
|
||||||
|
return {
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
cityId,
|
cityId,
|
||||||
cityName,
|
cityName,
|
||||||
stats: { leadership: 70, strength: 60, intelligence: 50 },
|
stats,
|
||||||
experience: 450,
|
experience: 450,
|
||||||
progression: {
|
progression,
|
||||||
experienceLevel: 4,
|
panel: {
|
||||||
statExperience: { leadership: 7, strength: 8, intelligence: 9 },
|
general: {
|
||||||
statUpgradeLimit: 20,
|
id,
|
||||||
dex: [350, 1_375, 3_500, 7_125, 1_275_975],
|
name,
|
||||||
|
picture: 'default.jpg',
|
||||||
|
imageServer: 0,
|
||||||
|
npcState: 0,
|
||||||
|
officerLevel: 1,
|
||||||
|
officerLevelText: '일반',
|
||||||
|
officerCityName: null,
|
||||||
|
generalType: '용장',
|
||||||
|
leadershipBonus: 0,
|
||||||
|
stats,
|
||||||
|
gold: 1_234,
|
||||||
|
rice: 4_321,
|
||||||
|
crew: 987,
|
||||||
|
train: 88,
|
||||||
|
atmos: 77,
|
||||||
|
injury: 0,
|
||||||
|
experience: 450,
|
||||||
|
dedication: 900,
|
||||||
|
age: 31,
|
||||||
|
retirementYear: 70,
|
||||||
|
turnTime: '2026-07-25T08:22:33.000Z',
|
||||||
|
defenceTrain: 90,
|
||||||
|
killTurn: 7,
|
||||||
|
remainingMinutes: 3,
|
||||||
|
troopId: 1,
|
||||||
|
troop: { name: troopName, status: 'present' },
|
||||||
|
refreshScore: { current: 13, total: 800, text: '열심' },
|
||||||
|
crewTypeId: 1100,
|
||||||
|
crewTypeName: '보병',
|
||||||
|
traits: { personal: '대담', specialDomestic: '농업', specialWar: '맹장' },
|
||||||
|
progression,
|
||||||
|
itemNames: { horse: '명마', weapon: '명검', book: '병서', item: '도구' },
|
||||||
},
|
},
|
||||||
});
|
summary: {
|
||||||
|
available: true,
|
||||||
|
experience: 450,
|
||||||
|
dedicationText: '28품관',
|
||||||
|
bill: 1_000,
|
||||||
|
warnum: 17,
|
||||||
|
wins: 11,
|
||||||
|
losses: 6,
|
||||||
|
strategies: 5,
|
||||||
|
serviceYears: 11,
|
||||||
|
killCrew: 1_234,
|
||||||
|
deathCrew: 432,
|
||||||
|
recentWar: '2026-07-25T08:12:34.000Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
type TroopFixture = {
|
type TroopFixture = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -105,7 +231,7 @@ const baseTroops = (): TroopFixture[] => [
|
|||||||
picture: 'default.jpg',
|
picture: 'default.jpg',
|
||||||
imageServer: 0,
|
imageServer: 0,
|
||||||
},
|
},
|
||||||
members: [member(2, '관우', 2, '계')],
|
members: [member(2, '관우', 2, '계', '청룡대')],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -157,14 +283,18 @@ const installApiFixture = async (page: Page, state: FixtureState) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await page.route('**/image/icons/**', async (route) => {
|
await page.route('**/game/crewtype1100.png', async (route) => {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: 'image/png',
|
contentType: 'image/png',
|
||||||
body: Buffer.from(
|
body: await readReferenceImage('crewtype1100.png'),
|
||||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
});
|
||||||
'base64'
|
});
|
||||||
),
|
await page.route('**/icons/**', async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
body: await readReferenceIcon('default.jpg'),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
await page.route(gameTrpcRoute, async (route) => {
|
await page.route(gameTrpcRoute, async (route) => {
|
||||||
@@ -179,7 +309,7 @@ const installApiFixture = async (page: Page, state: FixtureState) => {
|
|||||||
}
|
}
|
||||||
if (operation === 'troop.getList') {
|
if (operation === 'troop.getList') {
|
||||||
return response({
|
return response({
|
||||||
nation: { id: 1, name: '테스트국' },
|
nation: { id: 1, name: '테스트국', color: '#123456' },
|
||||||
me: state.me,
|
me: state.me,
|
||||||
permission: state.permission,
|
permission: state.permission,
|
||||||
troops: state.troops,
|
troops: state.troops,
|
||||||
@@ -206,7 +336,7 @@ const installApiFixture = async (page: Page, state: FixtureState) => {
|
|||||||
picture: 'default.jpg',
|
picture: 'default.jpg',
|
||||||
imageServer: 0,
|
imageServer: 0,
|
||||||
},
|
},
|
||||||
members: [member(createdId, '유비', 1, '북평')],
|
members: [member(createdId, '유비', 1, '북평', '신규대')],
|
||||||
});
|
});
|
||||||
return response({ ok: true, troopId: createdId, troopName: '신규대' });
|
return response({ ok: true, troopId: createdId, troopName: '신규대' });
|
||||||
}
|
}
|
||||||
@@ -301,7 +431,7 @@ test('renders the legacy desktop grid with matching computed geometry and states
|
|||||||
paddingTop: '7px',
|
paddingTop: '7px',
|
||||||
paddingLeft: '9.8px',
|
paddingLeft: '9.8px',
|
||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
fontFamily: 'Pretendard, "Apple SD Gothic Neo", "Noto Sans KR", "Malgun Gothic"',
|
fontFamily: 'Pretendard, "Apple SD Gothic Neo", "Noto Sans KR", "Malgun Gothic", sans-serif',
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
lineHeight: '21px',
|
lineHeight: '21px',
|
||||||
});
|
});
|
||||||
@@ -317,9 +447,16 @@ test('renders the legacy desktop grid with matching computed geometry and states
|
|||||||
expect(hoverStyle.borderBottomWidth).toBe('3px');
|
expect(hoverStyle.borderBottomWidth).toBe('3px');
|
||||||
|
|
||||||
await page.locator('.troopMember').nth(1).hover();
|
await page.locator('.troopMember').nth(1).hover();
|
||||||
await expect(page.getByRole('tooltip')).toContainText('조운');
|
const popup = page.getByRole('tooltip');
|
||||||
await expect(page.getByRole('tooltip').locator('[role="progressbar"]')).toHaveCount(14);
|
await expect(popup).toContainText('조운');
|
||||||
await expect(page.getByRole('tooltip').locator('[aria-label*="1,275,975 (EX+)"]')).toHaveCount(5);
|
await expect(popup.locator('[data-general-information-panel]')).toHaveCount(1);
|
||||||
|
await expect(popup.locator('[data-general-basic-card]')).toHaveCount(1);
|
||||||
|
await expect(popup.locator('[data-general-battle-summary]')).toHaveCount(1);
|
||||||
|
await expect(popup).toContainText('봉급1,000');
|
||||||
|
await expect(popup).toContainText('승률64.71%');
|
||||||
|
await expect(popup).toContainText('살상률285.65%');
|
||||||
|
await expect(popup.locator('[role="progressbar"]')).toHaveCount(14);
|
||||||
|
await expect(popup.locator('[aria-label*="1,275,975 (EX+)"]')).toHaveCount(5);
|
||||||
expect(
|
expect(
|
||||||
await page
|
await page
|
||||||
.getByRole('tooltip')
|
.getByRole('tooltip')
|
||||||
@@ -327,10 +464,21 @@ test('renders the legacy desktop grid with matching computed geometry and states
|
|||||||
.first()
|
.first()
|
||||||
.evaluate((bar) => getComputedStyle(bar).backgroundImage)
|
.evaluate((bar) => getComputedStyle(bar).backgroundImage)
|
||||||
).toContain('/game/pr8.gif');
|
).toContain('/game/pr8.gif');
|
||||||
expect(await page.getByRole('tooltip').evaluate((tooltip) => tooltip.getBoundingClientRect().width)).toBeCloseTo(
|
const popupGeometry = await popup.evaluate((tooltip) => {
|
||||||
500,
|
const rect = tooltip.getBoundingClientRect();
|
||||||
0
|
const generalIcon = tooltip.querySelector<HTMLElement>('.general-icon')!.getBoundingClientRect();
|
||||||
);
|
const crewIcon = tooltip.querySelector<HTMLElement>('.general-crew-type-icon')!.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
width: rect.width,
|
||||||
|
generalIcon: { width: generalIcon.width, height: generalIcon.height },
|
||||||
|
crewIcon: { width: crewIcon.width, height: crewIcon.height },
|
||||||
|
backgroundImage: getComputedStyle(tooltip.querySelector<HTMLElement>('.general-icon')!).backgroundImage,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(popupGeometry.width).toBeCloseTo(500, 0);
|
||||||
|
expect(popupGeometry.generalIcon).toEqual({ width: 64, height: 64 });
|
||||||
|
expect(popupGeometry.crewIcon).toEqual({ width: 64, height: 64 });
|
||||||
|
expect(popupGeometry.backgroundImage).toContain('/icons/default.jpg');
|
||||||
await page.screenshot({ path: 'test-results/troop/desktop-leader.png', fullPage: true });
|
await page.screenshot({ path: 'test-results/troop/desktop-leader.png', fullPage: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -371,6 +519,12 @@ test('matches the legacy 500px responsive placement', async ({ page }) => {
|
|||||||
expect(geometry.reserved).toMatchObject({ x: 260, y: 0, width: 100 });
|
expect(geometry.reserved).toMatchObject({ x: 260, y: 0, width: 100 });
|
||||||
expect(geometry.action).toMatchObject({ x: 360, y: 0, width: 140 });
|
expect(geometry.action).toMatchObject({ x: 360, y: 0, width: 140 });
|
||||||
expect(geometry.members).toMatchObject({ x: 130, y: 93, width: 370 });
|
expect(geometry.members).toMatchObject({ x: 130, y: 93, width: 370 });
|
||||||
|
await page.locator('.troopMember').nth(1).hover();
|
||||||
|
const mobilePopup = page.getByRole('tooltip');
|
||||||
|
await expect(mobilePopup.locator('[data-general-information-panel]')).toHaveCount(1);
|
||||||
|
await expect(mobilePopup.locator('[role="progressbar"]')).toHaveCount(14);
|
||||||
|
expect(await mobilePopup.evaluate((tooltip) => tooltip.getBoundingClientRect().width)).toBeCloseTo(500, 0);
|
||||||
|
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(500);
|
||||||
await page.screenshot({ path: 'test-results/troop/mobile-leader.png', fullPage: true });
|
await page.screenshot({ path: 'test-results/troop/mobile-leader.png', fullPage: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,11 @@ const dragKind = ref<'replace' | 'toggle' | null>(null);
|
|||||||
const quickTarget = ref<number | null>(null);
|
const quickTarget = ref<number | null>(null);
|
||||||
const pickerOpen = ref(false);
|
const pickerOpen = ref(false);
|
||||||
const selectedCommand = ref<CommandAvailability | null>(null);
|
const selectedCommand = ref<CommandAvailability | null>(null);
|
||||||
|
const commandInputSnapshot = shallowRef<{
|
||||||
|
options: CommandTable['inputOptions'];
|
||||||
|
mapData: CommandMapData | null;
|
||||||
|
mapLayout: CommandMapLayout | null;
|
||||||
|
} | null>(null);
|
||||||
const commandArgs = ref<Record<string, unknown>>({});
|
const commandArgs = ref<Record<string, unknown>>({});
|
||||||
const commandArgsValid = ref(false);
|
const commandArgsValid = ref(false);
|
||||||
const expanded = ref(false);
|
const expanded = ref(false);
|
||||||
@@ -188,6 +193,7 @@ const openPicker = (turnIndex?: number) => {
|
|||||||
quickTarget.value = turnIndex ?? null;
|
quickTarget.value = turnIndex ?? null;
|
||||||
pickerOpen.value = true;
|
pickerOpen.value = true;
|
||||||
selectedCommand.value = null;
|
selectedCommand.value = null;
|
||||||
|
commandInputSnapshot.value = null;
|
||||||
commandArgs.value = {};
|
commandArgs.value = {};
|
||||||
commandArgsValid.value = false;
|
commandArgsValid.value = false;
|
||||||
};
|
};
|
||||||
@@ -195,6 +201,7 @@ const closePicker = () => {
|
|||||||
pickerOpen.value = false;
|
pickerOpen.value = false;
|
||||||
quickTarget.value = null;
|
quickTarget.value = null;
|
||||||
selectedCommand.value = null;
|
selectedCommand.value = null;
|
||||||
|
commandInputSnapshot.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
let previousBodyOverflow: string | null = null;
|
let previousBodyOverflow: string | null = null;
|
||||||
@@ -245,11 +252,21 @@ const togglePicker = (turnIndex?: number) => {
|
|||||||
openPicker(turnIndex);
|
openPicker(turnIndex);
|
||||||
};
|
};
|
||||||
const selectCommand = (commandKey: string) => {
|
const selectCommand = (commandKey: string) => {
|
||||||
const command = props.commandTable?.[props.scope]
|
const table = props.commandTable;
|
||||||
|
const command = table?.[props.scope]
|
||||||
.flatMap((group) => group.values)
|
.flatMap((group) => group.values)
|
||||||
.find((entry) => entry.key === commandKey);
|
.find((entry) => entry.key === commandKey);
|
||||||
if (!command) return;
|
if (!table || !command) return;
|
||||||
selectedCommand.value = command;
|
selectedCommand.value = command;
|
||||||
|
// Ref opens argument commands on a separate processing page. Keep the same
|
||||||
|
// isolation while this inline form is open: patching a focused <select>
|
||||||
|
// during a realtime refresh can reset an iOS/Android native picker before
|
||||||
|
// its tentative wheel selection has emitted `change`.
|
||||||
|
commandInputSnapshot.value = {
|
||||||
|
options: table.inputOptions,
|
||||||
|
mapData: props.mapData,
|
||||||
|
mapLayout: props.mapLayout,
|
||||||
|
};
|
||||||
commandArgs.value = {};
|
commandArgs.value = {};
|
||||||
commandArgsValid.value = !command.reqArg;
|
commandArgsValid.value = !command.reqArg;
|
||||||
const needsInformationalConfirmation = commandArgumentPresentation(command.key).mapTarget === 'capital';
|
const needsInformationalConfirmation = commandArgumentPresentation(command.key).mapTarget === 'capital';
|
||||||
@@ -263,6 +280,10 @@ const submitCommand = () => {
|
|||||||
emit('reserve-bulk', [entry]);
|
emit('reserve-bulk', [entry]);
|
||||||
pendingReservation.value = entry;
|
pendingReservation.value = entry;
|
||||||
};
|
};
|
||||||
|
const returnToCommandList = () => {
|
||||||
|
selectedCommand.value = null;
|
||||||
|
commandInputSnapshot.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
const applyPattern = (raw: CommandPatternEntry[] | undefined) => {
|
const applyPattern = (raw: CommandPatternEntry[] | undefined) => {
|
||||||
if (!raw?.length) return;
|
if (!raw?.length) return;
|
||||||
@@ -735,31 +756,31 @@ const clickOutsideMenu = (event: Event) => {
|
|||||||
<RecruitmentCommandForm
|
<RecruitmentCommandForm
|
||||||
v-if="
|
v-if="
|
||||||
isRecruitmentCommand &&
|
isRecruitmentCommand &&
|
||||||
props.commandTable?.inputOptions.recruitment &&
|
commandInputSnapshot?.options.recruitment &&
|
||||||
(selectedCommand.key === 'che_징병' || selectedCommand.key === 'che_모병')
|
(selectedCommand.key === 'che_징병' || selectedCommand.key === 'che_모병')
|
||||||
"
|
"
|
||||||
:command-key="selectedCommand.key"
|
:command-key="selectedCommand.key"
|
||||||
:info="props.commandTable.inputOptions.recruitment"
|
:info="commandInputSnapshot.options.recruitment"
|
||||||
@update:args="commandArgs = $event"
|
@update:args="commandArgs = $event"
|
||||||
@update:valid="commandArgsValid = $event"
|
@update:valid="commandArgsValid = $event"
|
||||||
@submit="submitCommand"
|
@submit="submitCommand"
|
||||||
/>
|
/>
|
||||||
<CommandArgumentForm
|
<CommandArgumentForm
|
||||||
v-else-if="
|
v-else-if="
|
||||||
props.commandTable &&
|
commandInputSnapshot &&
|
||||||
(selectedCommand.reqArg ||
|
(selectedCommand.reqArg ||
|
||||||
commandArgumentPresentation(selectedCommand.key).mapTarget === 'capital')
|
commandArgumentPresentation(selectedCommand.key).mapTarget === 'capital')
|
||||||
"
|
"
|
||||||
:command-key="selectedCommand.key"
|
:command-key="selectedCommand.key"
|
||||||
:fields="selectedCommand.inputFields"
|
:fields="selectedCommand.inputFields"
|
||||||
:options="props.commandTable.inputOptions"
|
:options="commandInputSnapshot.options"
|
||||||
:map-data="props.mapData"
|
:map-data="commandInputSnapshot.mapData"
|
||||||
:map-layout="props.mapLayout"
|
:map-layout="commandInputSnapshot.mapLayout"
|
||||||
@update:args="commandArgs = $event"
|
@update:args="commandArgs = $event"
|
||||||
@update:valid="commandArgsValid = $event"
|
@update:valid="commandArgsValid = $event"
|
||||||
/>
|
/>
|
||||||
<div class="picker-actions">
|
<div class="picker-actions">
|
||||||
<button :disabled="Boolean(pendingReservation)" @click="selectedCommand = null">
|
<button :disabled="Boolean(pendingReservation)" @click="returnToCommandList">
|
||||||
명령 다시 선택</button
|
명령 다시 선택</button
|
||||||
><button :disabled="!commandArgsValid || Boolean(pendingReservation)" @click="submitCommand">
|
><button :disabled="!commandArgsValid || Boolean(pendingReservation)" @click="submitCommand">
|
||||||
{{ pendingReservation ? '저장 중' : '입력' }}
|
{{ pendingReservation ? '저장 중' : '입력' }}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { JosaUtil } from '@sammo-ts/common/util/JosaUtil';
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||||
import SortableStringList from '../components/ui/SortableStringList';
|
import SortableStringList from '../components/ui/SortableStringList';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
@@ -365,7 +366,7 @@ const dieOnPrestart = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const dropItem = (item: { key: ItemSlotKey; slotName: string; displayName: string | null; code: string | null }) =>
|
const dropItem = (item: { key: ItemSlotKey; slotName: string; displayName: string | null; code: string | null }) =>
|
||||||
confirmMutation(`${item.displayName ?? item.slotName}을(를) 버리시겠습니까?`, () =>
|
confirmMutation(`${JosaUtil.put(item.displayName ?? item.slotName, '을')} 버리시겠습니까?`, () =>
|
||||||
trpc.general.dropItem.mutate({ itemType: item.key })
|
trpc.general.dropItem.mutate({ itemType: item.key })
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
|
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
|
||||||
|
import { JosaUtil } from '@sammo-ts/common/util/JosaUtil';
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { formatReservedCommandBrief } from '../components/command/reservedCommandBrief';
|
import { formatReservedCommandBrief } from '../components/command/reservedCommandBrief';
|
||||||
@@ -59,6 +60,8 @@ const sortOptions = [
|
|||||||
'규모',
|
'규모',
|
||||||
].map((label, index) => ({ value: index + 1, label }));
|
].map((label, index) => ({ value: index + 1, label }));
|
||||||
const officerLabels: Record<OfficerLevel, string> = { 4: '태수', 3: '군사', 2: '종사' };
|
const officerLabels: Record<OfficerLevel, string> = { 4: '태수', 3: '군사', 2: '종사' };
|
||||||
|
const appointmentDescription = (city: City, general: SecretGeneral, level: OfficerLevel): string =>
|
||||||
|
`${JosaUtil.put(general.name, '을')} ${city.name} ${JosaUtil.put(officerLabels[level], '으로')} 임명`;
|
||||||
const generalsForCity = (cityId: number) => data.value?.generals.filter((general) => general.cityId === cityId) ?? [];
|
const generalsForCity = (cityId: number) => data.value?.generals.filter((general) => general.cityId === cityId) ?? [];
|
||||||
const secretGeneralsForCity = (cityId: number) =>
|
const secretGeneralsForCity = (cityId: number) =>
|
||||||
secretData.value?.generals.filter((general) => general.cityId === cityId) ?? [];
|
secretData.value?.generals.filter((general) => general.cityId === cityId) ?? [];
|
||||||
@@ -262,7 +265,7 @@ const appointCityOfficer = async (city: City, general: SecretGeneral, level: Off
|
|||||||
destCityId: city.id,
|
destCityId: city.id,
|
||||||
officerLevel: level,
|
officerLevel: level,
|
||||||
});
|
});
|
||||||
showSuccessToast(`${general.name}을(를) ${city.name} ${officerLabels[level]}로 임명했습니다.`);
|
showSuccessToast(`${appointmentDescription(city, general, level)}했습니다.`);
|
||||||
try {
|
try {
|
||||||
await refreshIntegratedData();
|
await refreshIntegratedData();
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
@@ -652,7 +655,7 @@ onMounted(async () => {
|
|||||||
:disabled="
|
:disabled="
|
||||||
!canAppoint(city.id, general.id, level) || pendingAppointment !== ''
|
!canAppoint(city.id, general.id, level) || pendingAppointment !== ''
|
||||||
"
|
"
|
||||||
:aria-label="`${general.name}을(를) ${city.name} ${officerLabels[level]}로 임명`"
|
:aria-label="appointmentDescription(city, general, level)"
|
||||||
@click="appointCityOfficer(city, general, level)"
|
@click="appointCityOfficer(city, general, level)"
|
||||||
>
|
>
|
||||||
{{ officerLabels[level].slice(0, 1) }}
|
{{ officerLabels[level].slice(0, 1) }}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useRouter } from 'vue-router';
|
|||||||
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
|
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue';
|
import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue';
|
||||||
|
import GeneralInformationPanel from '../components/main/GeneralInformationPanel.vue';
|
||||||
import { useGameFeedback } from '../composables/useGameFeedback';
|
import { useGameFeedback } from '../composables/useGameFeedback';
|
||||||
|
|
||||||
type TroopList = Awaited<ReturnType<typeof trpc.troop.getList.query>>;
|
type TroopList = Awaited<ReturnType<typeof trpc.troop.getList.query>>;
|
||||||
@@ -340,11 +341,20 @@ onMounted(() => {
|
|||||||
<div></div>
|
<div></div>
|
||||||
</footer>
|
</footer>
|
||||||
<div v-if="popupMember" id="generalPopup" :style="{ top: `${popupTop}px` }" role="tooltip">
|
<div v-if="popupMember" id="generalPopup" :style="{ top: `${popupTop}px` }" role="tooltip">
|
||||||
|
<GeneralInformationPanel
|
||||||
|
v-if="popupMember.panel"
|
||||||
|
:general="popupMember.panel.general"
|
||||||
|
:summary="popupMember.panel.summary"
|
||||||
|
:loading="false"
|
||||||
|
:nation-color="data?.nation.color"
|
||||||
|
/>
|
||||||
|
<template v-else>
|
||||||
<div class="popup-title">
|
<div class="popup-title">
|
||||||
<strong>{{ popupMember.name }}</strong>
|
<strong>{{ popupMember.name }}</strong>
|
||||||
<span>{{ popupMember.cityName }}</span>
|
<span>{{ popupMember.cityName }}</span>
|
||||||
</div>
|
</div>
|
||||||
<LegacyGeneralProgress :general="popupMember" />
|
<LegacyGeneralProgress :general="popupMember" />
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user