Merge main into feature/ingame-info-menus
This commit is contained in:
@@ -30,6 +30,9 @@ export * from './auction/types.js';
|
||||
export * from './auction/keys.js';
|
||||
export * from './auction/scheduler.js';
|
||||
export * from './auction/worker.js';
|
||||
export * from './tournament/keys.js';
|
||||
export * from './tournament/store.js';
|
||||
export * from './tournament/types.js';
|
||||
export * from './tournament/worker.js';
|
||||
|
||||
// Types for TRPC consumer
|
||||
|
||||
@@ -3,7 +3,14 @@ import { z } from 'zod';
|
||||
|
||||
import { authedProcedure, router } from '../../trpc.js';
|
||||
import { asNumber, asRecord, parseJson, LiteHashDRBG } from '@sammo-ts/common';
|
||||
import { loadWarTraitModules, WarTraitLoader, WAR_TRAIT_KEYS, isWarTraitKey } from '@sammo-ts/logic';
|
||||
import {
|
||||
ItemLoader,
|
||||
isItemKey,
|
||||
loadWarTraitModules,
|
||||
WarTraitLoader,
|
||||
WAR_TRAIT_KEYS,
|
||||
isWarTraitKey,
|
||||
} from '@sammo-ts/logic';
|
||||
import type { InheritBuffType } from '@sammo-ts/logic';
|
||||
import {
|
||||
appendInheritanceLog,
|
||||
@@ -23,8 +30,8 @@ const BUFF_KEYS: InheritBuffType[] = [
|
||||
'warAvoidRatio',
|
||||
'warCriticalRatio',
|
||||
'warMagicTrialProb',
|
||||
'success',
|
||||
'fail',
|
||||
'domesticSuccessProb',
|
||||
'domesticFailProb',
|
||||
'warAvoidRatioOppose',
|
||||
'warCriticalRatioOppose',
|
||||
'warMagicTrialProbOppose',
|
||||
@@ -34,8 +41,8 @@ const BUFF_LABELS: Record<InheritBuffType, string> = {
|
||||
warAvoidRatio: '회피 확률 증가',
|
||||
warCriticalRatio: '필살 확률 증가',
|
||||
warMagicTrialProb: '전투계략 시도 확률 증가',
|
||||
success: '내정 성공률 증가',
|
||||
fail: '내정 실패율 감소',
|
||||
domesticSuccessProb: '내정 성공률 증가',
|
||||
domesticFailProb: '내정 실패율 감소',
|
||||
warAvoidRatioOppose: '상대 회피 확률 감소',
|
||||
warCriticalRatioOppose: '상대 필살 확률 감소',
|
||||
warMagicTrialProbOppose: '상대 전투계략 시도 확률 감소',
|
||||
@@ -58,6 +65,37 @@ const parseBuffRecord = (raw: unknown): Record<string, number> => {
|
||||
|
||||
const serializeBuffRecord = (buff: Record<string, number>): string => JSON.stringify(buff);
|
||||
|
||||
const readBuffLevel = (buff: Record<string, number>, key: InheritBuffType): number => {
|
||||
const compatibilityKey = key === 'domesticSuccessProb' ? 'success' : key === 'domesticFailProb' ? 'fail' : null;
|
||||
return Math.max(0, Math.min(5, Math.floor(buff[key] ?? (compatibilityKey ? buff[compatibilityKey] : 0) ?? 0)));
|
||||
};
|
||||
|
||||
const loadAvailableUniqueItems = async (worldState: WorldStateRow) => {
|
||||
const configuredItems = asRecord(asRecord(worldState.config).const).allItems;
|
||||
const enabledKeys: Array<Parameters<ItemLoader['load']>[0]> = [];
|
||||
for (const entries of Object.values(asRecord(configuredItems))) {
|
||||
for (const [key, amount] of Object.entries(asRecord(entries))) {
|
||||
if (asNumber(amount, 0) !== 0 && isItemKey(key)) {
|
||||
enabledKeys.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const loader = new ItemLoader();
|
||||
const items = await Promise.all(
|
||||
[...new Set(enabledKeys)].map(async (key) => {
|
||||
const item = await loader.load(key);
|
||||
return {
|
||||
key,
|
||||
name: item.name,
|
||||
rawName: item.rawName,
|
||||
info: item.info ?? '',
|
||||
};
|
||||
})
|
||||
);
|
||||
return items.sort((left, right) => left.name.localeCompare(right.name, 'ko'));
|
||||
};
|
||||
|
||||
const resolveWorld = async (ctx: { db: { worldState: { findFirst: () => Promise<unknown> } } }) => {
|
||||
const worldState = await ctx.db.worldState.findFirst();
|
||||
if (!worldState || typeof worldState !== 'object') {
|
||||
@@ -199,6 +237,9 @@ export const inheritRouter = router({
|
||||
special2Code: true,
|
||||
meta: true,
|
||||
turnTime: true,
|
||||
leadership: true,
|
||||
strength: true,
|
||||
intel: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -219,7 +260,7 @@ export const inheritRouter = router({
|
||||
const inheritConst = resolveInheritConstants(worldState);
|
||||
const buffState = parseBuffRecord(asRecord(general.meta).inheritBuff);
|
||||
const buffLevels = BUFF_KEYS.reduce<Record<string, number>>((acc, key) => {
|
||||
acc[key] = Math.max(0, Math.min(5, Math.floor(buffState[key] ?? 0)));
|
||||
acc[key] = readBuffLevel(buffState, key);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
@@ -240,11 +281,14 @@ export const inheritRouter = router({
|
||||
info: trait.info ?? '',
|
||||
}));
|
||||
|
||||
const others = await ctx.db.general.findMany({
|
||||
where: { id: { not: general.id }, userId: { not: null } },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
const [others, availableUnique] = await Promise.all([
|
||||
ctx.db.general.findMany({
|
||||
where: { id: { not: general.id }, npcState: { lt: 2 }, userId: { not: null } },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { id: 'asc' },
|
||||
}),
|
||||
loadAvailableUniqueItems(worldState),
|
||||
]);
|
||||
|
||||
return {
|
||||
items,
|
||||
@@ -260,10 +304,16 @@ export const inheritRouter = router({
|
||||
resetTurnTime: resetTurnLevel,
|
||||
},
|
||||
availableSpecialWar: warSpecials,
|
||||
availableUnique,
|
||||
availableTargetGenerals: others,
|
||||
turnTimeZones: buildTurnTimeZoneList(Math.max(1, Math.round(worldState.tickSeconds / 60))),
|
||||
isUnited,
|
||||
currentSpecialWar: general.special2Code ?? 'None',
|
||||
currentStat: {
|
||||
leadership: general.leadership,
|
||||
strength: general.strength,
|
||||
intel: general.intel,
|
||||
},
|
||||
};
|
||||
}),
|
||||
getLogs: authedProcedure
|
||||
@@ -285,7 +335,7 @@ export const inheritRouter = router({
|
||||
},
|
||||
orderBy: { id: 'desc' },
|
||||
take: 30,
|
||||
select: { id: true, year: true, month: true, text: true },
|
||||
select: { id: true, year: true, month: true, text: true, createdAt: true },
|
||||
});
|
||||
return logs;
|
||||
}),
|
||||
@@ -318,7 +368,7 @@ export const inheritRouter = router({
|
||||
}
|
||||
|
||||
const buff = parseBuffRecord(asRecord(general.meta).inheritBuff);
|
||||
const prevLevel = Math.max(0, Math.min(5, Math.floor(buff[input.type] ?? 0)));
|
||||
const prevLevel = readBuffLevel(buff, input.type);
|
||||
if (input.level === prevLevel) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 구입했습니다.' });
|
||||
}
|
||||
@@ -417,7 +467,12 @@ export const inheritRouter = router({
|
||||
},
|
||||
});
|
||||
|
||||
await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - inheritConst.inheritSpecificSpecialPoint);
|
||||
await setInheritancePoint(
|
||||
ctx.db,
|
||||
userId,
|
||||
'previous',
|
||||
currentPoint - inheritConst.inheritSpecificSpecialPoint
|
||||
);
|
||||
await appendInheritanceLog(
|
||||
ctx.db,
|
||||
userId,
|
||||
@@ -460,7 +515,8 @@ export const inheritRouter = router({
|
||||
}
|
||||
|
||||
const meta = asRecord(general.meta);
|
||||
const prevList = parseJson<string[]>(typeof meta.prev_types_special2 === 'string' ? meta.prev_types_special2 : null) ?? [];
|
||||
const prevList =
|
||||
parseJson<string[]>(typeof meta.prev_types_special2 === 'string' ? meta.prev_types_special2 : null) ?? [];
|
||||
prevList.push(general.special2Code);
|
||||
|
||||
await patchGeneral(ctx, general.id, {
|
||||
@@ -473,7 +529,13 @@ export const inheritRouter = router({
|
||||
});
|
||||
|
||||
await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - cost);
|
||||
await appendInheritanceLog(ctx.db, userId, worldState.currentYear, worldState.currentMonth, `${cost} 포인트로 전투 특기 초기화`);
|
||||
await appendInheritanceLog(
|
||||
ctx.db,
|
||||
userId,
|
||||
worldState.currentYear,
|
||||
worldState.currentMonth,
|
||||
`${cost} 포인트로 전투 특기 초기화`
|
||||
);
|
||||
return { ok: true };
|
||||
}),
|
||||
resetTurnTime: authedProcedure.mutation(async ({ ctx }) => {
|
||||
@@ -624,9 +686,7 @@ export const inheritRouter = router({
|
||||
const finalBonus =
|
||||
bonusSum === 0
|
||||
? buildRandomBonus(
|
||||
new LiteHashDRBG(
|
||||
`${asRecord(worldState.meta).hiddenSeed ?? 'inherit'}:ResetStat:${userId}`
|
||||
),
|
||||
new LiteHashDRBG(`${asRecord(worldState.meta).hiddenSeed ?? 'inherit'}:ResetStat:${userId}`),
|
||||
[input.leadership, input.strength, input.intel]
|
||||
)
|
||||
: (bonus as [number, number, number]);
|
||||
@@ -674,9 +734,7 @@ export const inheritRouter = router({
|
||||
if (seasonValue !== null) {
|
||||
const userState = await readUserStateMeta(ctx.db, userId);
|
||||
const resetSeasons = readResetSeasons(userState);
|
||||
const nextSeasons = resetSeasons.includes(seasonValue)
|
||||
? resetSeasons
|
||||
: [...resetSeasons, seasonValue];
|
||||
const nextSeasons = resetSeasons.includes(seasonValue) ? resetSeasons : [...resetSeasons, seasonValue];
|
||||
await writeUserStateMeta(ctx.db, userId, {
|
||||
...userState,
|
||||
last_stat_reset: nextSeasons,
|
||||
@@ -709,7 +767,10 @@ export const inheritRouter = router({
|
||||
}
|
||||
const meta = asRecord(general.meta);
|
||||
if (meta.inheritRandomUnique !== undefined && meta.inheritRandomUnique !== null) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 구입 명령을 내렸습니다. 다음 턴까지 기다려주세요.' });
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: '이미 구입 명령을 내렸습니다. 다음 턴까지 기다려주세요.',
|
||||
});
|
||||
}
|
||||
|
||||
await patchGeneral(ctx, general.id, {
|
||||
@@ -803,7 +864,9 @@ export const inheritRouter = router({
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '자신의 정보는 확인할 수 없습니다.' });
|
||||
}
|
||||
|
||||
const ownerName = typeof asRecord(target.meta).ownerName === 'string' ? (asRecord(target.meta).ownerName as string) : target.userId;
|
||||
const rawOwnerName = asRecord(target.meta).ownerName;
|
||||
const ownerName =
|
||||
typeof rawOwnerName === 'string' && rawOwnerName.trim().length > 0 ? rawOwnerName : '알수없음';
|
||||
|
||||
await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - inheritConst.inheritCheckOwnerPoint);
|
||||
await appendInheritanceLog(
|
||||
|
||||
@@ -1,279 +1,130 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
|
||||
import type { GameApiContext } from '../../../context.js';
|
||||
import { authedProcedure } from '../../../trpc.js';
|
||||
import { getMyGeneral } from '../../shared/general.js';
|
||||
import {
|
||||
assertNationAccess,
|
||||
loadTraitNames,
|
||||
mapGeneralList,
|
||||
resolveChiefStatMin,
|
||||
resolveNationPermission,
|
||||
resolveOfficerCity,
|
||||
} from '../shared.js';
|
||||
|
||||
const MAX_DEDICATION_LEVEL = 10;
|
||||
|
||||
const readNumber = (record: Record<string, unknown>, keys: string[], fallback = 0): number => {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const experienceLevel = (experience: number): number =>
|
||||
Math.max(0, Math.min(100, experience < 1_000 ? Math.floor(experience / 100) : Math.floor(Math.sqrt(experience / 10))));
|
||||
|
||||
Math.max(
|
||||
0,
|
||||
Math.min(100, experience < 1000 ? Math.floor(experience / 100) : Math.floor(Math.sqrt(experience / 10)))
|
||||
);
|
||||
const dedicationLevel = (dedication: number): number =>
|
||||
Math.max(0, Math.min(MAX_DEDICATION_LEVEL, Math.ceil(Math.sqrt(Math.max(0, dedication)) / 10)));
|
||||
Math.max(0, Math.min(10, Math.ceil(Math.sqrt(dedication) / 10)));
|
||||
|
||||
const dedicationLevelText = (level: number): string =>
|
||||
level === 0 ? '무품관' : `${MAX_DEDICATION_LEVEL - level + 1}품관`;
|
||||
export const getGeneralList = authedProcedure.query(async ({ ctx }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
assertNationAccess(general);
|
||||
|
||||
const honorText = (experience: number): string => {
|
||||
const levels: Array<[number, string]> = [
|
||||
[640, '전무'],
|
||||
[2_560, '무명'],
|
||||
[5_760, '신동'],
|
||||
[10_240, '약간'],
|
||||
[16_000, '평범'],
|
||||
[23_040, '지역적'],
|
||||
[31_360, '전국적'],
|
||||
[40_960, '세계적'],
|
||||
[45_000, '유명'],
|
||||
[51_840, '명사'],
|
||||
[55_000, '호걸'],
|
||||
[64_000, '효웅'],
|
||||
[77_440, '영웅'],
|
||||
];
|
||||
return levels.find(([limit]) => experience < limit)?.[1] ?? '구세주';
|
||||
};
|
||||
const [nation, cityRows, troopRows, generalRows, worldState] = await Promise.all([
|
||||
ctx.db.nation.findUnique({
|
||||
where: { id: general.nationId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
level: true,
|
||||
typeCode: true,
|
||||
capitalCityId: true,
|
||||
meta: true,
|
||||
},
|
||||
}),
|
||||
ctx.db.city.findMany({ select: { id: true, name: true } }),
|
||||
ctx.db.troop.findMany({ select: { troopLeaderId: true, name: true } }),
|
||||
ctx.db.general.findMany({
|
||||
where: { nationId: general.nationId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
npcState: true,
|
||||
nationId: true,
|
||||
cityId: true,
|
||||
troopId: true,
|
||||
picture: true,
|
||||
imageServer: true,
|
||||
officerLevel: true,
|
||||
leadership: true,
|
||||
strength: true,
|
||||
intel: true,
|
||||
experience: true,
|
||||
dedication: true,
|
||||
injury: true,
|
||||
gold: true,
|
||||
rice: true,
|
||||
crew: true,
|
||||
personalCode: true,
|
||||
specialCode: true,
|
||||
special2Code: true,
|
||||
meta: true,
|
||||
penalty: true,
|
||||
},
|
||||
orderBy: { id: 'asc' },
|
||||
}),
|
||||
ctx.db.worldState.findFirst(),
|
||||
]);
|
||||
|
||||
const leadershipBonus = (officerLevel: number, nationLevel: number): number => {
|
||||
if (officerLevel === 12) return nationLevel * 2;
|
||||
if (officerLevel >= 5) return nationLevel;
|
||||
return 0;
|
||||
};
|
||||
|
||||
const woundedStat = (value: number, injury: number): number =>
|
||||
injury > 0 ? Math.floor((value * (100 - injury)) / 100) : value;
|
||||
|
||||
const defenceTrainText = (value: number): string => {
|
||||
if (value === 999) return '×';
|
||||
if (value >= 90) return '☆';
|
||||
if (value >= 80) return '◎';
|
||||
if (value >= 60) return '○';
|
||||
return '△';
|
||||
};
|
||||
|
||||
const loadNationGeneralData = async (ctx: GameApiContext) => {
|
||||
const me = await getMyGeneral(ctx);
|
||||
assertNationAccess(me);
|
||||
|
||||
const nation = await ctx.db.nation.findUnique({
|
||||
where: { id: me.nationId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
level: true,
|
||||
typeCode: true,
|
||||
capitalCityId: true,
|
||||
meta: true,
|
||||
},
|
||||
});
|
||||
if (!nation) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
|
||||
}
|
||||
const viewerPermission = resolveNationPermission(me, nation.meta, true);
|
||||
|
||||
const [cityRows, troopRows, generalRows] = await Promise.all([
|
||||
ctx.db.city.findMany({ select: { id: true, name: true } }),
|
||||
ctx.db.troop.findMany({
|
||||
where: { nationId: me.nationId },
|
||||
select: { troopLeaderId: true, name: true },
|
||||
}),
|
||||
ctx.db.general.findMany({
|
||||
where: { nationId: me.nationId },
|
||||
orderBy: [{ turnTime: 'asc' }, { id: 'asc' }],
|
||||
}),
|
||||
]);
|
||||
const generalIds = generalRows.map((general) => general.id);
|
||||
const [accessRows, turnRows] = await Promise.all([
|
||||
ctx.db.generalAccessLog.findMany({
|
||||
where: { generalId: { in: generalIds } },
|
||||
select: { generalId: true, refreshScore: true, refreshScoreTotal: true },
|
||||
}),
|
||||
viewerPermission >= 1
|
||||
? ctx.db.generalTurn.findMany({
|
||||
where: { generalId: { in: generalIds }, turnIdx: { lt: 5 } },
|
||||
select: { generalId: true, turnIdx: true, actionCode: true },
|
||||
orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }],
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
const cityNames = new Map(cityRows.map((city) => [city.id, city.name]));
|
||||
const troopNames = new Map(troopRows.map((troop) => [troop.troopLeaderId, troop.name]));
|
||||
const accessByGeneral = new Map(accessRows.map((row) => [row.generalId, row]));
|
||||
const turnsByGeneral = new Map<number, string[]>();
|
||||
for (const turn of turnRows) {
|
||||
const turns = turnsByGeneral.get(turn.generalId) ?? [];
|
||||
turns[turn.turnIdx] = turn.actionCode;
|
||||
turnsByGeneral.set(turn.generalId, turns);
|
||||
}
|
||||
|
||||
const [personalityMap, domesticMap, warMap] = await Promise.all([
|
||||
loadTraitNames(generalRows.map((general) => general.personalCode), 'personality'),
|
||||
loadTraitNames(generalRows.map((general) => general.specialCode), 'domestic'),
|
||||
loadTraitNames(generalRows.map((general) => general.special2Code), 'war'),
|
||||
]);
|
||||
|
||||
const generals = generalRows.map((general) => {
|
||||
const meta = asRecord(general.meta);
|
||||
const officerCity = resolveOfficerCity(meta);
|
||||
const access = accessByGeneral.get(general.id);
|
||||
const dedLevel = dedicationLevel(general.dedication);
|
||||
const actualOfficerLevel = general.officerLevel;
|
||||
const visibleOfficerLevel =
|
||||
viewerPermission >= 1 || actualOfficerLevel >= 5 ? actualOfficerLevel : Math.min(1, actualOfficerLevel);
|
||||
const bonus = leadershipBonus(actualOfficerLevel, nation.level);
|
||||
const detail =
|
||||
viewerPermission >= 1
|
||||
? {
|
||||
officerLevel: actualOfficerLevel,
|
||||
officerCity,
|
||||
officerCityName: officerCity > 0 ? (cityNames.get(officerCity) ?? null) : null,
|
||||
cityId: general.cityId,
|
||||
cityName: cityNames.get(general.cityId) ?? null,
|
||||
troopId: general.troopId,
|
||||
troopName: troopNames.get(general.troopId) ?? null,
|
||||
defenceTrain: readNumber(meta, ['defenceTrain', 'defence_train'], 80),
|
||||
crewTypeId: general.crewTypeId,
|
||||
crew: general.crew,
|
||||
train: general.train,
|
||||
atmos: general.atmos,
|
||||
experience: general.experience,
|
||||
dedication: general.dedication,
|
||||
turnTime: general.turnTime.toISOString(),
|
||||
recentWarTime: general.recentWarTime?.toISOString() ?? null,
|
||||
killTurn: readNumber(meta, ['killturn', 'killTurn']),
|
||||
refreshScore: access?.refreshScore ?? 0,
|
||||
reservedCommands: general.npcState < 2 ? (turnsByGeneral.get(general.id) ?? []) : [],
|
||||
}
|
||||
: null;
|
||||
|
||||
const cityNameMap = new Map(cityRows.map((city) => [city.id, city.name]));
|
||||
const troopNameMap = new Map(troopRows.map((troop) => [troop.troopLeaderId, troop.name]));
|
||||
const list = await mapGeneralList(generalRows, cityNameMap, troopNameMap);
|
||||
const accessRows = generalRows.length
|
||||
? await ctx.db.generalAccessLog.findMany({
|
||||
where: { generalId: { in: generalRows.map((entry) => entry.id) } },
|
||||
select: { generalId: true, refreshScoreTotal: true },
|
||||
})
|
||||
: [];
|
||||
const accessByGeneral = new Map(accessRows.map((entry) => [entry.generalId, entry.refreshScoreTotal]));
|
||||
const nationTrait = (await loadTraitNames([nation.typeCode], 'nation')).get(nation.typeCode);
|
||||
const permission = resolveNationPermission(general, nation.meta, true);
|
||||
const visibleList = list.map((entry) => {
|
||||
const { permission: _targetPermission, ...safeEntry } = entry;
|
||||
if (permission >= 1) {
|
||||
return {
|
||||
...safeEntry,
|
||||
refreshScoreTotal: accessByGeneral.get(entry.id) ?? 0,
|
||||
experienceLevel: experienceLevel(entry.experience),
|
||||
dedicationLevel: dedicationLevel(entry.dedication),
|
||||
};
|
||||
}
|
||||
const { crew: _crew, experience: _experience, dedication: _dedication, ...visible } = safeEntry;
|
||||
return {
|
||||
id: general.id,
|
||||
name: general.name,
|
||||
npcState: general.npcState,
|
||||
picture: general.picture,
|
||||
imageServer: general.imageServer,
|
||||
injury: general.injury,
|
||||
stats: {
|
||||
leadership: woundedStat(general.leadership, general.injury),
|
||||
strength: woundedStat(general.strength, general.injury),
|
||||
intelligence: woundedStat(general.intel, general.injury),
|
||||
},
|
||||
leadershipBonus: bonus,
|
||||
officerLevel: visibleOfficerLevel,
|
||||
experienceLevel: experienceLevel(general.experience),
|
||||
honorText: honorText(general.experience),
|
||||
dedicationLevel: dedLevel,
|
||||
dedicationLevelText: dedicationLevelText(dedLevel),
|
||||
bill: dedLevel * 200 + 400,
|
||||
gold: general.gold,
|
||||
rice: general.rice,
|
||||
age: general.age,
|
||||
belong: readNumber(meta, ['belong']),
|
||||
refreshScoreTotal: access?.refreshScoreTotal ?? 0,
|
||||
personality: general.personalCode === 'None' ? null : (personalityMap.get(general.personalCode) ?? null),
|
||||
specialDomestic:
|
||||
general.specialCode === 'None' ? null : (domesticMap.get(general.specialCode) ?? null),
|
||||
specialWar: general.special2Code === 'None' ? null : (warMap.get(general.special2Code) ?? null),
|
||||
detail,
|
||||
...visible,
|
||||
refreshScoreTotal: accessByGeneral.get(entry.id) ?? 0,
|
||||
officerLevel: entry.officerLevel >= 5 ? entry.officerLevel : Math.min(1, entry.officerLevel),
|
||||
cityName: null,
|
||||
troopName: null,
|
||||
officerCity: 0,
|
||||
officerCityName: null,
|
||||
experienceLevel: experienceLevel(entry.experience),
|
||||
dedicationLevel: dedicationLevel(entry.dedication),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
me,
|
||||
nation: {
|
||||
id: nation.id,
|
||||
name: nation.name,
|
||||
color: nation.color,
|
||||
level: nation.level,
|
||||
typeCode: nation.typeCode,
|
||||
type: {
|
||||
key: nation.typeCode,
|
||||
name: nationTrait?.name ?? nation.typeCode,
|
||||
info: nationTrait?.info ?? '',
|
||||
},
|
||||
capitalCityId: nation.capitalCityId ?? 0,
|
||||
},
|
||||
viewerPermission,
|
||||
generals,
|
||||
};
|
||||
};
|
||||
|
||||
export const getGeneralList = authedProcedure.query(async ({ ctx }) => {
|
||||
const data = await loadNationGeneralData(ctx);
|
||||
return {
|
||||
nation: data.nation,
|
||||
viewer: { generalId: data.me.id, permission: data.viewerPermission },
|
||||
generals: data.generals,
|
||||
};
|
||||
});
|
||||
|
||||
export const getSecretGeneralList = authedProcedure.query(async ({ ctx }) => {
|
||||
const data = await loadNationGeneralData(ctx);
|
||||
if (data.viewerPermission < 1) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: '권한이 부족합니다. 수뇌부가 아니거나 사관년도가 부족합니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const visibleGenerals = data.generals.filter((general) => general.npcState !== 5);
|
||||
const summaryBase = visibleGenerals.reduce(
|
||||
(summary, general) => {
|
||||
const detail = general.detail;
|
||||
if (!detail) return summary;
|
||||
summary.gold += general.gold;
|
||||
summary.rice += general.rice;
|
||||
summary.crew += detail.crew;
|
||||
if (detail.crew > 0) {
|
||||
for (const threshold of [90, 80, 60] as const) {
|
||||
if (detail.train >= threshold && detail.atmos >= threshold) {
|
||||
summary.readiness[threshold].crew += detail.crew;
|
||||
summary.readiness[threshold].generals += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return summary;
|
||||
},
|
||||
{
|
||||
gold: 0,
|
||||
rice: 0,
|
||||
crew: 0,
|
||||
readiness: {
|
||||
90: { crew: 0, generals: 0 },
|
||||
80: { crew: 0, generals: 0 },
|
||||
60: { crew: 0, generals: 0 },
|
||||
},
|
||||
}
|
||||
);
|
||||
const generalCount = visibleGenerals.length;
|
||||
|
||||
return {
|
||||
nation: data.nation,
|
||||
viewer: { generalId: data.me.id, permission: data.viewerPermission },
|
||||
summary: {
|
||||
...summaryBase,
|
||||
generalCount,
|
||||
averageGold: generalCount ? summaryBase.gold / generalCount : 0,
|
||||
averageRice: generalCount ? summaryBase.rice / generalCount : 0,
|
||||
},
|
||||
generals: data.generals.map((general) => ({
|
||||
...general,
|
||||
defenceTrainText: defenceTrainText(general.detail?.defenceTrain ?? 0),
|
||||
})),
|
||||
chiefStatMin: resolveChiefStatMin(worldState),
|
||||
viewer: { generalId: general.id, permission },
|
||||
generals: visibleList,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
|
||||
import { authedProcedure } from '../../../trpc.js';
|
||||
import { getMyGeneral } from '../../shared/general.js';
|
||||
import { assertNationAccess, resolveNationPermission } from '../shared.js';
|
||||
|
||||
const readNumber = (record: Record<string, unknown>, keys: string[], fallback = 0): number => {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
const woundedStat = (value: number, injury: number): number =>
|
||||
injury > 0 ? Math.floor((value * (100 - injury)) / 100) : value;
|
||||
const experienceLevel = (experience: number): number =>
|
||||
Math.max(
|
||||
0,
|
||||
Math.min(100, experience < 1000 ? Math.floor(experience / 100) : Math.floor(Math.sqrt(experience / 10)))
|
||||
);
|
||||
const leadershipBonus = (officerLevel: number, nationLevel: number): number =>
|
||||
officerLevel === 12 ? nationLevel * 2 : officerLevel >= 5 ? nationLevel : 0;
|
||||
const defenceTrainText = (value: number): string =>
|
||||
value === 999 ? '×' : value >= 90 ? '☆' : value >= 80 ? '◎' : value >= 60 ? '○' : '△';
|
||||
|
||||
export const getSecretGeneralList = authedProcedure.query(async ({ ctx }) => {
|
||||
const me = await getMyGeneral(ctx);
|
||||
assertNationAccess(me);
|
||||
const nation = await ctx.db.nation.findUnique({
|
||||
where: { id: me.nationId },
|
||||
select: { id: true, name: true, color: true, level: true, meta: true },
|
||||
});
|
||||
if (!nation) throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
|
||||
const permission = resolveNationPermission(me, nation.meta, true);
|
||||
if (permission < 1) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: '권한이 부족합니다. 수뇌부가 아니거나 사관년도가 부족합니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const [cities, troops, generalRows] = await Promise.all([
|
||||
ctx.db.city.findMany({ select: { id: true, name: true } }),
|
||||
ctx.db.troop.findMany({
|
||||
where: { nationId: me.nationId },
|
||||
select: { troopLeaderId: true, name: true },
|
||||
}),
|
||||
ctx.db.general.findMany({
|
||||
where: { nationId: me.nationId },
|
||||
orderBy: [{ turnTime: 'asc' }, { id: 'asc' }],
|
||||
}),
|
||||
]);
|
||||
const generalIds = generalRows.map((general) => general.id);
|
||||
const turns = generalIds.length
|
||||
? await ctx.db.generalTurn.findMany({
|
||||
where: { generalId: { in: generalIds }, turnIdx: { lt: 5 } },
|
||||
select: { generalId: true, turnIdx: true, actionCode: true },
|
||||
orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }],
|
||||
})
|
||||
: [];
|
||||
const cityNames = new Map(cities.map((city) => [city.id, city.name]));
|
||||
const troopNames = new Map(troops.map((troop) => [troop.troopLeaderId, troop.name]));
|
||||
const turnMap = new Map<number, string[]>();
|
||||
for (const turn of turns) {
|
||||
const list = turnMap.get(turn.generalId) ?? [];
|
||||
list[turn.turnIdx] = turn.actionCode;
|
||||
turnMap.set(turn.generalId, list);
|
||||
}
|
||||
const generals = generalRows.map((general) => {
|
||||
const meta = asRecord(general.meta);
|
||||
const defenceTrain = readNumber(meta, ['defenceTrain', 'defence_train'], 80);
|
||||
return {
|
||||
id: general.id,
|
||||
name: general.name,
|
||||
npcState: general.npcState,
|
||||
injury: general.injury,
|
||||
stats: {
|
||||
leadership: woundedStat(general.leadership, general.injury),
|
||||
strength: woundedStat(general.strength, general.injury),
|
||||
intelligence: woundedStat(general.intel, general.injury),
|
||||
},
|
||||
leadershipBonus: leadershipBonus(general.officerLevel, nation.level),
|
||||
experienceLevel: experienceLevel(general.experience),
|
||||
troopId: general.troopId,
|
||||
troopName: troopNames.get(general.troopId) ?? null,
|
||||
gold: general.gold,
|
||||
rice: general.rice,
|
||||
cityId: general.cityId,
|
||||
cityName: cityNames.get(general.cityId) ?? null,
|
||||
defenceTrain,
|
||||
defenceTrainText: defenceTrainText(defenceTrain),
|
||||
crewTypeId: general.crewTypeId,
|
||||
crew: general.crew,
|
||||
train: general.train,
|
||||
atmos: general.atmos,
|
||||
killTurn: readNumber(meta, ['killturn', 'killTurn']),
|
||||
turnTime: general.turnTime.toISOString(),
|
||||
reservedCommands: general.npcState < 2 ? (turnMap.get(general.id) ?? []) : [],
|
||||
};
|
||||
});
|
||||
const counted = generals.filter((general) => general.npcState !== 5);
|
||||
const summary = counted.reduce(
|
||||
(result, general) => {
|
||||
result.gold += general.gold;
|
||||
result.rice += general.rice;
|
||||
result.crew += general.crew;
|
||||
if (general.crew > 0) {
|
||||
for (const threshold of [90, 80, 60] as const) {
|
||||
if (general.train >= threshold && general.atmos >= threshold) {
|
||||
result.readiness[threshold].crew += general.crew;
|
||||
result.readiness[threshold].generals += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
},
|
||||
{
|
||||
gold: 0,
|
||||
rice: 0,
|
||||
crew: 0,
|
||||
readiness: {
|
||||
90: { crew: 0, generals: 0 },
|
||||
80: { crew: 0, generals: 0 },
|
||||
60: { crew: 0, generals: 0 },
|
||||
},
|
||||
}
|
||||
);
|
||||
return {
|
||||
nation: { id: nation.id, name: nation.name, color: nation.color, level: nation.level },
|
||||
viewer: { generalId: me.id, permission },
|
||||
summary: {
|
||||
...summary,
|
||||
generalCount: counted.length,
|
||||
averageGold: counted.length ? summary.gold / counted.length : 0,
|
||||
averageRice: counted.length ? summary.rice / counted.length : 0,
|
||||
},
|
||||
generals,
|
||||
};
|
||||
});
|
||||
@@ -4,7 +4,8 @@ import { changePermission } from './endpoints/changePermission.js';
|
||||
import { getBattleCenter } from './endpoints/getBattleCenter.js';
|
||||
import { getChiefCenter } from './endpoints/getChiefCenter.js';
|
||||
import { getCityOverview } from './endpoints/getCityOverview.js';
|
||||
import { getGeneralList, getSecretGeneralList } from './endpoints/getGeneralList.js';
|
||||
import { getGeneralList } from './endpoints/getGeneralList.js';
|
||||
import { getSecretGeneralList } from './endpoints/getSecretGeneralList.js';
|
||||
import { getGeneralLog } from './endpoints/getGeneralLog.js';
|
||||
import { getNationInfo } from './endpoints/getNationInfo.js';
|
||||
import { getPersonnelInfo } from './endpoints/getPersonnelInfo.js';
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -165,8 +163,6 @@ const FLOAT_POLICY_KEYS = ['safeRecruitCityPopulationRatio'] as const;
|
||||
type NumericPolicyKey = (typeof INTEGER_POLICY_KEYS)[number];
|
||||
type FloatPolicyKey = (typeof FLOAT_POLICY_KEYS)[number];
|
||||
|
||||
const UNIT_SET_ROOT = path.resolve(process.cwd(), 'resources', 'unitset');
|
||||
|
||||
const readNumber = (value: unknown, fallback = 0): number => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
@@ -353,8 +349,8 @@ const buildZeroPolicy = async (
|
||||
}
|
||||
): Promise<NationPolicy> => {
|
||||
const { statMax, statNpcMax, nationTech, develCost, defaultCrewTypeId, unitSetName } = options;
|
||||
const unitSet = await loadUnitSetDefinitionByName(unitSetName, { unitSetRoot: UNIT_SET_ROOT });
|
||||
const crewType = findCrewTypeById(unitSet, defaultCrewTypeId);
|
||||
const unitSet = await loadUnitSetDefinitionByName(unitSetName);
|
||||
const crewType = findCrewTypeById(unitSet, defaultCrewTypeId || unitSet.defaultCrewTypeId || 0);
|
||||
const techCost = getTechCost(nationTech);
|
||||
const next = clonePolicy(policy);
|
||||
|
||||
@@ -364,7 +360,7 @@ const buildZeroPolicy = async (
|
||||
|
||||
if (next.reqNPCWarGold === 0 || next.reqNPCWarRice === 0) {
|
||||
const baseGold = crewType ? crewType.cost * techCost * statNpcMax : 0;
|
||||
const baseRice = statNpcMax;
|
||||
const baseRice = crewType ? crewType.rice * techCost * statNpcMax : 0;
|
||||
if (next.reqNPCWarGold === 0) {
|
||||
next.reqNPCWarGold = roundTo(baseGold * 4, -2);
|
||||
}
|
||||
@@ -375,7 +371,7 @@ const buildZeroPolicy = async (
|
||||
|
||||
if (next.reqHumanWarUrgentGold === 0 || next.reqHumanWarUrgentRice === 0) {
|
||||
const baseGold = crewType ? crewType.cost * techCost * statMax : 0;
|
||||
const baseRice = statMax;
|
||||
const baseRice = crewType ? crewType.rice * techCost * statMax : 0;
|
||||
if (next.reqHumanWarUrgentGold === 0) {
|
||||
next.reqHumanWarUrgentGold = roundTo(baseGold * 6, -2);
|
||||
}
|
||||
@@ -415,8 +411,6 @@ const resolveSetterInfo = (policy: Record<string, unknown>, kind: 'value' | 'pri
|
||||
};
|
||||
};
|
||||
|
||||
const ensureUniquePriority = (priority: string[]): string[] => Array.from(new Set(priority));
|
||||
|
||||
const validateGeneralPriority = (priority: string[]): string | null => {
|
||||
const orderRequired: Array<[string, string]> = [['출병', '일반내정']];
|
||||
const mustHave = new Set(['출병', '일반내정']);
|
||||
@@ -461,6 +455,7 @@ export const npcRouter = router({
|
||||
id: true,
|
||||
name: true,
|
||||
level: true,
|
||||
tech: true,
|
||||
meta: true,
|
||||
},
|
||||
}),
|
||||
@@ -508,9 +503,9 @@ export const npcRouter = router({
|
||||
const stat = resolveScenarioStat(config);
|
||||
const env = resolveCommandEnv(config);
|
||||
const unitSetName = resolveUnitSetName(config, 'che');
|
||||
const nationTech = readNumber(asRecord(nationMeta).tech, 0);
|
||||
const nationTech = readNumber(nation.tech, 0);
|
||||
|
||||
const zeroPolicy = await buildZeroPolicy(defaultNationPolicy, {
|
||||
const zeroPolicy = await buildZeroPolicy(DEFAULT_NATION_POLICY, {
|
||||
statMax: stat.max,
|
||||
statNpcMax: stat.npcMax,
|
||||
nationTech,
|
||||
@@ -542,277 +537,272 @@ export const npcRouter = router({
|
||||
permissionLevel,
|
||||
};
|
||||
}),
|
||||
setNationPolicy: authedProcedure
|
||||
.input(z.record(z.string(), z.unknown()))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
if (general.nationId <= 0) {
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' });
|
||||
}
|
||||
setNationPolicy: authedProcedure.input(z.record(z.string(), z.unknown())).mutation(async ({ ctx, input }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
if (general.nationId <= 0) {
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' });
|
||||
}
|
||||
|
||||
const nation = await ctx.db.nation.findUnique({
|
||||
where: { id: general.nationId },
|
||||
select: { id: true, meta: true },
|
||||
});
|
||||
if (!nation) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
|
||||
}
|
||||
const nation = await ctx.db.nation.findUnique({
|
||||
where: { id: general.nationId },
|
||||
select: { id: true, meta: true },
|
||||
});
|
||||
if (!nation) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
|
||||
}
|
||||
|
||||
const permissionLevel = resolveSecretPermission(
|
||||
{
|
||||
nationId: general.nationId,
|
||||
officerLevel: general.officerLevel,
|
||||
meta: general.meta,
|
||||
penalty: general.penalty,
|
||||
},
|
||||
nation.meta
|
||||
);
|
||||
if (permissionLevel < 3) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
|
||||
}
|
||||
const permissionLevel = resolveSecretPermission(
|
||||
{
|
||||
nationId: general.nationId,
|
||||
officerLevel: general.officerLevel,
|
||||
meta: general.meta,
|
||||
penalty: general.penalty,
|
||||
},
|
||||
nation.meta
|
||||
);
|
||||
if (permissionLevel < 3) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
|
||||
}
|
||||
|
||||
const keys = Object.keys(input);
|
||||
for (const key of keys) {
|
||||
if (!NATION_POLICY_KEYS.has(key as keyof NationPolicy)) {
|
||||
const keys = Object.keys(input);
|
||||
for (const key of keys) {
|
||||
if (!NATION_POLICY_KEYS.has(key as keyof NationPolicy)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 정책값이 아닙니다.` });
|
||||
}
|
||||
}
|
||||
|
||||
const troopRows = await ctx.db.troop.findMany({
|
||||
where: { nationId: general.nationId },
|
||||
select: { troopLeaderId: true },
|
||||
});
|
||||
const cityRows = await ctx.db.city.findMany({ select: { id: true } });
|
||||
|
||||
const troopSet = new Set(troopRows.map((row) => row.troopLeaderId));
|
||||
const citySet = new Set(cityRows.map((row) => row.id));
|
||||
const assigned = new Set<number>();
|
||||
|
||||
const nationMeta = asRecord(nation.meta);
|
||||
const policyRoot = asRecord(nationMeta.npc_nation_policy);
|
||||
const nextValues = applyPolicyValues(DEFAULT_NATION_POLICY, asRecord(policyRoot.values));
|
||||
|
||||
for (const key of INTEGER_POLICY_KEYS) {
|
||||
if (!(key in input)) {
|
||||
continue;
|
||||
}
|
||||
const value = input[key];
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isInteger(value)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 값이 아닙니다.` });
|
||||
}
|
||||
nextValues[key] = Math.max(0, value);
|
||||
}
|
||||
|
||||
for (const key of FLOAT_POLICY_KEYS) {
|
||||
if (!(key in input)) {
|
||||
continue;
|
||||
}
|
||||
const value = input[key];
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 값이 아닙니다.` });
|
||||
}
|
||||
nextValues[key] = value;
|
||||
}
|
||||
|
||||
if ('CombatForce' in input) {
|
||||
const rawCombat = input.CombatForce;
|
||||
if (!isRecord(rawCombat)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'CombatForce는 올바른 정책값이 아닙니다.' });
|
||||
}
|
||||
const combatForce: Record<number, [number, number]> = {};
|
||||
for (const [rawKey, rawValue] of Object.entries(rawCombat)) {
|
||||
const leaderId = Number(rawKey);
|
||||
if (!Number.isFinite(leaderId)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `${rawKey}는 올바른 부대가 아닙니다.` });
|
||||
}
|
||||
if (!troopSet.has(leaderId)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `${leaderId}는 국가의 부대가 아닙니다.` });
|
||||
}
|
||||
if (assigned.has(leaderId)) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `부대(${leaderId})는 하나의 역할만 지정할 수 있습니다.`,
|
||||
});
|
||||
}
|
||||
if (!Array.isArray(rawValue) || rawValue.length < 2) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `${leaderId}의 입력양식이 올바르지 않습니다.`,
|
||||
});
|
||||
}
|
||||
const fromCity = Number(rawValue[0]);
|
||||
const toCity = Number(rawValue[1]);
|
||||
if (!citySet.has(fromCity) || !citySet.has(toCity)) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `${leaderId}의 도시 ${fromCity}, ${toCity}가 올바른 도시 번호가 아닙니다.`,
|
||||
});
|
||||
}
|
||||
combatForce[leaderId] = [fromCity, toCity];
|
||||
assigned.add(leaderId);
|
||||
}
|
||||
nextValues.CombatForce = combatForce;
|
||||
}
|
||||
|
||||
for (const key of ['SupportForce', 'DevelopForce'] as const) {
|
||||
if (!(key in input)) {
|
||||
continue;
|
||||
}
|
||||
const rawList = input[key];
|
||||
if (!Array.isArray(rawList)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 정책값이 아닙니다.` });
|
||||
}
|
||||
const list: number[] = [];
|
||||
for (const rawValue of rawList) {
|
||||
if (typeof rawValue !== 'number' || !Number.isFinite(rawValue)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 정책값이 아닙니다.` });
|
||||
}
|
||||
}
|
||||
|
||||
const troopRows = await ctx.db.troop.findMany({
|
||||
where: { nationId: general.nationId },
|
||||
select: { troopLeaderId: true },
|
||||
});
|
||||
const cityRows = await ctx.db.city.findMany({ select: { id: true } });
|
||||
|
||||
const troopSet = new Set(troopRows.map((row) => row.troopLeaderId));
|
||||
const citySet = new Set(cityRows.map((row) => row.id));
|
||||
const assigned = new Set<number>();
|
||||
|
||||
const nationMeta = asRecord(nation.meta);
|
||||
const policyRoot = asRecord(nationMeta.npc_nation_policy);
|
||||
const nextValues = applyPolicyValues(DEFAULT_NATION_POLICY, asRecord(policyRoot.values));
|
||||
|
||||
for (const key of INTEGER_POLICY_KEYS) {
|
||||
if (!(key in input)) {
|
||||
continue;
|
||||
if (!troopSet.has(rawValue)) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `${rawValue}는 국가의 부대가 아닙니다.`,
|
||||
});
|
||||
}
|
||||
const value = input[key];
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isInteger(value)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 값이 아닙니다.` });
|
||||
if (assigned.has(rawValue)) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `부대(${rawValue})는 하나의 역할만 지정할 수 있습니다.`,
|
||||
});
|
||||
}
|
||||
nextValues[key] = Math.max(0, value);
|
||||
assigned.add(rawValue);
|
||||
list.push(rawValue);
|
||||
}
|
||||
|
||||
for (const key of FLOAT_POLICY_KEYS) {
|
||||
if (!(key in input)) {
|
||||
continue;
|
||||
}
|
||||
const value = input[key];
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 값이 아닙니다.` });
|
||||
}
|
||||
nextValues[key] = Math.max(0, value);
|
||||
if (key === 'SupportForce') {
|
||||
nextValues.SupportForce = list;
|
||||
} else {
|
||||
nextValues.DevelopForce = list;
|
||||
}
|
||||
}
|
||||
|
||||
if ('CombatForce' in input) {
|
||||
const rawCombat = input.CombatForce;
|
||||
if (!isRecord(rawCombat)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'CombatForce는 올바른 정책값이 아닙니다.' });
|
||||
}
|
||||
const combatForce: Record<number, [number, number]> = {};
|
||||
for (const [rawKey, rawValue] of Object.entries(rawCombat)) {
|
||||
const leaderId = Number(rawKey);
|
||||
if (!Number.isFinite(leaderId)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `${rawKey}는 올바른 부대가 아닙니다.` });
|
||||
}
|
||||
if (!troopSet.has(leaderId)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `${leaderId}는 국가의 부대가 아닙니다.` });
|
||||
}
|
||||
if (assigned.has(leaderId)) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `부대(${leaderId})는 하나의 역할만 지정할 수 있습니다.`,
|
||||
});
|
||||
}
|
||||
if (!Array.isArray(rawValue) || rawValue.length < 2) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `${leaderId}의 입력양식이 올바르지 않습니다.` });
|
||||
}
|
||||
const fromCity = Number(rawValue[0]);
|
||||
const toCity = Number(rawValue[1]);
|
||||
if (!citySet.has(fromCity) || !citySet.has(toCity)) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `${leaderId}의 도시 ${fromCity}, ${toCity}가 올바른 도시 번호가 아닙니다.`,
|
||||
});
|
||||
}
|
||||
combatForce[leaderId] = [fromCity, toCity];
|
||||
assigned.add(leaderId);
|
||||
}
|
||||
nextValues.CombatForce = combatForce;
|
||||
const nextPolicyRoot = {
|
||||
...policyRoot,
|
||||
values: nextValues,
|
||||
valueSetter: general.name,
|
||||
valueSetTime: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await updateNationMeta(
|
||||
ctx,
|
||||
nation.id,
|
||||
{
|
||||
npc_nation_policy: nextPolicyRoot,
|
||||
},
|
||||
nationMeta
|
||||
);
|
||||
|
||||
return { ok: true };
|
||||
}),
|
||||
setNationPriority: authedProcedure.input(z.array(z.string())).mutation(async ({ ctx, input }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
if (general.nationId <= 0) {
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' });
|
||||
}
|
||||
|
||||
const nation = await ctx.db.nation.findUnique({
|
||||
where: { id: general.nationId },
|
||||
select: { id: true, meta: true },
|
||||
});
|
||||
if (!nation) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
|
||||
}
|
||||
|
||||
const permissionLevel = resolveSecretPermission(
|
||||
{
|
||||
nationId: general.nationId,
|
||||
officerLevel: general.officerLevel,
|
||||
meta: general.meta,
|
||||
penalty: general.penalty,
|
||||
},
|
||||
nation.meta
|
||||
);
|
||||
if (permissionLevel < 3) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
|
||||
}
|
||||
|
||||
for (const item of input) {
|
||||
if (!DEFAULT_NATION_PRIORITY.includes(item as (typeof DEFAULT_NATION_PRIORITY)[number])) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `${item}은 올바른 명령이 아닙니다.` });
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of ['SupportForce', 'DevelopForce'] as const) {
|
||||
if (!(key in input)) {
|
||||
continue;
|
||||
}
|
||||
const rawList = input[key];
|
||||
if (!Array.isArray(rawList)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 정책값이 아닙니다.` });
|
||||
}
|
||||
const list: number[] = [];
|
||||
for (const rawValue of rawList) {
|
||||
if (typeof rawValue !== 'number' || !Number.isFinite(rawValue)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 정책값이 아닙니다.` });
|
||||
}
|
||||
if (!troopSet.has(rawValue)) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `${rawValue}는 국가의 부대가 아닙니다.`,
|
||||
});
|
||||
}
|
||||
if (assigned.has(rawValue)) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `부대(${rawValue})는 하나의 역할만 지정할 수 있습니다.`,
|
||||
});
|
||||
}
|
||||
assigned.add(rawValue);
|
||||
list.push(rawValue);
|
||||
}
|
||||
if (key === 'SupportForce') {
|
||||
nextValues.SupportForce = list;
|
||||
} else {
|
||||
nextValues.DevelopForce = list;
|
||||
}
|
||||
}
|
||||
const nationMeta = asRecord(nation.meta);
|
||||
const policyRoot = asRecord(nationMeta.npc_nation_policy);
|
||||
const nextPolicyRoot = {
|
||||
...policyRoot,
|
||||
priority: input,
|
||||
prioritySetter: general.name,
|
||||
prioritySetTime: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const nextPolicyRoot = {
|
||||
...policyRoot,
|
||||
values: nextValues,
|
||||
valueSetter: general.name,
|
||||
valueSetTime: new Date().toISOString(),
|
||||
};
|
||||
await updateNationMeta(
|
||||
ctx,
|
||||
nation.id,
|
||||
{
|
||||
npc_nation_policy: nextPolicyRoot,
|
||||
},
|
||||
nationMeta
|
||||
);
|
||||
|
||||
await updateNationMeta(
|
||||
ctx,
|
||||
nation.id,
|
||||
{
|
||||
npc_nation_policy: nextPolicyRoot,
|
||||
},
|
||||
nationMeta
|
||||
);
|
||||
return { ok: true };
|
||||
}),
|
||||
setGeneralPriority: authedProcedure.input(z.array(z.string())).mutation(async ({ ctx, input }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
if (general.nationId <= 0) {
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' });
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}),
|
||||
setNationPriority: authedProcedure
|
||||
.input(z.array(z.string()))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
if (general.nationId <= 0) {
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' });
|
||||
}
|
||||
const nation = await ctx.db.nation.findUnique({
|
||||
where: { id: general.nationId },
|
||||
select: { id: true, meta: true },
|
||||
});
|
||||
if (!nation) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
|
||||
}
|
||||
|
||||
const nation = await ctx.db.nation.findUnique({
|
||||
where: { id: general.nationId },
|
||||
select: { id: true, meta: true },
|
||||
});
|
||||
if (!nation) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
|
||||
}
|
||||
const permissionLevel = resolveSecretPermission(
|
||||
{
|
||||
nationId: general.nationId,
|
||||
officerLevel: general.officerLevel,
|
||||
meta: general.meta,
|
||||
penalty: general.penalty,
|
||||
},
|
||||
nation.meta
|
||||
);
|
||||
if (permissionLevel < 3) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
|
||||
}
|
||||
|
||||
const permissionLevel = resolveSecretPermission(
|
||||
{
|
||||
nationId: general.nationId,
|
||||
officerLevel: general.officerLevel,
|
||||
meta: general.meta,
|
||||
penalty: general.penalty,
|
||||
},
|
||||
nation.meta
|
||||
);
|
||||
if (permissionLevel < 3) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
|
||||
}
|
||||
const validationError = validateGeneralPriority(input);
|
||||
if (validationError) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: validationError });
|
||||
}
|
||||
|
||||
const unique = ensureUniquePriority(input);
|
||||
for (const item of unique) {
|
||||
if (!DEFAULT_NATION_PRIORITY.includes(item as (typeof DEFAULT_NATION_PRIORITY)[number])) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `${item}은 올바른 명령이 아닙니다.` });
|
||||
}
|
||||
}
|
||||
const nationMeta = asRecord(nation.meta);
|
||||
const policyRoot = asRecord(nationMeta.npc_general_policy);
|
||||
const nextPolicyRoot = {
|
||||
...policyRoot,
|
||||
priority: input,
|
||||
prioritySetter: general.name,
|
||||
prioritySetTime: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const nationMeta = asRecord(nation.meta);
|
||||
const policyRoot = asRecord(nationMeta.npc_nation_policy);
|
||||
const nextPolicyRoot = {
|
||||
...policyRoot,
|
||||
priority: unique,
|
||||
prioritySetter: general.name,
|
||||
prioritySetTime: new Date().toISOString(),
|
||||
};
|
||||
await updateNationMeta(
|
||||
ctx,
|
||||
nation.id,
|
||||
{
|
||||
npc_general_policy: nextPolicyRoot,
|
||||
},
|
||||
nationMeta
|
||||
);
|
||||
|
||||
await updateNationMeta(
|
||||
ctx,
|
||||
nation.id,
|
||||
{
|
||||
npc_nation_policy: nextPolicyRoot,
|
||||
},
|
||||
nationMeta
|
||||
);
|
||||
|
||||
return { ok: true };
|
||||
}),
|
||||
setGeneralPriority: authedProcedure
|
||||
.input(z.array(z.string()))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
if (general.nationId <= 0) {
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' });
|
||||
}
|
||||
|
||||
const nation = await ctx.db.nation.findUnique({
|
||||
where: { id: general.nationId },
|
||||
select: { id: true, meta: true },
|
||||
});
|
||||
if (!nation) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
|
||||
}
|
||||
|
||||
const permissionLevel = resolveSecretPermission(
|
||||
{
|
||||
nationId: general.nationId,
|
||||
officerLevel: general.officerLevel,
|
||||
meta: general.meta,
|
||||
penalty: general.penalty,
|
||||
},
|
||||
nation.meta
|
||||
);
|
||||
if (permissionLevel < 3) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
|
||||
}
|
||||
|
||||
const unique = ensureUniquePriority(input);
|
||||
const validationError = validateGeneralPriority(unique);
|
||||
if (validationError) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: validationError });
|
||||
}
|
||||
|
||||
const nationMeta = asRecord(nation.meta);
|
||||
const policyRoot = asRecord(nationMeta.npc_general_policy);
|
||||
const nextPolicyRoot = {
|
||||
...policyRoot,
|
||||
priority: unique,
|
||||
prioritySetter: general.name,
|
||||
prioritySetTime: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await updateNationMeta(
|
||||
ctx,
|
||||
nation.id,
|
||||
{
|
||||
npc_general_policy: nextPolicyRoot,
|
||||
},
|
||||
nationMeta
|
||||
);
|
||||
|
||||
return { ok: true };
|
||||
}),
|
||||
return { ok: true };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import { authedProcedure } from '../../trpc.js';
|
||||
import { asRecord, isRecord } from '@sammo-ts/common';
|
||||
import { loadWorldMap } from '../../maps/worldMap.js';
|
||||
import { loadMapLayout } from '../../maps/mapLayout.js';
|
||||
import { loadUnitSetDefinitionByName } from '../../battleSim/unitSetLoader.js';
|
||||
import { getMyGeneral, getOwnedGeneral } from '../shared/general.js';
|
||||
|
||||
const isWorldAdmin = (roles: readonly string[]): boolean =>
|
||||
@@ -33,6 +34,29 @@ const defenceTrain = (meta: unknown): number => {
|
||||
return typeof raw === 'number' && Number.isFinite(raw) ? raw : 0;
|
||||
};
|
||||
|
||||
const unitSetName = (world: WorldStateRow | null, fallback: string): string => {
|
||||
const config = asRecord(world?.config);
|
||||
const environment = asRecord(config.environment ?? config.map);
|
||||
return typeof environment.unitSet === 'string' && environment.unitSet.trim() ? environment.unitSet : fallback;
|
||||
};
|
||||
|
||||
const crewTypeNameCache = new Map<string, Promise<Map<number, string>>>();
|
||||
const loadCrewTypeNames = (name: string): Promise<Map<number, string>> => {
|
||||
const cached = crewTypeNameCache.get(name);
|
||||
if (cached) return cached;
|
||||
const pending = loadUnitSetDefinitionByName(name)
|
||||
.then((definition) => new Map((definition.crewTypes ?? []).map((crewType) => [crewType.id, crewType.name])))
|
||||
.catch(() => new Map<number, string>());
|
||||
crewTypeNameCache.set(name, pending);
|
||||
return pending;
|
||||
};
|
||||
|
||||
const leadershipBonus = (officerLevel: number, nationLevel: number): number => {
|
||||
if (officerLevel === 12) return nationLevel * 2;
|
||||
if (officerLevel >= 5) return nationLevel;
|
||||
return 0;
|
||||
};
|
||||
|
||||
const toWorldStateSnapshot = (row: WorldStateRow) => ({
|
||||
scenarioCode: row.scenarioCode,
|
||||
currentYear: row.currentYear,
|
||||
@@ -122,6 +146,8 @@ export const worldRouter = router({
|
||||
if (me.officerLevel > 0 && me.nationId > 0) {
|
||||
cities.filter((city) => city.nationId === me.nationId).forEach((city) => selectable.add(city.id));
|
||||
nationGenerals.forEach((general) => selectable.add(general.cityId));
|
||||
}
|
||||
if ((nation?.level ?? 0) > 0) {
|
||||
Object.keys(spy).forEach((id) => selectable.add(Number(id)));
|
||||
}
|
||||
if (admin) cities.forEach((city) => selectable.add(city.id));
|
||||
@@ -150,6 +176,8 @@ export const worldRouter = router({
|
||||
turnMap.set(turn.generalId, list);
|
||||
}
|
||||
const nationMap = new Map(nations.map((item) => [item.id, item]));
|
||||
const selectedNation = nationMap.get(selected.nationId);
|
||||
const crewTypeNames = await loadCrewTypeNames(unitSetName(world, ctx.profile.id));
|
||||
const officers = await ctx.db.general.findMany({
|
||||
where: { officerLevel: { in: [2, 3, 4] } },
|
||||
select: { name: true, officerLevel: true, meta: true },
|
||||
@@ -175,14 +203,59 @@ export const worldRouter = router({
|
||||
intelligence: general.intel,
|
||||
injury: general.injury,
|
||||
officerLevel: general.officerLevel,
|
||||
leadershipBonus: leadershipBonus(general.officerLevel, nationMap.get(general.nationId)?.level ?? 0),
|
||||
defenceTrain: ours ? defenceTrain(general.meta) : null,
|
||||
crewTypeId: ours ? general.crewTypeId : null,
|
||||
crewTypeName: ours ? (crewTypeNames.get(general.crewTypeId) ?? null) : null,
|
||||
crew: ours || full ? general.crew : null,
|
||||
train: ours ? general.train : null,
|
||||
atmos: ours ? general.atmos : null,
|
||||
turns: ours && general.npcState <= 1 ? (turnMap.get(general.id) ?? []) : [],
|
||||
};
|
||||
});
|
||||
const forceSummary = mappedGenerals.reduce(
|
||||
(summary, general) => {
|
||||
if (general.nationId > 0 && me.nationId > 0 && general.nationId !== me.nationId) {
|
||||
summary.enemyGenerals += 1;
|
||||
if (general.crew !== null && general.crew >= 0) summary.enemyCrew += general.crew;
|
||||
if (general.crew !== null && general.crew > 0) summary.enemyArmedGenerals += 1;
|
||||
return summary;
|
||||
}
|
||||
if (me.nationId <= 0 || general.nationId !== me.nationId) return summary;
|
||||
summary.ownGenerals += 1;
|
||||
summary.ownCrew += general.crew ?? 0;
|
||||
if ((general.crew ?? 0) <= 0) return summary;
|
||||
summary.ownArmedGenerals += 1;
|
||||
const readiness = Math.min(general.train ?? -1, general.atmos ?? -1);
|
||||
if (readiness >= 90) {
|
||||
summary.ready90Crew += general.crew ?? 0;
|
||||
summary.ready90Generals += 1;
|
||||
}
|
||||
if (readiness >= 60) {
|
||||
summary.ready60Crew += general.crew ?? 0;
|
||||
summary.ready60Generals += 1;
|
||||
}
|
||||
if (general.defenceTrain !== null && readiness >= general.defenceTrain) {
|
||||
summary.defenceReadyCrew += general.crew ?? 0;
|
||||
summary.defenceReadyGenerals += 1;
|
||||
}
|
||||
return summary;
|
||||
},
|
||||
{
|
||||
enemyCrew: 0,
|
||||
enemyArmedGenerals: 0,
|
||||
enemyGenerals: 0,
|
||||
ownCrew: 0,
|
||||
ownArmedGenerals: 0,
|
||||
ownGenerals: 0,
|
||||
ready90Crew: 0,
|
||||
ready90Generals: 0,
|
||||
ready60Crew: 0,
|
||||
ready60Generals: 0,
|
||||
defenceReadyCrew: 0,
|
||||
defenceReadyGenerals: 0,
|
||||
}
|
||||
);
|
||||
return {
|
||||
me: { id: me.id, nationId: me.nationId, officerLevel: me.officerLevel, admin },
|
||||
options: [...selectable]
|
||||
@@ -194,6 +267,7 @@ export const worldRouter = router({
|
||||
id: selected.id,
|
||||
name: selected.name,
|
||||
nationId: selected.nationId,
|
||||
nationColor: selectedNation?.color ?? '#000000',
|
||||
level: selected.level,
|
||||
region: selected.region,
|
||||
population: redact(selected.population),
|
||||
@@ -217,8 +291,11 @@ export const worldRouter = router({
|
||||
},
|
||||
},
|
||||
generals: mappedGenerals,
|
||||
forceSummary,
|
||||
lastExecute:
|
||||
typeof asRecord(world?.meta).turntime === 'string' ? String(asRecord(world?.meta).turntime) : '',
|
||||
typeof asRecord(world?.meta).turntime === 'string'
|
||||
? String(asRecord(world?.meta).turntime).slice(5, 19)
|
||||
: '',
|
||||
};
|
||||
}),
|
||||
getState: procedure.query(async ({ ctx }) => {
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { createTournamentRng } from '@sammo-ts/common';
|
||||
import { createTournamentRng, type TurnDaemonCommandResult } from '@sammo-ts/common';
|
||||
import { resolveTournamentBattle } from '@sammo-ts/logic';
|
||||
import {
|
||||
createGamePostgresConnector,
|
||||
createRedisConnector,
|
||||
resolvePostgresConfigFromEnv,
|
||||
resolveRedisConfigFromEnv,
|
||||
type GamePrismaClient,
|
||||
} from '@sammo-ts/infra';
|
||||
|
||||
import { resolveGameApiConfigFromEnv } from '../config.js';
|
||||
import { RedisTurnDaemonTransport } from '../daemon/redisTransport.js';
|
||||
import { buildTurnDaemonStreamKeys } from '../daemon/streamKeys.js';
|
||||
import { DatabaseTurnDaemonTransport } from '../daemon/databaseTransport.js';
|
||||
import type { TurnDaemonTransport } from '../daemon/transport.js';
|
||||
import { buildTournamentKeys } from './keys.js';
|
||||
import { TournamentStore } from './store.js';
|
||||
@@ -462,13 +462,30 @@ export const settleTournamentOutcome = async (options: {
|
||||
return null;
|
||||
}
|
||||
|
||||
let settledState: TournamentState | null = null;
|
||||
let settledState = state;
|
||||
let changed = false;
|
||||
|
||||
if (!state.rewardSettled) {
|
||||
const requireSuccessfulResult = (
|
||||
result: TurnDaemonCommandResult | null,
|
||||
expectedType: TurnDaemonCommandResult['type']
|
||||
): void => {
|
||||
if (!result) {
|
||||
throw new Error(`${expectedType} 명령 응답 시간이 초과되었습니다.`);
|
||||
}
|
||||
if (result.type !== expectedType) {
|
||||
throw new Error(`${expectedType} 명령에 잘못된 응답(${result.type})을 받았습니다.`);
|
||||
}
|
||||
if (!result.ok) {
|
||||
throw new Error(`${expectedType} 명령이 실패했습니다: ${result.reason}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (!settledState.rewardSettled) {
|
||||
const matches = await store.getMatches();
|
||||
const rewardPayload = buildTournamentRewardPayload(matches);
|
||||
await daemonTransport.sendCommand({
|
||||
const result = await daemonTransport.requestCommand({
|
||||
type: 'tournamentReward',
|
||||
requestId: `tournament:${state.bettingId ?? `${state.openYear}:${state.openMonth}:${state.type}`}:reward`,
|
||||
tournamentType: state.type,
|
||||
winnerId: rewardPayload.winnerId,
|
||||
runnerUpId: rewardPayload.runnerUpId,
|
||||
@@ -476,46 +493,100 @@ export const settleTournamentOutcome = async (options: {
|
||||
top8: rewardPayload.top8,
|
||||
top4: rewardPayload.top4,
|
||||
});
|
||||
requireSuccessfulResult(result, 'tournamentReward');
|
||||
settledState = {
|
||||
...(settledState ?? state),
|
||||
...settledState,
|
||||
rewardSettled: true,
|
||||
};
|
||||
changed = true;
|
||||
await store.setState(settledState);
|
||||
}
|
||||
|
||||
if (state.bettingId && !state.bettingSettled) {
|
||||
if (settledState.bettingId && !settledState.bettingSettled) {
|
||||
const bettingEntries = await store.getBettingEntries();
|
||||
if (bettingEntries.length > 0) {
|
||||
const payoutInfo = buildBettingPayouts(state.winnerId, bettingEntries);
|
||||
const payoutInfo = buildBettingPayouts(settledState.winnerId!, bettingEntries);
|
||||
if (payoutInfo.payouts.length > 0) {
|
||||
if (payoutInfo.refundAll) {
|
||||
await daemonTransport.sendCommand({
|
||||
const result = await daemonTransport.requestCommand({
|
||||
type: 'tournamentRefund',
|
||||
bettingId: state.bettingId,
|
||||
requestId: `tournament:${settledState.bettingId}:betting-refund`,
|
||||
bettingId: settledState.bettingId,
|
||||
refunds: payoutInfo.payouts,
|
||||
reason: 'no_winner',
|
||||
});
|
||||
requireSuccessfulResult(result, 'tournamentRefund');
|
||||
} else {
|
||||
await daemonTransport.sendCommand({
|
||||
const result = await daemonTransport.requestCommand({
|
||||
type: 'tournamentBettingPayout',
|
||||
bettingId: state.bettingId,
|
||||
requestId: `tournament:${settledState.bettingId}:betting-payout`,
|
||||
bettingId: settledState.bettingId,
|
||||
payouts: payoutInfo.payouts,
|
||||
reason: 'winner_payout',
|
||||
});
|
||||
requireSuccessfulResult(result, 'tournamentBettingPayout');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
settledState = {
|
||||
...(settledState ?? state),
|
||||
...settledState,
|
||||
bettingSettled: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (settledState) {
|
||||
changed = true;
|
||||
await store.setState(settledState);
|
||||
}
|
||||
|
||||
return settledState;
|
||||
return changed ? settledState : null;
|
||||
};
|
||||
|
||||
const needsSettlement = (state: TournamentState): boolean =>
|
||||
state.stage === 0 &&
|
||||
Boolean(state.winnerId) &&
|
||||
(!state.rewardSettled || (Boolean(state.bettingId) && !state.bettingSettled));
|
||||
|
||||
export const processTournamentTick = async (options: {
|
||||
store: TournamentStore;
|
||||
prisma: GamePrismaClient;
|
||||
daemonTransport: TurnDaemonTransport;
|
||||
now?: () => number;
|
||||
}): Promise<TournamentState | null> => {
|
||||
const { store, prisma, daemonTransport } = options;
|
||||
const now = options.now ?? Date.now;
|
||||
let processedState: TournamentState | null = null;
|
||||
|
||||
await store.withMutationLock(async () => {
|
||||
const state = await store.getState();
|
||||
if (!state || (!state.auto && !needsSettlement(state))) {
|
||||
return;
|
||||
}
|
||||
const nextAt = new Date(state.nextAt).getTime();
|
||||
if (state.auto && Number.isFinite(nextAt) && nextAt > now()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (needsSettlement(state)) {
|
||||
processedState = (await settleTournamentOutcome({ store, daemonTransport, state })) ?? state;
|
||||
return;
|
||||
}
|
||||
|
||||
const worldState = await prisma.worldState.findFirst();
|
||||
const baseSeed = (worldState?.meta as Record<string, unknown> | null)?.hiddenSeed ?? 'tournament';
|
||||
let nextState = state;
|
||||
if (isBattleStage(state.stage)) {
|
||||
nextState = await applyBattle(store, state, String(baseSeed), daemonTransport);
|
||||
} else if (isPreBattleStage(state.stage)) {
|
||||
nextState = await applyPreBattleStage(store, prisma, state, String(baseSeed), daemonTransport);
|
||||
}
|
||||
processedState =
|
||||
(await settleTournamentOutcome({
|
||||
store,
|
||||
daemonTransport,
|
||||
state: nextState,
|
||||
})) ?? nextState;
|
||||
});
|
||||
|
||||
return processedState;
|
||||
};
|
||||
|
||||
export const runTournamentWorker = async (): Promise<void> => {
|
||||
@@ -527,10 +598,7 @@ export const runTournamentWorker = async (): Promise<void> => {
|
||||
await redis.connect();
|
||||
|
||||
const store = new TournamentStore(redis.client, buildTournamentKeys(config.profileName));
|
||||
const daemonTransport = new RedisTurnDaemonTransport(redis.client, {
|
||||
keys: buildTurnDaemonStreamKeys(config.profileName),
|
||||
requestTimeoutMs: config.daemonRequestTimeoutMs,
|
||||
});
|
||||
const daemonTransport = new DatabaseTurnDaemonTransport(postgres.prisma, config.daemonRequestTimeoutMs);
|
||||
|
||||
const handleExit = async () => {
|
||||
await redis.disconnect();
|
||||
@@ -541,49 +609,23 @@ export const runTournamentWorker = async (): Promise<void> => {
|
||||
|
||||
while (true) {
|
||||
const state = await store.getState();
|
||||
if (!state || !state.auto) {
|
||||
if (!state || (!state.auto && !needsSettlement(state))) {
|
||||
await sleepMs(config.tournamentPollMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextAt = new Date(state.nextAt).getTime();
|
||||
const now = Date.now();
|
||||
if (Number.isFinite(nextAt) && nextAt > now) {
|
||||
if (state.auto && Number.isFinite(nextAt) && nextAt > now) {
|
||||
await sleepMs(Math.min(config.tournamentPollMs, nextAt - now));
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await store.withMutationLock(async () => {
|
||||
const lockedState = await store.getState();
|
||||
if (!lockedState || !lockedState.auto) {
|
||||
return;
|
||||
}
|
||||
const lockedNextAt = new Date(lockedState.nextAt).getTime();
|
||||
if (Number.isFinite(lockedNextAt) && lockedNextAt > Date.now()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const worldState = await postgres.prisma.worldState.findFirst();
|
||||
const baseSeed = (worldState?.meta as Record<string, unknown> | null)?.hiddenSeed ?? 'tournament';
|
||||
let nextState = lockedState;
|
||||
if (isBattleStage(lockedState.stage)) {
|
||||
nextState = await applyBattle(store, lockedState, String(baseSeed), daemonTransport);
|
||||
} else if (isPreBattleStage(lockedState.stage)) {
|
||||
nextState = await applyPreBattleStage(
|
||||
store,
|
||||
postgres.prisma,
|
||||
lockedState,
|
||||
String(baseSeed),
|
||||
daemonTransport
|
||||
);
|
||||
}
|
||||
|
||||
await settleTournamentOutcome({
|
||||
store,
|
||||
daemonTransport,
|
||||
state: nextState,
|
||||
});
|
||||
await processTournamentTick({
|
||||
store,
|
||||
prisma: postgres.prisma,
|
||||
daemonTransport,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
@@ -603,9 +645,9 @@ export const runTournamentWorker = async (): Promise<void> => {
|
||||
},
|
||||
},
|
||||
});
|
||||
const currentState = (await store.getState()) ?? state;
|
||||
const nextState: TournamentState = {
|
||||
...state,
|
||||
auto: false,
|
||||
...currentState,
|
||||
lastError: message,
|
||||
lastErrorAt: now,
|
||||
};
|
||||
|
||||
@@ -520,8 +520,9 @@ export const buildBettingPayouts = (
|
||||
const winners = entries.filter((entry) => entry.targetId === winnerId);
|
||||
const winnersTotal = winners.reduce((sum, entry) => sum + entry.amount, 0);
|
||||
if (winnersTotal <= 0) {
|
||||
const refunds = entries.map((entry) => ({ generalId: entry.generalId, amount: entry.amount }));
|
||||
return { payouts: refunds, total, refundAll: true };
|
||||
// Legacy Betting::_calcRewardExclusive() builds a refund candidate list
|
||||
// but returns no rewards when nobody selected the winner.
|
||||
return { payouts: [], total, refundAll: false };
|
||||
}
|
||||
const ratio = total / winnersTotal;
|
||||
const payouts = winners.map((entry) => ({
|
||||
|
||||
@@ -53,13 +53,13 @@ const general = (overrides: Partial<GeneralRow> = {}): GeneralRow => ({
|
||||
updatedAt: now,
|
||||
...overrides,
|
||||
});
|
||||
const auth = (roles: string[] = []): GameSessionTokenPayload => ({
|
||||
const auth = (roles: string[] = [], userId = 'user-1'): GameSessionTokenPayload => ({
|
||||
version: 1,
|
||||
profile: 'che:default',
|
||||
issuedAt: now.toISOString(),
|
||||
expiresAt: new Date(now.getTime() + 86400000).toISOString(),
|
||||
sessionId: 'session',
|
||||
user: { id: 'user-1', username: 'tester', displayName: 'Tester', roles },
|
||||
user: { id: userId, username: 'tester', displayName: 'Tester', roles },
|
||||
sanctions: {},
|
||||
});
|
||||
const city = (id: number, nationId: number) => ({
|
||||
@@ -88,15 +88,30 @@ const city = (id: number, nationId: number) => ({
|
||||
meta: {},
|
||||
});
|
||||
|
||||
const context = (options: { me?: GeneralRow; roles?: string[]; nationMeta?: Record<string, unknown> } = {}) => {
|
||||
const context = (
|
||||
options: {
|
||||
me?: GeneralRow;
|
||||
roles?: string[];
|
||||
userId?: string;
|
||||
nationMeta?: Record<string, unknown>;
|
||||
nationLevel?: number;
|
||||
stationCityId?: number;
|
||||
} = {}
|
||||
) => {
|
||||
const me = options.me ?? general();
|
||||
const cities = [city(1, 1), city(2, 2), city(3, 2), city(80, 1)];
|
||||
const foreign = general({ id: 2, userId: 'user-2', name: '적군', nationId: 2, cityId: 2, crew: 777 });
|
||||
const db = {
|
||||
general: {
|
||||
findFirst: vi.fn(async () => me),
|
||||
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) =>
|
||||
where.userId === me.userId ? me : null
|
||||
),
|
||||
findMany: vi.fn(async (args: { where?: Record<string, unknown>; select?: Record<string, boolean> }) => {
|
||||
if (args.where?.nationId === 1 && args.select?.cityId) return [{ cityId: me.cityId }];
|
||||
if (args.where?.nationId === 1 && args.select?.cityId)
|
||||
return [
|
||||
{ cityId: me.cityId },
|
||||
...(options.stationCityId ? [{ cityId: options.stationCityId }] : []),
|
||||
];
|
||||
if (args.where?.cityId === 2) return [foreign];
|
||||
if (args.where?.cityId === 3) return [foreign];
|
||||
if (args.where?.officerLevel) return [];
|
||||
@@ -108,7 +123,7 @@ const context = (options: { me?: GeneralRow; roles?: string[]; nationMeta?: Reco
|
||||
id: 1,
|
||||
name: '아국',
|
||||
color: '#008000',
|
||||
level: 1,
|
||||
level: options.nationLevel ?? 1,
|
||||
capitalCityId: 1,
|
||||
meta: options.nationMeta ?? {},
|
||||
})),
|
||||
@@ -130,7 +145,7 @@ const context = (options: { me?: GeneralRow; roles?: string[]; nationMeta?: Reco
|
||||
turnDaemon: {} as GameApiContext['turnDaemon'],
|
||||
battleSim: {} as GameApiContext['battleSim'],
|
||||
profile: { id: 'che', scenario: 'default', name: 'che:default' },
|
||||
auth: auth(options.roles),
|
||||
auth: auth(options.roles, options.userId),
|
||||
uploadDir: 'uploads',
|
||||
uploadPath: '/uploads',
|
||||
uploadPublicUrl: null,
|
||||
@@ -157,6 +172,25 @@ describe('in-game information permissions', () => {
|
||||
expect(result.generals).toEqual([]);
|
||||
});
|
||||
|
||||
it('derives the actor from the session user instead of accepting another user general', async () => {
|
||||
const caller = appRouter.createCaller(context({ userId: 'user-2' }));
|
||||
await expect(caller.world.getCurrentCity({ cityId: 1 })).rejects.toMatchObject({ code: 'NOT_FOUND' });
|
||||
});
|
||||
|
||||
it('allows a nation member to select a city occupied by another general of the same nation', async () => {
|
||||
const result = await appRouter.createCaller(context({ stationCityId: 2 })).world.getCurrentCity({ cityId: 2 });
|
||||
expect(result.options.map((entry) => entry.id)).toContain(2);
|
||||
expect(result.visibility.full).toBe(true);
|
||||
});
|
||||
|
||||
it('does not grant a spy city while the nation has no active level', async () => {
|
||||
const result = await appRouter
|
||||
.createCaller(context({ nationMeta: { spy: { 2: 2 } }, nationLevel: 0 }))
|
||||
.world.getCurrentCity({ cityId: 2 });
|
||||
expect(result.options.map((entry) => entry.id)).not.toContain(2);
|
||||
expect(result.visibility.full).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps adjacent foreign detail redacted and never reveals military fields', async () => {
|
||||
const result = await appRouter
|
||||
.createCaller(context({ me: general({ cityId: 80 }) }))
|
||||
@@ -174,12 +208,20 @@ describe('in-game information permissions', () => {
|
||||
expect(result.visibility.full).toBe(true);
|
||||
expect(result.city.population).toBe(1000);
|
||||
expect(result.generals[0]).toMatchObject({ crew: 777, train: null, atmos: null, crewTypeId: null });
|
||||
expect(result.forceSummary).toMatchObject({
|
||||
enemyCrew: 777,
|
||||
enemyArmedGenerals: 1,
|
||||
enemyGenerals: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('allows administrative roles to inspect all city and general fields', async () => {
|
||||
const result = await appRouter.createCaller(context({ roles: ['admin'] })).world.getCurrentCity({ cityId: 3 });
|
||||
expect(result.options).toHaveLength(4);
|
||||
expect(result.visibility.full).toBe(true);
|
||||
expect(result.generals[0]).toMatchObject({ crew: 777, train: 90, atmos: 90, crewTypeId: 1 });
|
||||
});
|
||||
it.each(['admin', 'superuser', 'admin.superuser'])(
|
||||
'allows the %s role to inspect all city and general fields',
|
||||
async (role) => {
|
||||
const result = await appRouter.createCaller(context({ roles: [role] })).world.getCurrentCity({ cityId: 3 });
|
||||
expect(result.options).toHaveLength(4);
|
||||
expect(result.visibility.full).toBe(true);
|
||||
expect(result.generals[0]).toMatchObject({ crew: 777, train: 90, atmos: 90, crewTypeId: 1 });
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import type { RedisConnector } from '@sammo-ts/infra';
|
||||
|
||||
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
|
||||
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
|
||||
import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js';
|
||||
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
|
||||
import { appRouter } from '../src/router.js';
|
||||
|
||||
const buildGeneral = (overrides: Partial<GeneralRow> = {}): GeneralRow => ({
|
||||
id: 7,
|
||||
userId: 'user-1',
|
||||
name: '유비',
|
||||
nationId: 1,
|
||||
cityId: 1,
|
||||
troopId: 0,
|
||||
npcState: 0,
|
||||
affinity: null,
|
||||
bornYear: 180,
|
||||
deadYear: 300,
|
||||
picture: null,
|
||||
imageServer: 0,
|
||||
leadership: 70,
|
||||
strength: 45,
|
||||
intel: 85,
|
||||
injury: 0,
|
||||
experience: 0,
|
||||
dedication: 0,
|
||||
officerLevel: 1,
|
||||
gold: 1000,
|
||||
rice: 1000,
|
||||
crew: 0,
|
||||
crewTypeId: 0,
|
||||
train: 0,
|
||||
atmos: 0,
|
||||
weaponCode: 'None',
|
||||
bookCode: 'None',
|
||||
horseCode: 'None',
|
||||
itemCode: 'None',
|
||||
turnTime: new Date('2026-07-26T00:00:00Z'),
|
||||
recentWarTime: null,
|
||||
age: 20,
|
||||
startAge: 20,
|
||||
personalCode: 'None',
|
||||
specialCode: 'None',
|
||||
special2Code: 'che_선봉',
|
||||
lastTurn: {},
|
||||
meta: {},
|
||||
penalty: {},
|
||||
createdAt: new Date('2026-07-26T00:00:00Z'),
|
||||
updatedAt: new Date('2026-07-26T00:00:00Z'),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const buildAuth = (userId = 'user-1'): GameSessionTokenPayload => ({
|
||||
version: 1,
|
||||
profile: 'che:default',
|
||||
issuedAt: '2026-07-26T00:00:00.000Z',
|
||||
expiresAt: '2026-07-27T00:00:00.000Z',
|
||||
sessionId: `session-${userId}`,
|
||||
user: {
|
||||
id: userId,
|
||||
username: userId,
|
||||
displayName: userId,
|
||||
roles: [],
|
||||
},
|
||||
sanctions: {},
|
||||
});
|
||||
|
||||
const worldState = {
|
||||
id: 1,
|
||||
scenarioCode: 'default',
|
||||
currentYear: 200,
|
||||
currentMonth: 4,
|
||||
tickSeconds: 3600,
|
||||
config: {
|
||||
const: {
|
||||
availableSpecialWar: ['che_선봉'],
|
||||
allItems: {
|
||||
weapon: {
|
||||
che_무기_12_칠성검: 1,
|
||||
che_무기_01_단도: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
meta: { hiddenSeed: 'test-seed', isUnited: 0, season: 1 },
|
||||
updatedAt: new Date('2026-07-26T00:00:00Z'),
|
||||
};
|
||||
|
||||
const buildContext = (options: {
|
||||
auth?: GameSessionTokenPayload | null;
|
||||
general?: GeneralRow | null;
|
||||
target?: GeneralRow | null;
|
||||
inheritancePoint?: number;
|
||||
}) => {
|
||||
const auth = options.auth === undefined ? buildAuth() : options.auth;
|
||||
const general = options.general === undefined ? buildGeneral() : options.general;
|
||||
const target =
|
||||
options.target === undefined
|
||||
? buildGeneral({ id: 8, userId: 'user-2', name: '조조', meta: { ownerName: '위유저' } })
|
||||
: options.target;
|
||||
const requestCommand = vi.fn(async (command: { type: string; generalId: number }) => ({
|
||||
type: command.type,
|
||||
ok: true,
|
||||
generalId: command.generalId,
|
||||
}));
|
||||
const pointUpsert = vi.fn(async () => ({}));
|
||||
const logCreate = vi.fn(async () => ({}));
|
||||
const findMany = vi.fn(async () => (target ? [{ id: target.id, name: target.name }] : []));
|
||||
const db = {
|
||||
$queryRaw: vi.fn(async () => [{ value: options.inheritancePoint ?? 10_000 }]),
|
||||
worldState: {
|
||||
findFirst: vi.fn(async () => worldState),
|
||||
},
|
||||
general: {
|
||||
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) =>
|
||||
general?.userId === where.userId ? general : null
|
||||
),
|
||||
findMany,
|
||||
findUnique: vi.fn(async ({ where }: { where: { id: number } }) =>
|
||||
target?.id === where.id ? target : null
|
||||
),
|
||||
},
|
||||
inheritancePoint: {
|
||||
upsert: pointUpsert,
|
||||
},
|
||||
inheritanceLog: {
|
||||
create: logCreate,
|
||||
findMany: vi.fn(async () => []),
|
||||
},
|
||||
inheritanceUserState: {
|
||||
findUnique: vi.fn(async () => null),
|
||||
upsert: vi.fn(async () => ({})),
|
||||
},
|
||||
};
|
||||
const accessTokenStore = new RedisAccessTokenStore(
|
||||
{
|
||||
get: async () => null,
|
||||
set: async () => null,
|
||||
},
|
||||
'che:default'
|
||||
);
|
||||
const context: GameApiContext = {
|
||||
db: db as unknown as DatabaseClient,
|
||||
redis: {} as RedisConnector['client'],
|
||||
turnDaemon: { requestCommand } as unknown as TurnDaemonTransport,
|
||||
battleSim: {} as GameApiContext['battleSim'],
|
||||
profile: { id: 'che', scenario: 'default', name: 'che:default' },
|
||||
auth,
|
||||
uploadDir: 'uploads',
|
||||
uploadPath: '/uploads',
|
||||
uploadPublicUrl: null,
|
||||
accessTokenStore,
|
||||
flushStore: new InMemoryFlushStore(),
|
||||
gameTokenSecret: 'test-secret',
|
||||
};
|
||||
return { context, requestCommand, pointUpsert, logCreate, findMany };
|
||||
};
|
||||
|
||||
describe('inherit router actor and permission boundaries', () => {
|
||||
it('rejects unauthenticated status and mutations', async () => {
|
||||
const fixture = buildContext({ auth: null });
|
||||
const caller = appRouter.createCaller(fixture.context);
|
||||
|
||||
await expect(caller.inherit.getStatus()).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
||||
await expect(caller.inherit.buyHiddenBuff({ type: 'warAvoidRatio', level: 1 })).rejects.toMatchObject({
|
||||
code: 'UNAUTHORIZED',
|
||||
});
|
||||
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('builds status only from the authenticated user general and filters target generals like ref', async () => {
|
||||
const fixture = buildContext({});
|
||||
const status = await appRouter.createCaller(fixture.context).inherit.getStatus();
|
||||
|
||||
expect(status.currentStat).toEqual({ leadership: 70, strength: 45, intel: 85 });
|
||||
expect(status.availableTargetGenerals).toEqual([{ id: 8, name: '조조' }]);
|
||||
expect(status.availableUnique).toEqual([
|
||||
expect.objectContaining({ key: 'che_무기_12_칠성검', rawName: '칠성검' }),
|
||||
]);
|
||||
expect(status.buffLevels).toHaveProperty('domesticSuccessProb', 0);
|
||||
expect(fixture.findMany).toHaveBeenCalledWith({
|
||||
where: { id: { not: 7 }, npcState: { lt: 2 }, userId: { not: null } },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not dispatch or charge when the authenticated user owns no general', async () => {
|
||||
const fixture = buildContext({
|
||||
auth: buildAuth('user-2'),
|
||||
general: buildGeneral({ userId: 'user-1' }),
|
||||
});
|
||||
|
||||
await expect(
|
||||
appRouter.createCaller(fixture.context).inherit.buyHiddenBuff({
|
||||
type: 'domesticSuccessProb',
|
||||
level: 1,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: '장수가 존재하지 않습니다.',
|
||||
});
|
||||
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
||||
expect(fixture.pointUpsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('mutates only the authenticated user general and inheritance balance', async () => {
|
||||
const fixture = buildContext({ inheritancePoint: 1000 });
|
||||
|
||||
await expect(
|
||||
appRouter.createCaller(fixture.context).inherit.buyHiddenBuff({
|
||||
type: 'domesticSuccessProb',
|
||||
level: 1,
|
||||
})
|
||||
).resolves.toEqual({ ok: true, remainPoint: 800 });
|
||||
|
||||
expect(fixture.requestCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'patchGeneral',
|
||||
generalId: 7,
|
||||
patch: expect.objectContaining({
|
||||
meta: expect.objectContaining({
|
||||
inheritBuff: JSON.stringify({ domesticSuccessProb: 1 }),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(fixture.pointUpsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { userId_key: { userId: 'user-1', key: 'previous' } },
|
||||
update: { value: 800 },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('reveals a target owner to the caller without using the caller general id from input', async () => {
|
||||
const fixture = buildContext({ inheritancePoint: 1500 });
|
||||
|
||||
await expect(
|
||||
appRouter.createCaller(fixture.context).inherit.checkOwner({ targetGeneralId: 8 })
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
ownerName: '위유저',
|
||||
targetName: '조조',
|
||||
});
|
||||
expect(fixture.pointUpsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { userId_key: { userId: 'user-1', key: 'previous' } },
|
||||
update: { value: 500 },
|
||||
})
|
||||
);
|
||||
expect(fixture.logCreate).toHaveBeenCalledWith({
|
||||
data: {
|
||||
userId: 'user-1',
|
||||
year: 200,
|
||||
month: 4,
|
||||
text: '1000 포인트로 장수 소유자 확인',
|
||||
},
|
||||
});
|
||||
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,16 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import type { RedisConnector } from '@sammo-ts/infra';
|
||||
|
||||
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
|
||||
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
|
||||
import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js';
|
||||
import { appRouter } from '../src/router.js';
|
||||
|
||||
const now = new Date('2026-01-01T01:02:00.000Z');
|
||||
const buildGeneral = (overrides: Partial<GeneralRow> = {}): GeneralRow => ({
|
||||
const now = new Date('2026-01-01T01:02:00Z');
|
||||
const general = (overrides: Partial<GeneralRow> = {}): GeneralRow => ({
|
||||
id: 1,
|
||||
userId: 'user-1',
|
||||
name: '일반장수',
|
||||
userId: 'u1',
|
||||
name: '장수',
|
||||
nationId: 1,
|
||||
cityId: 1,
|
||||
troopId: 0,
|
||||
@@ -20,7 +18,7 @@ const buildGeneral = (overrides: Partial<GeneralRow> = {}): GeneralRow => ({
|
||||
affinity: null,
|
||||
bornYear: 180,
|
||||
deadYear: 300,
|
||||
picture: 'default.jpg',
|
||||
picture: null,
|
||||
imageServer: 0,
|
||||
leadership: 70,
|
||||
strength: 60,
|
||||
@@ -29,8 +27,8 @@ const buildGeneral = (overrides: Partial<GeneralRow> = {}): GeneralRow => ({
|
||||
experience: 900,
|
||||
dedication: 100,
|
||||
officerLevel: 1,
|
||||
gold: 1_000,
|
||||
rice: 2_000,
|
||||
gold: 1000,
|
||||
rice: 2000,
|
||||
crew: 300,
|
||||
crewTypeId: 1,
|
||||
train: 90,
|
||||
@@ -53,157 +51,92 @@ const buildGeneral = (overrides: Partial<GeneralRow> = {}): GeneralRow => ({
|
||||
updatedAt: now,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const auth = (userId: string): GameSessionTokenPayload => ({
|
||||
const token = (userId: string): GameSessionTokenPayload => ({
|
||||
version: 1,
|
||||
profile: 'che:default',
|
||||
issuedAt: now.toISOString(),
|
||||
expiresAt: new Date(now.getTime() + 86_400_000).toISOString(),
|
||||
sessionId: `session-${userId}`,
|
||||
expiresAt: new Date(now.getTime() + 86400000).toISOString(),
|
||||
sessionId: userId,
|
||||
user: { id: userId, username: userId, displayName: userId, roles: [] },
|
||||
sanctions: {},
|
||||
});
|
||||
|
||||
const createContext = (options: {
|
||||
sessionUserId?: string;
|
||||
generals?: GeneralRow[];
|
||||
nationMeta?: Record<string, unknown>;
|
||||
}) => {
|
||||
const sessionUserId = options.sessionUserId ?? 'user-1';
|
||||
const generals = options.generals ?? [buildGeneral()];
|
||||
const fixture = (generals: GeneralRow[], userId = 'u1') => {
|
||||
const db = {
|
||||
general: {
|
||||
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) =>
|
||||
generals.find((general) => general.userId === where.userId)
|
||||
generals.find((g) => g.userId === where.userId)
|
||||
),
|
||||
findMany: vi.fn(async ({ where }: { where: { nationId: number } }) =>
|
||||
generals.filter((general) => general.nationId === where.nationId)
|
||||
generals.filter((g) => g.nationId === where.nationId)
|
||||
),
|
||||
},
|
||||
nation: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
id: 1,
|
||||
name: '위',
|
||||
color: '#008000',
|
||||
color: '#080',
|
||||
level: 3,
|
||||
typeCode: 'che_중립',
|
||||
capitalCityId: 1,
|
||||
meta: options.nationMeta ?? { secretlimit: 3 },
|
||||
meta: { secretlimit: 3 },
|
||||
})),
|
||||
},
|
||||
city: { findMany: vi.fn(async () => [{ id: 1, name: '업' }]) },
|
||||
troop: { findMany: vi.fn(async () => [{ troopLeaderId: 2, name: '선봉대' }]) },
|
||||
worldState: { findFirst: vi.fn(async () => null) },
|
||||
generalTurn: { findMany: vi.fn(async () => [{ generalId: 1, turnIdx: 0, actionCode: '징병' }]) },
|
||||
generalAccessLog: {
|
||||
findMany: vi.fn(async () =>
|
||||
generals.map((general) => ({
|
||||
generalId: general.id,
|
||||
refreshScore: general.id,
|
||||
refreshScoreTotal: general.id * 10,
|
||||
}))
|
||||
),
|
||||
},
|
||||
generalTurn: {
|
||||
findMany: vi.fn(async () => [
|
||||
{ generalId: 1, turnIdx: 0, actionCode: '징병' },
|
||||
{ generalId: 1, turnIdx: 1, actionCode: '훈련' },
|
||||
]),
|
||||
findMany: vi.fn(async () => generals.map((g) => ({ generalId: g.id, refreshScoreTotal: g.id * 10 }))),
|
||||
},
|
||||
};
|
||||
const redis = { get: vi.fn(async () => null), set: vi.fn(async () => null) } as unknown as RedisConnector['client'];
|
||||
return {
|
||||
context: {
|
||||
db: db as unknown as DatabaseClient,
|
||||
redis,
|
||||
turnDaemon: {} as GameApiContext['turnDaemon'],
|
||||
battleSim: {} as GameApiContext['battleSim'],
|
||||
profile: { id: 'che', scenario: 'default', name: 'che:default' },
|
||||
auth: auth(sessionUserId),
|
||||
uploadDir: 'uploads',
|
||||
uploadPath: '/uploads',
|
||||
uploadPublicUrl: null,
|
||||
accessTokenStore: new RedisAccessTokenStore(redis, 'che:default'),
|
||||
flushStore: new InMemoryFlushStore(),
|
||||
gameTokenSecret: 'test-secret',
|
||||
} satisfies GameApiContext,
|
||||
db,
|
||||
const context: GameApiContext = {
|
||||
db: db as unknown as DatabaseClient,
|
||||
redis,
|
||||
turnDaemon: {} as GameApiContext['turnDaemon'],
|
||||
battleSim: {} as GameApiContext['battleSim'],
|
||||
profile: { id: 'che', scenario: 'default', name: 'che:default' },
|
||||
auth: token(userId),
|
||||
uploadDir: 'uploads',
|
||||
uploadPath: '/uploads',
|
||||
uploadPublicUrl: null,
|
||||
accessTokenStore: new RedisAccessTokenStore(redis, 'che:default'),
|
||||
flushStore: new InMemoryFlushStore(),
|
||||
gameTokenSecret: 'secret',
|
||||
};
|
||||
return { caller: appRouter.createCaller(context), db };
|
||||
};
|
||||
|
||||
describe('nation general and secret-office permissions', () => {
|
||||
it('redacts confidential columns for an ordinary member and denies the secret office', async () => {
|
||||
const fixture = createContext({});
|
||||
const caller = appRouter.createCaller(fixture.context);
|
||||
|
||||
const list = await caller.nation.getGeneralList();
|
||||
expect(list.viewer).toEqual({ generalId: 1, permission: 0 });
|
||||
expect(list.generals[0]).toMatchObject({
|
||||
describe('nation general and secret office permissions', () => {
|
||||
it('redacts ordinary-member details and denies the secret office', async () => {
|
||||
const { caller } = fixture([general()]);
|
||||
const result = await caller.nation.getGeneralList();
|
||||
expect(result.viewer).toEqual({ generalId: 1, permission: 0 });
|
||||
expect(result.generals[0]).toMatchObject({
|
||||
officerLevel: 1,
|
||||
gold: 1_000,
|
||||
rice: 2_000,
|
||||
detail: null,
|
||||
cityName: null,
|
||||
troopName: null,
|
||||
refreshScoreTotal: 10,
|
||||
});
|
||||
expect(fixture.db.generalTurn.findMany).not.toHaveBeenCalled();
|
||||
expect(result.generals[0]).not.toHaveProperty('crew');
|
||||
await expect(caller.nation.getSecretGeneralList()).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||
});
|
||||
|
||||
it('allows a tenured member, scopes rows to the actor nation, and returns legacy summary and turns', async () => {
|
||||
const me = buildGeneral({ meta: { belong: 3, defence_train: 90, killturn: 7 } });
|
||||
const ally = buildGeneral({
|
||||
id: 2,
|
||||
userId: 'user-2',
|
||||
name: '아군',
|
||||
troopId: 2,
|
||||
gold: 3_000,
|
||||
rice: 4_000,
|
||||
crew: 200,
|
||||
train: 80,
|
||||
atmos: 80,
|
||||
});
|
||||
const hiddenNpc = buildGeneral({ id: 3, userId: null, name: '가상', npcState: 5, gold: 99_999 });
|
||||
const foreign = buildGeneral({ id: 4, userId: 'foreign', nationId: 2, gold: 88_888 });
|
||||
const fixture = createContext({ generals: [me, ally, hiddenNpc, foreign] });
|
||||
|
||||
const result = await appRouter.createCaller(fixture.context).nation.getSecretGeneralList();
|
||||
expect(result.viewer.permission).toBe(1);
|
||||
expect(result.generals.map((general) => general.id)).toEqual([1, 2, 3]);
|
||||
expect(result.generals[0]?.detail).toMatchObject({
|
||||
cityName: '업',
|
||||
defenceTrain: 90,
|
||||
killTurn: 7,
|
||||
reservedCommands: ['징병', '훈련'],
|
||||
});
|
||||
expect(result.generals[1]?.detail?.troopName).toBe('선봉대');
|
||||
expect(result.summary).toMatchObject({
|
||||
gold: 4_000,
|
||||
rice: 6_000,
|
||||
crew: 500,
|
||||
generalCount: 2,
|
||||
readiness: {
|
||||
90: { crew: 300, generals: 1 },
|
||||
80: { crew: 500, generals: 2 },
|
||||
60: { crew: 500, generals: 2 },
|
||||
},
|
||||
});
|
||||
expect(fixture.db.general.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { nationId: 1 } })
|
||||
);
|
||||
});
|
||||
|
||||
it('derives the acting general from the authenticated user and applies that general permission', async () => {
|
||||
const first = buildGeneral({ userId: 'user-1', meta: { belong: 1 } });
|
||||
const second = buildGeneral({ id: 2, userId: 'user-2', officerLevel: 5, meta: { belong: 1 } });
|
||||
const fixture = createContext({ sessionUserId: 'user-2', generals: [first, second] });
|
||||
|
||||
const result = await appRouter.createCaller(fixture.context).nation.getSecretGeneralList();
|
||||
it('uses the session-owned general and scopes secret rows to that nation', async () => {
|
||||
const first = general();
|
||||
const actor = general({ id: 2, userId: 'u2', officerLevel: 5, meta: { belong: 1 } });
|
||||
const ally = general({ id: 3, userId: 'u3', gold: 3000, crew: 200, train: 80, atmos: 80 });
|
||||
const foreign = general({ id: 4, userId: 'u4', nationId: 2, gold: 99999 });
|
||||
const { caller, db } = fixture([first, actor, ally, foreign], 'u2');
|
||||
const result = await caller.nation.getSecretGeneralList();
|
||||
expect(result.viewer).toEqual({ generalId: 2, permission: 2 });
|
||||
expect(fixture.db.general.findFirst).toHaveBeenCalledWith({ where: { userId: 'user-2' } });
|
||||
expect(result.generals.map((g) => g.id)).toEqual([1, 2, 3]);
|
||||
expect(result.summary).toMatchObject({ gold: 5000, crew: 800, generalCount: 3 });
|
||||
expect(db.general.findFirst).toHaveBeenCalledWith({ where: { userId: 'u2' } });
|
||||
expect(db.general.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { nationId: 1 } }));
|
||||
});
|
||||
|
||||
it('keeps penalty-based secret denial even for an otherwise qualified general', async () => {
|
||||
const me = buildGeneral({ officerLevel: 5, penalty: { noChief: true } });
|
||||
const fixture = createContext({ generals: [me] });
|
||||
await expect(appRouter.createCaller(fixture.context).nation.getSecretGeneralList()).rejects.toMatchObject({
|
||||
code: 'FORBIDDEN',
|
||||
});
|
||||
it('honors general penalties after a user switch', async () => {
|
||||
const penalized = general({ officerLevel: 5, penalty: { noChief: true } });
|
||||
const { caller } = fixture([penalized]);
|
||||
await expect(caller.nation.getSecretGeneralList()).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import type { RedisConnector } from '@sammo-ts/infra';
|
||||
|
||||
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
|
||||
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
|
||||
import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js';
|
||||
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
|
||||
import { appRouter } from '../src/router.js';
|
||||
|
||||
const baseGeneral: GeneralRow = {
|
||||
id: 22,
|
||||
userId: 'user-22',
|
||||
name: '정책담당',
|
||||
nationId: 1,
|
||||
cityId: 1,
|
||||
troopId: 0,
|
||||
npcState: 0,
|
||||
affinity: null,
|
||||
bornYear: 180,
|
||||
deadYear: 300,
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
leadership: 70,
|
||||
strength: 70,
|
||||
intel: 70,
|
||||
injury: 0,
|
||||
experience: 0,
|
||||
dedication: 0,
|
||||
officerLevel: 12,
|
||||
gold: 1_000,
|
||||
rice: 1_000,
|
||||
crew: 0,
|
||||
crewTypeId: 0,
|
||||
train: 0,
|
||||
atmos: 0,
|
||||
weaponCode: 'None',
|
||||
bookCode: 'None',
|
||||
horseCode: 'None',
|
||||
itemCode: 'None',
|
||||
turnTime: new Date('2026-01-01T00:00:00.000Z'),
|
||||
recentWarTime: null,
|
||||
age: 20,
|
||||
startAge: 20,
|
||||
personalCode: 'None',
|
||||
specialCode: 'None',
|
||||
special2Code: 'None',
|
||||
lastTurn: {},
|
||||
meta: { belong: 5, permission: 'normal' },
|
||||
penalty: {},
|
||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
};
|
||||
|
||||
const auth: GameSessionTokenPayload = {
|
||||
version: 1,
|
||||
profile: 'che:default',
|
||||
issuedAt: '2026-01-01T00:00:00.000Z',
|
||||
expiresAt: '2026-01-02T00:00:00.000Z',
|
||||
sessionId: 'session-22',
|
||||
user: { id: 'user-22', username: 'tester', displayName: 'Tester', roles: [] },
|
||||
sanctions: {},
|
||||
};
|
||||
|
||||
const baseNation = {
|
||||
id: 1,
|
||||
name: '위',
|
||||
level: 3,
|
||||
tech: 3_000,
|
||||
meta: {
|
||||
_updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
npc_nation_policy: {
|
||||
values: { reqNationRice: 456 },
|
||||
priority: ['천도', '천도'],
|
||||
},
|
||||
npc_general_policy: {
|
||||
priority: ['출병', '일반내정', '출병'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const baseWorld = {
|
||||
config: {
|
||||
stat: { max: 80, npcMax: 75 },
|
||||
environment: { unitSet: 'basic' },
|
||||
const: { develCost: 100 },
|
||||
},
|
||||
meta: {
|
||||
npc_nation_policy: { values: { reqNationGold: 123 } },
|
||||
npc_general_policy: {},
|
||||
},
|
||||
};
|
||||
|
||||
const createContext = (
|
||||
options: {
|
||||
me?: GeneralRow;
|
||||
nation?: typeof baseNation;
|
||||
world?: typeof baseWorld;
|
||||
requestCommand?: ReturnType<typeof vi.fn>;
|
||||
troopRows?: Array<{ troopLeaderId: number }>;
|
||||
cityRows?: Array<{ id: number }>;
|
||||
} = {}
|
||||
): { context: GameApiContext; findFirst: ReturnType<typeof vi.fn>; requestCommand: ReturnType<typeof vi.fn> } => {
|
||||
const requestCommand =
|
||||
options.requestCommand ??
|
||||
vi.fn(async () => ({
|
||||
type: 'setNationMeta',
|
||||
ok: true,
|
||||
nationId: 1,
|
||||
updatedAt: '2026-01-01T00:01:00.000Z',
|
||||
}));
|
||||
const findFirst = vi.fn(async () => options.me ?? baseGeneral);
|
||||
const db = {
|
||||
general: { findFirst },
|
||||
nation: { findUnique: vi.fn(async () => options.nation ?? baseNation) },
|
||||
worldState: { findFirst: vi.fn(async () => options.world ?? baseWorld) },
|
||||
troop: { findMany: vi.fn(async () => options.troopRows ?? [{ troopLeaderId: 101 }]) },
|
||||
city: { findMany: vi.fn(async () => options.cityRows ?? [{ id: 1 }, { id: 2 }]) },
|
||||
};
|
||||
const redisClient = { get: async () => null, set: async () => null };
|
||||
return {
|
||||
context: {
|
||||
db: db as unknown as DatabaseClient,
|
||||
redis: {} as RedisConnector['client'],
|
||||
turnDaemon: { requestCommand } as unknown as TurnDaemonTransport,
|
||||
battleSim: {} as GameApiContext['battleSim'],
|
||||
profile: { id: 'che', scenario: 'default', name: 'che:default' },
|
||||
auth,
|
||||
uploadDir: 'uploads',
|
||||
uploadPath: '/uploads',
|
||||
uploadPublicUrl: null,
|
||||
accessTokenStore: new RedisAccessTokenStore(redisClient, 'che:default'),
|
||||
flushStore: new InMemoryFlushStore(),
|
||||
gameTokenSecret: 'test-secret',
|
||||
},
|
||||
findFirst,
|
||||
requestCommand,
|
||||
};
|
||||
};
|
||||
|
||||
describe('NPC policy router', () => {
|
||||
it('loads server and nation overrides while calculating legacy zero-value hints from nation tech', async () => {
|
||||
const fixture = createContext();
|
||||
const result = await appRouter.createCaller(fixture.context).npc.getPolicy();
|
||||
|
||||
expect(fixture.findFirst).toHaveBeenCalledWith({ where: { userId: 'user-22' } });
|
||||
expect(result.currentNationPolicy).toMatchObject({ reqNationGold: 123, reqNationRice: 456 });
|
||||
expect(result.currentNationPriority).toEqual(['천도', '천도']);
|
||||
expect(result.currentGeneralActionPriority).toEqual(['출병', '일반내정', '출병']);
|
||||
expect(result.zeroPolicy).toMatchObject({
|
||||
reqNationGold: 10_000,
|
||||
reqNationRice: 12_000,
|
||||
reqNPCDevelGold: 3_000,
|
||||
reqNPCWarGold: 3_900,
|
||||
reqNPCWarRice: 3_900,
|
||||
reqHumanWarUrgentGold: 6_300,
|
||||
reqHumanWarUrgentRice: 6_300,
|
||||
reqHumanWarRecommandGold: 12_600,
|
||||
reqHumanWarRecommandRice: 12_600,
|
||||
});
|
||||
});
|
||||
|
||||
it('lets a secret-level reader load the page but rejects every mutation before daemon dispatch', async () => {
|
||||
const reader = { ...baseGeneral, officerLevel: 2 };
|
||||
const fixture = createContext({ me: reader });
|
||||
const caller = appRouter.createCaller(fixture.context);
|
||||
|
||||
await expect(caller.npc.getPolicy()).resolves.toMatchObject({ permissionLevel: 1 });
|
||||
await expect(caller.npc.setNationPriority(['천도'])).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||
await expect(caller.npc.setGeneralPriority(['출병', '일반내정'])).rejects.toMatchObject({
|
||||
code: 'FORBIDDEN',
|
||||
});
|
||||
await expect(caller.npc.setNationPolicy({ reqNationGold: 100 })).rejects.toMatchObject({
|
||||
code: 'FORBIDDEN',
|
||||
});
|
||||
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['군주', { ...baseGeneral, officerLevel: 12 }],
|
||||
['감찰권자', { ...baseGeneral, officerLevel: 1, meta: { belong: 0, permission: 'auditor' } }],
|
||||
['외교권자', { ...baseGeneral, officerLevel: 1, meta: { belong: 0, permission: 'ambassador' } }],
|
||||
])('%s can persist policy through the daemon-owned metadata command', async (_label, me) => {
|
||||
const fixture = createContext({ me });
|
||||
await expect(appRouter.createCaller(fixture.context).npc.setNationPriority(['천도', '천도'])).resolves.toEqual({
|
||||
ok: true,
|
||||
});
|
||||
expect(fixture.requestCommand).toHaveBeenCalledWith({
|
||||
type: 'setNationMeta',
|
||||
nationId: 1,
|
||||
updates: {
|
||||
npc_nation_policy: expect.objectContaining({
|
||||
priority: ['천도', '천도'],
|
||||
prioritySetter: '정책담당',
|
||||
prioritySetTime: expect.any(String),
|
||||
}),
|
||||
},
|
||||
expectedUpdatedAt: '2026-01-01T00:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('clamps legacy integer values, preserves float values, and validates troop ownership before dispatch', async () => {
|
||||
const fixture = createContext();
|
||||
const caller = appRouter.createCaller(fixture.context);
|
||||
|
||||
await caller.npc.setNationPolicy({
|
||||
reqNationGold: -100,
|
||||
safeRecruitCityPopulationRatio: -0.5,
|
||||
CombatForce: { 101: [1, 2] },
|
||||
});
|
||||
expect(fixture.requestCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
updates: {
|
||||
npc_nation_policy: expect.objectContaining({
|
||||
values: expect.objectContaining({
|
||||
reqNationGold: 0,
|
||||
safeRecruitCityPopulationRatio: -0.5,
|
||||
CombatForce: { 101: [1, 2] },
|
||||
}),
|
||||
}),
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
fixture.requestCommand.mockClear();
|
||||
await expect(caller.npc.setNationPolicy({ SupportForce: [999] })).rejects.toMatchObject({
|
||||
code: 'BAD_REQUEST',
|
||||
});
|
||||
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves duplicate legacy priority entries and enforces required general actions and ordering', async () => {
|
||||
const fixture = createContext();
|
||||
const caller = appRouter.createCaller(fixture.context);
|
||||
|
||||
await caller.npc.setGeneralPriority(['출병', '출병', '일반내정']);
|
||||
expect(fixture.requestCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
updates: {
|
||||
npc_general_policy: expect.objectContaining({
|
||||
priority: ['출병', '출병', '일반내정'],
|
||||
}),
|
||||
},
|
||||
})
|
||||
);
|
||||
await expect(caller.npc.setGeneralPriority(['일반내정', '출병'])).rejects.toMatchObject({
|
||||
code: 'BAD_REQUEST',
|
||||
});
|
||||
await expect(caller.npc.setGeneralPriority(['출병'])).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
||||
});
|
||||
|
||||
it('blocks nationless, penalized, and stale writers without changing lifecycle state directly', async () => {
|
||||
const nationless = createContext({ me: { ...baseGeneral, nationId: 0, officerLevel: 0 } });
|
||||
await expect(appRouter.createCaller(nationless.context).npc.getPolicy()).rejects.toMatchObject({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
});
|
||||
|
||||
const penalized = createContext({ me: { ...baseGeneral, penalty: { noChief: true } } });
|
||||
await expect(appRouter.createCaller(penalized.context).npc.getPolicy()).rejects.toMatchObject({
|
||||
code: 'FORBIDDEN',
|
||||
});
|
||||
|
||||
const staleCommand = vi.fn(async () => ({
|
||||
type: 'setNationMeta',
|
||||
ok: false,
|
||||
nationId: 1,
|
||||
reason: 'CONFLICT',
|
||||
}));
|
||||
const stale = createContext({ requestCommand: staleCommand });
|
||||
await expect(appRouter.createCaller(stale.context).npc.setNationPriority(['천도'])).rejects.toMatchObject({
|
||||
code: 'CONFLICT',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
TournamentState,
|
||||
} from '../src/tournament/types.js';
|
||||
import { applyBattle, applyPreBattleStage, settleTournamentOutcome } from '../src/tournament/worker.js';
|
||||
import { buildBettingPayouts } from '../src/tournament/workerHelpers.js';
|
||||
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
|
||||
|
||||
class MemoryRedis {
|
||||
@@ -209,6 +210,15 @@ const runTournamentToCompletion = async (options: {
|
||||
const delayTick = async (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
describe('tournament worker (in-memory)', () => {
|
||||
it('당첨자가 없으면 레거시와 같이 베팅금을 지급하거나 환불하지 않는다', () => {
|
||||
expect(
|
||||
buildBettingPayouts(10, [
|
||||
{ generalId: 1, targetId: 11, amount: 100 },
|
||||
{ generalId: 2, targetId: 12, amount: 200 },
|
||||
])
|
||||
).toEqual({ payouts: [], total: 300, refundAll: false });
|
||||
});
|
||||
|
||||
it('locks 64 applicants into eight groups of eight', async () => {
|
||||
const redis = new MemoryRedis();
|
||||
const store = new TournamentStore(redis, buildTournamentKeys('test-groups'));
|
||||
@@ -284,11 +294,33 @@ describe('tournament worker (in-memory)', () => {
|
||||
|
||||
const sent: TurnDaemonCommand[] = [];
|
||||
const transport: TurnDaemonTransport = {
|
||||
sendCommand: async (command) => {
|
||||
sendCommand: async () => 'unused',
|
||||
requestCommand: async (command) => {
|
||||
sent.push(command);
|
||||
return 'ok';
|
||||
if (command.type === 'tournamentReward') {
|
||||
return {
|
||||
type: 'tournamentReward',
|
||||
ok: true,
|
||||
winnerId: command.winnerId,
|
||||
runnerUpId: command.runnerUpId,
|
||||
rewarded: 2,
|
||||
missing: 0,
|
||||
totalGold: 100,
|
||||
totalExp: 10,
|
||||
};
|
||||
}
|
||||
if (command.type === 'tournamentBettingPayout') {
|
||||
return {
|
||||
type: 'tournamentBettingPayout',
|
||||
ok: true,
|
||||
bettingId: command.bettingId,
|
||||
processed: command.payouts.length,
|
||||
missing: 0,
|
||||
totalPayout: command.payouts.reduce((sum, payout) => sum + payout.amount, 0),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
requestCommand: async () => null,
|
||||
requestStatus: async () => null,
|
||||
};
|
||||
|
||||
@@ -302,6 +334,82 @@ describe('tournament worker (in-memory)', () => {
|
||||
if (bettingCommand && bettingCommand.type === 'tournamentBettingPayout') {
|
||||
expect(bettingCommand.payouts).toEqual([{ generalId: 1, amount: 300 }]);
|
||||
}
|
||||
expect(await store.getState()).toMatchObject({
|
||||
rewardSettled: true,
|
||||
bettingSettled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('정산 응답 실패 시 완료 표시를 남기지 않고 성공한 보상만 재시도에서 제외한다', async () => {
|
||||
const redis = new MemoryRedis();
|
||||
const store = new TournamentStore(redis, buildTournamentKeys('test-bet-retry'));
|
||||
const state = createTournamentState({
|
||||
stage: 0,
|
||||
auto: false,
|
||||
winnerId: 10,
|
||||
bettingId: 124,
|
||||
rewardSettled: false,
|
||||
bettingSettled: false,
|
||||
});
|
||||
await store.setMatches([{ id: 1, stage: 10, roundIndex: 0, attackerId: 10, defenderId: 11, winnerId: 10 }]);
|
||||
await store.setBettingEntries([{ generalId: 1, targetId: 10, amount: 100 }]);
|
||||
await store.setState(state);
|
||||
|
||||
let payoutAttempts = 0;
|
||||
const commands: TurnDaemonCommand[] = [];
|
||||
const transport: TurnDaemonTransport = {
|
||||
sendCommand: async () => 'unused',
|
||||
requestCommand: async (command) => {
|
||||
commands.push(command);
|
||||
if (command.type === 'tournamentReward') {
|
||||
return {
|
||||
type: 'tournamentReward',
|
||||
ok: true,
|
||||
winnerId: command.winnerId,
|
||||
runnerUpId: command.runnerUpId,
|
||||
rewarded: 2,
|
||||
missing: 0,
|
||||
totalGold: 100,
|
||||
totalExp: 10,
|
||||
};
|
||||
}
|
||||
if (command.type === 'tournamentBettingPayout') {
|
||||
payoutAttempts += 1;
|
||||
if (payoutAttempts === 1) {
|
||||
return {
|
||||
type: 'tournamentBettingPayout',
|
||||
ok: false,
|
||||
bettingId: command.bettingId,
|
||||
reason: '일시적 실패',
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'tournamentBettingPayout',
|
||||
ok: true,
|
||||
bettingId: command.bettingId,
|
||||
processed: 1,
|
||||
missing: 0,
|
||||
totalPayout: 100,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
requestStatus: async () => null,
|
||||
};
|
||||
|
||||
await expect(settleTournamentOutcome({ store, daemonTransport: transport, state })).rejects.toThrow(
|
||||
'일시적 실패'
|
||||
);
|
||||
const afterFailure = await store.getState();
|
||||
expect(afterFailure).toMatchObject({ rewardSettled: true, bettingSettled: false });
|
||||
|
||||
await settleTournamentOutcome({ store, daemonTransport: transport, state: afterFailure! });
|
||||
expect(await store.getState()).toMatchObject({ rewardSettled: true, bettingSettled: true });
|
||||
expect(commands.filter((command) => command.type === 'tournamentReward')).toHaveLength(1);
|
||||
expect(commands.filter((command) => command.type === 'tournamentBettingPayout')).toHaveLength(2);
|
||||
expect(
|
||||
commands.filter((command) => command.type === 'tournamentBettingPayout').map((command) => command.requestId)
|
||||
).toEqual(['tournament:124:betting-payout', 'tournament:124:betting-payout']);
|
||||
});
|
||||
|
||||
it('자동 오픈 후 참가자 보충(NPC/더미 포함)하고 결승까지 진행된다', async () => {
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { TurnCommandEnv, TurnSchedule, UnitSetDefinition } from '@sammo-ts/logic';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import { AutorunNationPolicy } from '../src/turn/ai/policies.js';
|
||||
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
|
||||
|
||||
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
|
||||
const general: TurnGeneral = {
|
||||
id: 1,
|
||||
userId: 'owner-1',
|
||||
name: 'NPC군주',
|
||||
nationId: 1,
|
||||
cityId: 1,
|
||||
troopId: 0,
|
||||
stats: { leadership: 75, strength: 40, intelligence: 70 },
|
||||
turnTime: new Date('0185-01-01T00:00:00Z'),
|
||||
recentWarTime: null,
|
||||
role: {
|
||||
items: { horse: null, weapon: null, book: null, item: null },
|
||||
personality: null,
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
},
|
||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
meta: { killturn: 24 },
|
||||
penalty: {},
|
||||
officerLevel: 12,
|
||||
experience: 0,
|
||||
dedication: 0,
|
||||
injury: 0,
|
||||
gold: 1_000,
|
||||
rice: 1_000,
|
||||
crew: 0,
|
||||
crewTypeId: 1100,
|
||||
train: 0,
|
||||
atmos: 0,
|
||||
age: 30,
|
||||
npcState: 2,
|
||||
};
|
||||
|
||||
const snapshot: TurnWorldSnapshot = {
|
||||
generals: [general],
|
||||
cities: [
|
||||
{
|
||||
id: 1,
|
||||
name: '허창',
|
||||
nationId: 1,
|
||||
level: 7,
|
||||
state: 0,
|
||||
population: 100_000,
|
||||
populationMax: 200_000,
|
||||
agriculture: 1_000,
|
||||
agricultureMax: 2_000,
|
||||
commerce: 1_000,
|
||||
commerceMax: 2_000,
|
||||
security: 1_000,
|
||||
securityMax: 2_000,
|
||||
supplyState: 1,
|
||||
frontState: 0,
|
||||
defence: 1_000,
|
||||
defenceMax: 2_000,
|
||||
wall: 1_000,
|
||||
wallMax: 2_000,
|
||||
meta: {},
|
||||
},
|
||||
],
|
||||
nations: [
|
||||
{
|
||||
id: 1,
|
||||
name: '위',
|
||||
color: '#777777',
|
||||
capitalCityId: 1,
|
||||
chiefGeneralId: 1,
|
||||
gold: 10_000,
|
||||
rice: 20_000,
|
||||
power: 0,
|
||||
level: 3,
|
||||
typeCode: 'che_법가',
|
||||
meta: { tech: 3_000, preserved: 'yes', _updatedAt: '2026-01-01T00:00:00.000Z' },
|
||||
},
|
||||
],
|
||||
troops: [],
|
||||
diplomacy: [],
|
||||
events: [],
|
||||
initialEvents: [],
|
||||
scenarioConfig: {
|
||||
stat: { total: 300, min: 10, max: 80, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 65 },
|
||||
iconPath: '',
|
||||
map: {},
|
||||
const: {},
|
||||
environment: { mapName: 'test', unitSet: 'basic' },
|
||||
},
|
||||
scenarioMeta: {
|
||||
title: 'test',
|
||||
startYear: 180,
|
||||
life: null,
|
||||
fiction: null,
|
||||
history: [],
|
||||
ignoreDefaultEvents: false,
|
||||
},
|
||||
map: {
|
||||
id: 'test',
|
||||
name: 'test',
|
||||
cities: [],
|
||||
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
|
||||
},
|
||||
};
|
||||
|
||||
const state: TurnWorldState = {
|
||||
id: 1,
|
||||
currentYear: 185,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
lastTurnTime: new Date('0185-01-01T00:00:00Z'),
|
||||
meta: { killturn: 24 },
|
||||
};
|
||||
|
||||
const commandEnv: TurnCommandEnv = {
|
||||
baseGold: 1_000,
|
||||
baseRice: 1_000,
|
||||
develCost: 18,
|
||||
maxResourceActionAmount: 10_000,
|
||||
minAvailableRecruitPop: 30_000,
|
||||
trainDelta: 5,
|
||||
atmosDelta: 5,
|
||||
maxTrainByCommand: 100,
|
||||
maxAtmosByCommand: 100,
|
||||
sabotageDefaultProb: 0.5,
|
||||
sabotageProbCoefByStat: 0.01,
|
||||
sabotageDefenceCoefByGeneralCount: 0.01,
|
||||
sabotageDamageMin: 1,
|
||||
sabotageDamageMax: 10,
|
||||
defaultCrewTypeId: 1100,
|
||||
maxGeneral: 100,
|
||||
defaultNpcGold: 1_000,
|
||||
defaultNpcRice: 1_000,
|
||||
defaultSpecialDomestic: null,
|
||||
defaultSpecialWar: null,
|
||||
openingPartYear: 3,
|
||||
initialNationGenLimit: 10,
|
||||
maxTechLevel: 10,
|
||||
techLevelIncYear: 5,
|
||||
initialAllowedTechLevel: 1,
|
||||
};
|
||||
|
||||
const unitSet: UnitSetDefinition = {
|
||||
id: 'basic',
|
||||
name: 'basic',
|
||||
defaultCrewTypeId: 1100,
|
||||
armTypes: { 1: '보병' },
|
||||
crewTypes: [
|
||||
{
|
||||
id: 1100,
|
||||
armType: 1,
|
||||
name: '보병',
|
||||
attack: 100,
|
||||
defence: 150,
|
||||
speed: 7,
|
||||
avoid: 10,
|
||||
magicCoef: 0,
|
||||
cost: 9,
|
||||
rice: 9,
|
||||
requirements: [],
|
||||
attackCoef: {},
|
||||
defenceCoef: {},
|
||||
info: [],
|
||||
initSkillTrigger: null,
|
||||
phaseSkillTrigger: null,
|
||||
iActionList: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('NPC policy lifecycle', () => {
|
||||
it('applies one CAS-protected metadata command and the next AI instance consumes it without scheduler changes', async () => {
|
||||
const world = new InMemoryTurnWorld(state, snapshot, { schedule });
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
const updates = {
|
||||
npc_nation_policy: {
|
||||
values: { reqNationGold: 4_321 },
|
||||
priority: ['천도'],
|
||||
valueSetter: '정책담당',
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
handler.handle({
|
||||
type: 'setNationMeta',
|
||||
nationId: 1,
|
||||
updates,
|
||||
expectedUpdatedAt: '2026-01-01T00:00:00.000Z',
|
||||
})
|
||||
).resolves.toMatchObject({ type: 'setNationMeta', ok: true, nationId: 1 });
|
||||
|
||||
const nation = world.getNationById(1)!;
|
||||
expect(nation.meta).toMatchObject({ preserved: 'yes', npc_nation_policy: updates.npc_nation_policy });
|
||||
const policy = new AutorunNationPolicy({
|
||||
general: world.getGeneralById(1)!,
|
||||
aiOptions: null,
|
||||
nationPolicy: asRecord(nation.meta).npc_nation_policy as Record<string, unknown>,
|
||||
serverPolicy: null,
|
||||
nation,
|
||||
env: commandEnv,
|
||||
scenarioConfig: snapshot.scenarioConfig,
|
||||
unitSet,
|
||||
});
|
||||
expect(policy.reqNationGold).toBe(4_321);
|
||||
expect(policy.priority).toEqual(['천도']);
|
||||
expect(policy.reqNpcDevelGold).toBe(540);
|
||||
expect(policy.reqNpcWarGold).toBe(3_900);
|
||||
expect(policy.reqNpcWarRice).toBe(3_900);
|
||||
|
||||
await expect(
|
||||
handler.handle({
|
||||
type: 'setNationMeta',
|
||||
nationId: 1,
|
||||
updates: { npc_nation_policy: { values: { reqNationGold: 9_999 } } },
|
||||
expectedUpdatedAt: '2026-01-01T00:00:00.000Z',
|
||||
})
|
||||
).resolves.toMatchObject({ type: 'setNationMeta', ok: false, reason: 'CONFLICT' });
|
||||
expect(asRecord(asRecord(world.getNationById(1)?.meta).npc_nation_policy).values).toEqual({
|
||||
reqNationGold: 4_321,
|
||||
});
|
||||
expect(world.getState()).toMatchObject({ currentYear: 185, currentMonth: 1, tickSeconds: 600 });
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,28 @@
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const response = (data: unknown) => ({ result: { data } });
|
||||
const artifactRoot = process.env.CITY_PARITY_ARTIFACT_DIR;
|
||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||
const imageRoots = [resolve(repositoryRoot, '../image'), resolve(repositoryRoot, '../../image')];
|
||||
const readImage = async (relativePath: string): Promise<Buffer> => {
|
||||
if (relativePath.includes('..')) throw new Error(`Unsafe fixture image path: ${relativePath}`);
|
||||
for (const root of imageRoots) {
|
||||
try {
|
||||
return await readFile(resolve(root, relativePath));
|
||||
} catch {
|
||||
// Product checkout and feature worktrees have different image-root parents.
|
||||
}
|
||||
}
|
||||
throw new Error(`Fixture image not found: ${relativePath}`);
|
||||
};
|
||||
const imageContentType = (relativePath: string) => {
|
||||
if (relativePath.endsWith('.png')) return 'image/png';
|
||||
if (relativePath.endsWith('.gif')) return 'image/gif';
|
||||
return 'image/jpeg';
|
||||
};
|
||||
const operationNames = (route: Route) =>
|
||||
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
|
||||
const city = {
|
||||
@@ -46,19 +68,68 @@ const layout = {
|
||||
regionMap: { 1: '하북' },
|
||||
levelMap: { 8: '특' },
|
||||
};
|
||||
const generalContext = {
|
||||
general: {
|
||||
id: 1,
|
||||
name: '장수',
|
||||
nationId: 1,
|
||||
cityId: 1,
|
||||
officerLevel: 1,
|
||||
npcState: 0,
|
||||
troopId: 0,
|
||||
picture: null,
|
||||
imageServer: 0,
|
||||
stats: { leadership: 70, strength: 60, intelligence: 50 },
|
||||
gold: 1000,
|
||||
rice: 1000,
|
||||
crew: 500,
|
||||
train: 90,
|
||||
atmos: 90,
|
||||
injury: 0,
|
||||
experience: 0,
|
||||
dedication: 0,
|
||||
items: { horse: 'None', weapon: 'None', book: 'None', item: 'None' },
|
||||
},
|
||||
city,
|
||||
nation: { id: 1, name: '아국', color: '#008000', level: 1 },
|
||||
settings: {},
|
||||
penalties: {},
|
||||
};
|
||||
const emptyMessages = {
|
||||
private: [],
|
||||
national: [],
|
||||
public: [],
|
||||
diplomacy: [],
|
||||
sequence: -1,
|
||||
hasMore: { private: false, national: false, public: false, diplomacy: false },
|
||||
latestRead: { private: 0, national: 0, public: 0, diplomacy: 0 },
|
||||
canRespondDiplomacy: false,
|
||||
};
|
||||
|
||||
const install = async (page: Page, mode: 'member' | 'wanderer' | 'admin' = 'member') => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('sammo-game-token', 'ga_info');
|
||||
localStorage.setItem('sammo-game-profile', 'che:default');
|
||||
});
|
||||
await page.route('**/image/game/**', (route) =>
|
||||
route.fulfill({ status: 200, contentType: 'image/jpeg', body: Buffer.from('') })
|
||||
);
|
||||
await page.route('**/image/**', async (route) => {
|
||||
const relativePath = decodeURIComponent(new URL(route.request().url()).pathname.split('/image/')[1] ?? '');
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: imageContentType(relativePath),
|
||||
body: await readImage(relativePath),
|
||||
});
|
||||
});
|
||||
await page.route('**/che/api/trpc/**', async (route) => {
|
||||
const results = operationNames(route).map((operation) => {
|
||||
if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: '장수' } });
|
||||
if (operation === 'join.getConfig') return response({});
|
||||
if (operation === 'general.me') return response(generalContext);
|
||||
if (operation === 'world.getMap') return response(map);
|
||||
if (operation === 'turns.getCommandTable') return response({ general: [], nation: [] });
|
||||
if (operation === 'turns.reserved.getGeneral') return response([]);
|
||||
if (operation === 'messages.getRecent') return response(emptyMessages);
|
||||
if (operation === 'board.getAccess') return response({ canMeeting: false, canSecret: false });
|
||||
if (operation === 'tournament.getState') return response({ stage: 0 });
|
||||
if (operation === 'nation.getNationInfo')
|
||||
return response({
|
||||
nation: {
|
||||
@@ -158,6 +229,7 @@ const install = async (page: Page, mode: 'member' | 'wanderer' | 'admin' = 'memb
|
||||
id: 1,
|
||||
name: '업',
|
||||
nationId: 1,
|
||||
nationColor: '#008000',
|
||||
level: 8,
|
||||
region: 1,
|
||||
population: mode === 'wanderer' ? null : 150000,
|
||||
@@ -193,14 +265,30 @@ const install = async (page: Page, mode: 'member' | 'wanderer' | 'admin' = 'memb
|
||||
intelligence: 50,
|
||||
injury: 0,
|
||||
officerLevel: 1,
|
||||
leadershipBonus: 0,
|
||||
defenceTrain: 80,
|
||||
crewTypeId: 1,
|
||||
crewTypeName: '보병',
|
||||
crew: 500,
|
||||
train: 90,
|
||||
atmos: 90,
|
||||
turns: ['징병'],
|
||||
},
|
||||
],
|
||||
forceSummary: {
|
||||
enemyCrew: 0,
|
||||
enemyArmedGenerals: 0,
|
||||
enemyGenerals: 0,
|
||||
ownCrew: mode === 'wanderer' ? 0 : 500,
|
||||
ownArmedGenerals: mode === 'wanderer' ? 0 : 1,
|
||||
ownGenerals: mode === 'wanderer' ? 0 : 1,
|
||||
ready90Crew: mode === 'wanderer' ? 0 : 500,
|
||||
ready90Generals: mode === 'wanderer' ? 0 : 1,
|
||||
ready60Crew: mode === 'wanderer' ? 0 : 500,
|
||||
ready60Generals: mode === 'wanderer' ? 0 : 1,
|
||||
defenceReadyCrew: mode === 'wanderer' ? 0 : 500,
|
||||
defenceReadyGenerals: mode === 'wanderer' ? 0 : 1,
|
||||
},
|
||||
lastExecute: '2026-07-26',
|
||||
});
|
||||
return { error: { message: `unhandled ${operation}`, data: { code: 'BAD_REQUEST' } } };
|
||||
@@ -215,11 +303,11 @@ const go = async (page: Page, path: string) => {
|
||||
test('four legacy menu pages keep the 1000px desktop table contract', async ({ page }) => {
|
||||
await install(page);
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
for (const [path, selector] of [
|
||||
['nation/info', '.legacy-info-page'],
|
||||
['nation/cities', '.nation-cities-page'],
|
||||
['global-info', '.global-page'],
|
||||
['current-city', '.city-page'],
|
||||
for (const [path, selector, fontSize, fontFamily, borderCollapse] of [
|
||||
['nation/info', '.legacy-info-page', '14px', 'Pretendard', 'collapse'],
|
||||
['nation/cities', '.nation-cities-page', '14px', 'Pretendard', 'collapse'],
|
||||
['global-info', '.global-page', '14px', 'Pretendard', 'collapse'],
|
||||
['current-city', '.city-page', '16px', 'Times New Roman', 'separate'],
|
||||
] as const) {
|
||||
await go(page, path);
|
||||
await expect(page.locator(selector)).toBeVisible();
|
||||
@@ -230,14 +318,14 @@ test('four legacy menu pages keep the 1000px desktop table contract', async ({ p
|
||||
});
|
||||
expect(box.width).toBe(1000);
|
||||
expect(box.x).toBe(100);
|
||||
expect(box.fontSize).toBe('14px');
|
||||
expect(box.fontFamily).toContain('Pretendard');
|
||||
expect(box.fontSize).toBe(fontSize);
|
||||
expect(box.fontFamily).toContain(fontFamily);
|
||||
expect(
|
||||
await page
|
||||
.locator('table')
|
||||
.first()
|
||||
.evaluate((el) => getComputedStyle(el).borderCollapse)
|
||||
).toBe('collapse');
|
||||
).toBe(borderCollapse);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -250,7 +338,100 @@ test('current-city hides values and general rows for a wandering user', async ({
|
||||
|
||||
test('current-city exposes own general details to a member and admin fixture', async ({ page }) => {
|
||||
await install(page, 'admin');
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
await go(page, 'current-city');
|
||||
await expect(page.locator('.generals')).toContainText('장수');
|
||||
await expect(page.locator('.generals')).toContainText('90');
|
||||
const legacyGeometry = await page.evaluate(() => {
|
||||
const rect = (selector: string) => {
|
||||
const box = document.querySelector(selector)?.getBoundingClientRect();
|
||||
return box ? { x: box.x, y: box.y, width: box.width, height: box.height } : null;
|
||||
};
|
||||
const icon = document.querySelector<HTMLImageElement>('.general-icon');
|
||||
return {
|
||||
selector: rect('#citySelector'),
|
||||
stats: rect('.stats'),
|
||||
generals: rect('.generals'),
|
||||
titleAlign: getComputedStyle(document.querySelector('.city-page > table:first-child td')!).textAlign,
|
||||
icon: icon
|
||||
? {
|
||||
...rect('.general-icon'),
|
||||
naturalWidth: icon.naturalWidth,
|
||||
naturalHeight: icon.naturalHeight,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
});
|
||||
expect(legacyGeometry.selector).toMatchObject({ width: 400, height: 19 });
|
||||
expect(legacyGeometry.stats).toEqual({ x: 100, y: 178, width: 1000, height: 136 });
|
||||
expect(legacyGeometry.generals).toMatchObject({ x: 88, y: 332, width: 1024 });
|
||||
expect(legacyGeometry.titleAlign).toBe('start');
|
||||
expect(legacyGeometry.icon).toMatchObject({ width: 64, height: 64, naturalWidth: 64, naturalHeight: 64 });
|
||||
if (artifactRoot) {
|
||||
await mkdir(artifactRoot, { recursive: true });
|
||||
const computedDom = await page.evaluate(() => {
|
||||
const measure = (selector: string) => {
|
||||
const element = document.querySelector(selector);
|
||||
if (!element) return null;
|
||||
const box = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
rect: { x: box.x, y: box.y, width: box.width, height: box.height },
|
||||
style: {
|
||||
fontFamily: style.fontFamily,
|
||||
fontSize: style.fontSize,
|
||||
lineHeight: style.lineHeight,
|
||||
color: style.color,
|
||||
backgroundColor: style.backgroundColor,
|
||||
backgroundImage: style.backgroundImage,
|
||||
borderCollapse: style.borderCollapse,
|
||||
padding: style.padding,
|
||||
textAlign: style.textAlign,
|
||||
},
|
||||
};
|
||||
};
|
||||
const icon = document.querySelector<HTMLImageElement>('.general-icon');
|
||||
return {
|
||||
body: measure('body'),
|
||||
page: measure('.city-page'),
|
||||
selector: measure('#citySelector'),
|
||||
stats: measure('.stats'),
|
||||
generals: measure('.generals'),
|
||||
title: measure('.city-title'),
|
||||
firstIcon: icon
|
||||
? {
|
||||
...measure('.general-icon'),
|
||||
naturalWidth: icon.naturalWidth,
|
||||
naturalHeight: icon.naturalHeight,
|
||||
}
|
||||
: null,
|
||||
document: {
|
||||
width: document.documentElement.scrollWidth,
|
||||
height: document.documentElement.scrollHeight,
|
||||
},
|
||||
};
|
||||
});
|
||||
await writeFile(
|
||||
resolve(artifactRoot, 'core-current-city-computed-dom.json'),
|
||||
`${JSON.stringify(computedDom, null, 2)}\n`
|
||||
);
|
||||
await page.screenshot({
|
||||
path: resolve(artifactRoot, 'core-current-city-desktop.png'),
|
||||
fullPage: true,
|
||||
animations: 'disabled',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('a Chromium map click opens the clicked city route and keeps the legacy pointer interaction', async ({ page }) => {
|
||||
await install(page);
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
await go(page, '');
|
||||
const cityLink = page.locator('.map-city').first();
|
||||
await expect(cityLink).toBeVisible();
|
||||
await cityLink.hover();
|
||||
await expect(cityLink).toHaveCSS('cursor', 'pointer');
|
||||
await cityLink.click();
|
||||
await expect(page).toHaveURL(/\/che\/current-city\?cityId=1$/);
|
||||
await expect(page.locator('.stats')).toContainText('업');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
|
||||
const response = (data: unknown) => ({ result: { data } });
|
||||
const operations = (route: Route) =>
|
||||
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
|
||||
const general = {
|
||||
id: 1,
|
||||
name: '테스트장수',
|
||||
npcState: 0,
|
||||
officerLevel: 1,
|
||||
cityId: 1,
|
||||
cityName: null,
|
||||
troopId: 0,
|
||||
troopName: null,
|
||||
officerCity: 0,
|
||||
officerCityName: null,
|
||||
stats: { leadership: 70, strength: 60, intelligence: 50 },
|
||||
experienceLevel: 9,
|
||||
dedicationLevel: 1,
|
||||
injury: 0,
|
||||
gold: 1000,
|
||||
rice: 2000,
|
||||
personality: null,
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
belong: 1,
|
||||
refreshScoreTotal: 10,
|
||||
permission: 'normal',
|
||||
};
|
||||
const install = async (page: Page, secretAllowed = true) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('sammo-game-token', 'ga_general');
|
||||
localStorage.setItem('sammo-game-profile', 'che:default');
|
||||
});
|
||||
await page.route('**/che/api/trpc/**', async (route) => {
|
||||
const results = operations(route).map((operation) => {
|
||||
if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: '테스트장수' } });
|
||||
if (operation === 'join.getConfig') return response({});
|
||||
if (operation === 'nation.getGeneralList')
|
||||
return response({
|
||||
nation: { id: 1, name: '위', color: '#008000', level: 3 },
|
||||
viewer: { generalId: 1, permission: 0 },
|
||||
generals: [general],
|
||||
});
|
||||
if (operation === 'nation.getSecretGeneralList') {
|
||||
if (!secretAllowed)
|
||||
return {
|
||||
error: {
|
||||
message: '권한이 부족합니다.',
|
||||
code: -32000,
|
||||
data: { code: 'FORBIDDEN', httpStatus: 403, path: operation },
|
||||
},
|
||||
};
|
||||
return response({
|
||||
nation: { id: 1, name: '위', color: '#008000', level: 3 },
|
||||
viewer: { generalId: 1, permission: 1 },
|
||||
summary: {
|
||||
gold: 1000,
|
||||
rice: 2000,
|
||||
crew: 300,
|
||||
generalCount: 1,
|
||||
averageGold: 1000,
|
||||
averageRice: 2000,
|
||||
readiness: {
|
||||
90: { crew: 300, generals: 1 },
|
||||
80: { crew: 300, generals: 1 },
|
||||
60: { crew: 300, generals: 1 },
|
||||
},
|
||||
},
|
||||
generals: [
|
||||
{
|
||||
id: 1,
|
||||
name: '테스트장수',
|
||||
npcState: 0,
|
||||
injury: 0,
|
||||
stats: { leadership: 70, strength: 60, intelligence: 50 },
|
||||
leadershipBonus: 0,
|
||||
experienceLevel: 9,
|
||||
troopId: 0,
|
||||
troopName: null,
|
||||
gold: 1000,
|
||||
rice: 2000,
|
||||
cityId: 1,
|
||||
cityName: '업',
|
||||
defenceTrain: 90,
|
||||
defenceTrainText: '☆',
|
||||
crewTypeId: 1,
|
||||
crew: 300,
|
||||
train: 90,
|
||||
atmos: 90,
|
||||
killTurn: 7,
|
||||
turnTime: '2026-01-01T01:02:00.000Z',
|
||||
reservedCommands: ['징병', '훈련'],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
return { error: { message: `unhandled ${operation}`, data: { code: 'BAD_REQUEST' } } };
|
||||
});
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) });
|
||||
});
|
||||
};
|
||||
|
||||
test('nation generals keeps the 1000px legacy grid and redacted member columns', async ({ page }) => {
|
||||
await install(page);
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
await page.goto('nation/generals');
|
||||
await expect(page.locator('#nation-general-list')).toContainText('테스트장수');
|
||||
await expect(page.locator('#nation-general-list')).toContainText('?');
|
||||
const computed = await page.locator('.general-page').evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return { x: rect.x, width: rect.width, fontSize: style.fontSize, fontFamily: style.fontFamily };
|
||||
});
|
||||
expect(computed).toMatchObject({ x: 100, width: 1000, fontSize: '16px' });
|
||||
expect(computed.fontFamily).toContain('Times New Roman');
|
||||
expect(await page.locator('#nation-general-list').evaluate((el) => getComputedStyle(el).borderCollapse)).toBe(
|
||||
'separate'
|
||||
);
|
||||
expect((await page.locator('#nation-general-list').boundingBox())?.width).toBe(1030);
|
||||
expect((await page.locator('#nation-general-list tbody tr').boundingBox())?.height).toBe(66);
|
||||
});
|
||||
|
||||
test('both pages preserve the legacy 1000px overflow contract at 500px', async ({ page }) => {
|
||||
await install(page);
|
||||
await page.setViewportSize({ width: 500, height: 900 });
|
||||
for (const path of ['nation/generals', 'nation/secret']) {
|
||||
await page.goto(path);
|
||||
await expect(
|
||||
page.locator(path.endsWith('secret') ? '#secret-general-list' : '#nation-general-list')
|
||||
).toBeVisible();
|
||||
expect(await page.locator('main').evaluate((el) => el.getBoundingClientRect().width)).toBe(1000);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeGreaterThanOrEqual(1000);
|
||||
}
|
||||
});
|
||||
|
||||
test('secret office renders summary, turns, and the forbidden error flow', async ({ page }) => {
|
||||
await install(page);
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
await page.goto('nation/secret');
|
||||
await expect(page.locator('.summary')).toContainText('전체 금');
|
||||
await expect(page.locator('#secret-general-list')).toContainText('1 : 징병');
|
||||
expect(await page.locator('.secret-page').evaluate((el) => el.getBoundingClientRect().width)).toBe(1000);
|
||||
|
||||
await page.unroute('**/che/api/trpc/**');
|
||||
await install(page, false);
|
||||
await page.goto('nation/secret');
|
||||
await expect(page.getByRole('alert')).toContainText('권한이 부족합니다.');
|
||||
await expect(page.locator('#secret-general-list')).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,338 @@
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
import { mkdir, readFile } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
type FixtureState = {
|
||||
permissionLevel: number;
|
||||
failNextMutation?: boolean;
|
||||
failLoad?: boolean;
|
||||
mutations: string[];
|
||||
};
|
||||
|
||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||
const artifactRoot = process.env.NPC_POLICY_PARITY_ARTIFACT_DIR
|
||||
? resolve(process.env.NPC_POLICY_PARITY_ARTIFACT_DIR)
|
||||
: null;
|
||||
const imageRoots = [resolve(repositoryRoot, '../image'), resolve(repositoryRoot, '../../image')];
|
||||
const referenceAsset = async (relativePath: string): Promise<Buffer> => {
|
||||
for (const root of imageRoots) {
|
||||
try {
|
||||
return await readFile(resolve(root, relativePath));
|
||||
} catch {
|
||||
// Nested worktrees and the primary checkout have different image parents.
|
||||
}
|
||||
}
|
||||
throw new Error(`Reference image not found: ${relativePath}`);
|
||||
};
|
||||
|
||||
const response = (data: unknown) => ({ result: { data } });
|
||||
const errorResponse = (path: string, message: string, code = 'BAD_REQUEST') => ({
|
||||
error: { message, code: -32000, data: { code, httpStatus: code === 'FORBIDDEN' ? 403 : 400, path } },
|
||||
});
|
||||
const operationName = (route: Route): string => {
|
||||
const url = new URL(route.request().url());
|
||||
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6));
|
||||
};
|
||||
const fulfillJson = (route: Route, body: unknown) =>
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
|
||||
|
||||
const nationPriority = [
|
||||
'불가침제의',
|
||||
'선전포고',
|
||||
'천도',
|
||||
'유저장긴급포상',
|
||||
'부대전방발령',
|
||||
'유저장구출발령',
|
||||
'유저장후방발령',
|
||||
'부대유저장후방발령',
|
||||
'유저장전방발령',
|
||||
'유저장포상',
|
||||
'부대구출발령',
|
||||
'부대후방발령',
|
||||
'NPC긴급포상',
|
||||
'NPC구출발령',
|
||||
'NPC후방발령',
|
||||
'NPC포상',
|
||||
'NPC전방발령',
|
||||
'유저장내정발령',
|
||||
'NPC내정발령',
|
||||
'NPC몰수',
|
||||
];
|
||||
const generalPriority = [
|
||||
'NPC사망대비',
|
||||
'귀환',
|
||||
'금쌀구매',
|
||||
'출병',
|
||||
'긴급내정',
|
||||
'전투준비',
|
||||
'전방워프',
|
||||
'NPC헌납',
|
||||
'징병',
|
||||
'후방워프',
|
||||
'전쟁내정',
|
||||
'소집해제',
|
||||
'일반내정',
|
||||
'내정워프',
|
||||
];
|
||||
const policy = {
|
||||
reqNationGold: 10_000,
|
||||
reqNationRice: 12_000,
|
||||
CombatForce: {},
|
||||
SupportForce: [],
|
||||
DevelopForce: [],
|
||||
reqHumanWarUrgentGold: 0,
|
||||
reqHumanWarUrgentRice: 0,
|
||||
reqHumanWarRecommandGold: 0,
|
||||
reqHumanWarRecommandRice: 0,
|
||||
reqHumanDevelGold: 10_000,
|
||||
reqHumanDevelRice: 10_000,
|
||||
reqNPCWarGold: 0,
|
||||
reqNPCWarRice: 0,
|
||||
reqNPCDevelGold: 0,
|
||||
reqNPCDevelRice: 500,
|
||||
minimumResourceActionAmount: 1_000,
|
||||
maximumResourceActionAmount: 10_000,
|
||||
minNPCWarLeadership: 40,
|
||||
minWarCrew: 1_500,
|
||||
minNPCRecruitCityPopulation: 50_000,
|
||||
safeRecruitCityPopulationRatio: 0.5,
|
||||
properWarTrainAtmos: 90,
|
||||
cureThreshold: 10,
|
||||
};
|
||||
|
||||
const policyFixture = (state: FixtureState) => ({
|
||||
nationId: 1,
|
||||
nationName: '위',
|
||||
nationLevel: 3,
|
||||
defaultNationPolicy: policy,
|
||||
currentNationPolicy: policy,
|
||||
zeroPolicy: {
|
||||
...policy,
|
||||
reqHumanWarUrgentGold: 7_600,
|
||||
reqHumanWarUrgentRice: 7_600,
|
||||
reqHumanWarRecommandGold: 15_200,
|
||||
reqHumanWarRecommandRice: 15_200,
|
||||
reqNPCWarGold: 2_700,
|
||||
reqNPCWarRice: 2_700,
|
||||
reqNPCDevelGold: 540,
|
||||
},
|
||||
defaultNationPriority: nationPriority,
|
||||
currentNationPriority: nationPriority,
|
||||
availableNationPriorityItems: nationPriority,
|
||||
defaultGeneralActionPriority: generalPriority,
|
||||
currentGeneralActionPriority: generalPriority,
|
||||
availableGeneralActionPriorityItems: generalPriority,
|
||||
lastSetters: {
|
||||
policy: { setter: null, date: null },
|
||||
nation: { setter: null, date: null },
|
||||
general: { setter: null, date: null },
|
||||
},
|
||||
defaultStatMax: 70,
|
||||
defaultStatNpcMax: 75,
|
||||
permissionLevel: state.permissionLevel,
|
||||
});
|
||||
|
||||
const installFixture = async (page: Page, state: FixtureState) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('sammo-game-token', 'ga_npc_policy_playwright');
|
||||
localStorage.setItem('sammo-game-profile', 'che:default');
|
||||
});
|
||||
for (const filename of ['back_walnut.jpg', 'back_green.jpg', 'back_blue.jpg']) {
|
||||
await page.route(`**/image/game/${filename}`, async (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'image/jpeg',
|
||||
body: await referenceAsset(`game/${filename}`),
|
||||
})
|
||||
);
|
||||
}
|
||||
await page.route('**/che/api/trpc/**', async (route) => {
|
||||
const operations = operationName(route).split(',');
|
||||
const results = operations.map((operation) => {
|
||||
if (operation === 'lobby.info') return response({ myGeneral: { id: 22, name: '정책담당' } });
|
||||
if (operation === 'join.getConfig') return response({});
|
||||
if (operation === 'npc.getPolicy') {
|
||||
return state.failLoad
|
||||
? errorResponse(operation, '권한이 부족합니다.', 'FORBIDDEN')
|
||||
: response(policyFixture(state));
|
||||
}
|
||||
if (
|
||||
operation === 'npc.setNationPolicy' ||
|
||||
operation === 'npc.setNationPriority' ||
|
||||
operation === 'npc.setGeneralPriority'
|
||||
) {
|
||||
state.mutations.push(operation);
|
||||
if (state.failNextMutation) {
|
||||
state.failNextMutation = false;
|
||||
return errorResponse(operation, '권한이 부족합니다.', 'FORBIDDEN');
|
||||
}
|
||||
return response({ ok: true });
|
||||
}
|
||||
return errorResponse(operation, `Unhandled fixture operation: ${operation}`);
|
||||
});
|
||||
await fulfillJson(route, results);
|
||||
});
|
||||
};
|
||||
|
||||
const gotoPolicy = async (page: Page) => {
|
||||
await page.goto('npc-control');
|
||||
};
|
||||
|
||||
const screenshot = async (page: Page, name: string) => {
|
||||
if (!artifactRoot) return;
|
||||
await mkdir(artifactRoot, { recursive: true });
|
||||
await page.screenshot({ path: resolve(artifactRoot, name), fullPage: true });
|
||||
};
|
||||
|
||||
test('desktop geometry, typography, textures, drag, focus, tooltip, and successful save match the reference', async ({
|
||||
page,
|
||||
}) => {
|
||||
const state: FixtureState = { permissionLevel: 4, mutations: [] };
|
||||
await installFixture(page, state);
|
||||
await page.setViewportSize({ width: 1000, height: 900 });
|
||||
await gotoPolicy(page);
|
||||
await expect(page.locator('#container')).toBeVisible();
|
||||
|
||||
const computed = await page.evaluate(() => {
|
||||
const measure = (selector: string) => {
|
||||
const element = document.querySelector<HTMLElement>(selector)!;
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
display: style.display,
|
||||
gridTemplateColumns: style.gridTemplateColumns,
|
||||
fontFamily: style.fontFamily,
|
||||
fontSize: style.fontSize,
|
||||
lineHeight: style.lineHeight,
|
||||
color: style.color,
|
||||
backgroundImage: style.backgroundImage,
|
||||
backgroundColor: style.backgroundColor,
|
||||
};
|
||||
};
|
||||
return {
|
||||
body: measure('body'),
|
||||
container: measure('#container'),
|
||||
topBar: measure('.top-back-bar'),
|
||||
section: measure('.section_bar'),
|
||||
form: measure('.form_list'),
|
||||
field: measure('.policy-field'),
|
||||
input: measure('.field-row input'),
|
||||
control: measure('.control_bar'),
|
||||
reset: measure('.reset_btn'),
|
||||
submit: measure('.submit_btn'),
|
||||
priorityPanel: measure('.priority-panel'),
|
||||
priorityList: measure('.priority-list'),
|
||||
inactiveHeader: measure('.inactive-header'),
|
||||
activeItem: measure('.priority-column:nth-child(2) .priority-item'),
|
||||
help: measure('.help-button'),
|
||||
documentWidth: document.documentElement.scrollWidth,
|
||||
};
|
||||
});
|
||||
|
||||
expect(computed.body).toMatchObject({ width: 1000, fontSize: '14px', lineHeight: '21px' });
|
||||
expect(computed.body.fontFamily).toContain('Pretendard');
|
||||
expect(computed.container).toMatchObject({ x: 0, y: 32, width: 1000 });
|
||||
expect(computed.container.backgroundImage).toContain('back_walnut.jpg');
|
||||
expect(computed.topBar).toMatchObject({ width: 1000, height: 32 });
|
||||
expect(computed.section).toMatchObject({ x: 1, y: 33, width: 998, height: 23 });
|
||||
expect(computed.section.backgroundImage).toContain('back_green.jpg');
|
||||
expect(computed.form).toMatchObject({ x: 9, width: 982 });
|
||||
expect(computed.form.gridTemplateColumns).toBe('491px 491px');
|
||||
expect(computed.field.width).toBeCloseTo(491, 0);
|
||||
expect(computed.input).toMatchObject({ width: 224, height: 34 });
|
||||
expect(computed.reset).toMatchObject({ width: 150, height: 35.5, backgroundColor: 'rgb(48, 48, 48)' });
|
||||
expect(computed.submit).toMatchObject({ width: 150, height: 35.5, backgroundColor: 'rgb(55, 90, 127)' });
|
||||
expect(computed.priorityPanel.width).toBeCloseTo(499, 0);
|
||||
expect(computed.priorityList.width).toBeCloseTo(229, 0);
|
||||
expect(computed.inactiveHeader).toMatchObject({ height: 37, backgroundColor: 'rgb(214, 214, 214)' });
|
||||
expect(computed.activeItem.height).toBe(37);
|
||||
expect(computed.help).toMatchObject({ width: 24, height: 22.375 });
|
||||
expect(computed.documentWidth).toBe(1000);
|
||||
await screenshot(page, 'core-npc-policy-desktop-baseline.png');
|
||||
|
||||
const goldInput = page.getByLabel('국가 권장 금');
|
||||
await goldInput.focus();
|
||||
await expect(goldInput).toBeFocused();
|
||||
expect(await goldInput.evaluate((element) => getComputedStyle(element).outlineStyle)).not.toBe('none');
|
||||
|
||||
const help = page.getByRole('button', { name: '불가침제의 설명' });
|
||||
await help.hover();
|
||||
await expect.poll(() => help.evaluate((element) => getComputedStyle(element, '::after').opacity)).toBe('1');
|
||||
|
||||
const active = page.locator('.priority-panel').first().locator('.priority-column').nth(1).getByText('불가침제의');
|
||||
await active.dragTo(
|
||||
page.locator('.priority-panel').first().locator('.priority-column').first().locator('.priority-list')
|
||||
);
|
||||
await expect(
|
||||
page.locator('.priority-panel').first().locator('.priority-column').first().getByText('불가침제의')
|
||||
).toBeVisible();
|
||||
|
||||
await goldInput.fill('12345');
|
||||
page.once('dialog', (dialog) => dialog.accept());
|
||||
await page.locator('#container > .control_bar').getByRole('button', { name: '설정' }).click();
|
||||
await expect(page.getByRole('status')).toContainText('NPC 정책이 반영되었습니다.');
|
||||
expect(state.mutations).toContain('npc.setNationPolicy');
|
||||
await screenshot(page, 'core-npc-policy-desktop.png');
|
||||
});
|
||||
|
||||
test('500px layout stacks policy fields and priority panels like the reference', async ({ page }) => {
|
||||
await installFixture(page, { permissionLevel: 4, mutations: [] });
|
||||
await page.setViewportSize({ width: 500, height: 900 });
|
||||
await gotoPolicy(page);
|
||||
await expect(page.locator('#container')).toBeVisible();
|
||||
|
||||
const geometry = await page.evaluate(() => {
|
||||
const rect = (selector: string) => {
|
||||
const value = document.querySelector<HTMLElement>(selector)!.getBoundingClientRect();
|
||||
return { x: value.x, y: value.y, width: value.width, height: value.height };
|
||||
};
|
||||
return {
|
||||
container: rect('#container'),
|
||||
form: rect('.form_list'),
|
||||
firstField: rect('.policy-field'),
|
||||
panels: [...document.querySelectorAll<HTMLElement>('.priority-panel')].map((element) => {
|
||||
const value = element.getBoundingClientRect();
|
||||
return { x: value.x, y: value.y, width: value.width };
|
||||
}),
|
||||
documentWidth: document.documentElement.scrollWidth,
|
||||
};
|
||||
});
|
||||
|
||||
expect(geometry.container).toMatchObject({ x: 0, y: 32, width: 500 });
|
||||
expect(geometry.form).toMatchObject({ x: 9, width: 482 });
|
||||
expect(geometry.firstField.width).toBeCloseTo(482, 0);
|
||||
expect(geometry.panels).toHaveLength(2);
|
||||
expect(geometry.panels[0]).toMatchObject({ x: 1, width: 498 });
|
||||
expect(geometry.panels[1]?.x).toBe(1);
|
||||
expect(geometry.panels[1]?.y).toBeGreaterThan(geometry.panels[0]?.y ?? 0);
|
||||
expect(geometry.documentWidth).toBe(500);
|
||||
await screenshot(page, 'core-npc-policy-mobile.png');
|
||||
});
|
||||
|
||||
test('a read-level user sees enabled legacy controls but a forbidden save retains the draft', async ({ page }) => {
|
||||
const state: FixtureState = { permissionLevel: 1, failNextMutation: true, mutations: [] };
|
||||
await installFixture(page, state);
|
||||
await gotoPolicy(page);
|
||||
|
||||
const input = page.getByLabel('국가 권장 금');
|
||||
await expect(input).toBeEnabled();
|
||||
await input.fill('23456');
|
||||
page.once('dialog', (dialog) => dialog.accept());
|
||||
await page.locator('#container > .control_bar').getByRole('button', { name: '설정' }).click();
|
||||
await expect(page.getByRole('alert')).toContainText('권한이 부족합니다.');
|
||||
await expect(input).toHaveValue('23456');
|
||||
expect(state.mutations).toEqual(['npc.setNationPolicy']);
|
||||
});
|
||||
|
||||
test('a user below secret read permission receives a recoverable page error', async ({ page }) => {
|
||||
await installFixture(page, { permissionLevel: 0, failLoad: true, mutations: [] });
|
||||
await gotoPolicy(page);
|
||||
await expect(page.getByRole('alert')).toContainText('권한이 부족합니다.');
|
||||
await expect(page.locator('#container')).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: '다시 시도' })).toBeVisible();
|
||||
});
|
||||
@@ -8,7 +8,15 @@ const baseURL = `http://127.0.0.1:${port}/che/`;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: '.',
|
||||
testMatch: ['troop.spec.ts', 'board.spec.ts', 'inGameInfo.spec.ts', 'inGameMenus.spec.ts', 'nationOffices.spec.ts'],
|
||||
testMatch: [
|
||||
'troop.spec.ts',
|
||||
'board.spec.ts',
|
||||
'inGameInfo.spec.ts',
|
||||
'inGameMenus.spec.ts',
|
||||
'nationOffices.spec.ts',
|
||||
'nationGeneralSecret.spec.ts',
|
||||
'npcPolicy.spec.ts',
|
||||
],
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
timeout: 30_000,
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"preview": "vite preview",
|
||||
"test:e2e:troop": "playwright test troop.spec.ts --config e2e/playwright.config.mjs",
|
||||
"test:e2e:nation-offices": "playwright test nationOffices.spec.ts --config e2e/playwright.config.mjs",
|
||||
"test:e2e:npc-policy": "playwright test npcPolicy.spec.ts --config e2e/playwright.config.mjs",
|
||||
"test:e2e:board": "playwright test board.spec.ts --config e2e/playwright.config.mjs",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
|
||||
@@ -34,8 +34,9 @@ const stateOffset = computed(() => -6 * props.mapScale);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
<RouterLink
|
||||
class="map-city"
|
||||
:to="{ name: 'current-city', query: { cityId: props.city.id } }"
|
||||
:class="[
|
||||
`state-${props.city.stateClass}`,
|
||||
{ mine: props.city.isMyCity, selected: props.city.selected, 'supply-off': !props.city.supply },
|
||||
@@ -45,20 +46,22 @@ const stateOffset = computed(() => -6 * props.mapScale);
|
||||
@mouseleave="emit('leave')"
|
||||
@click.stop="emit('select', props.city.id)"
|
||||
>
|
||||
<div
|
||||
class="city-dot"
|
||||
:style="{ backgroundColor: props.city.color, width: `${size}px`, height: `${size}px` }"
|
||||
>
|
||||
<div class="city-dot" :style="{ backgroundColor: props.city.color, width: `${size}px`, height: `${size}px` }">
|
||||
<span v-if="props.city.isCapital" class="capital" />
|
||||
</div>
|
||||
<div
|
||||
v-if="props.city.state > 0"
|
||||
class="city-state"
|
||||
:class="`state-${props.city.stateClass}`"
|
||||
:style="{ width: `${stateSize}px`, height: `${stateSize}px`, left: `${stateOffset}px`, top: `${stateOffset}px` }"
|
||||
:style="{
|
||||
width: `${stateSize}px`,
|
||||
height: `${stateSize}px`,
|
||||
left: `${stateOffset}px`,
|
||||
top: `${stateOffset}px`,
|
||||
}"
|
||||
/>
|
||||
<div v-if="props.showName" class="city-name">{{ props.city.name }}</div>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -71,6 +74,8 @@ const stateOffset = computed(() => -6 * props.mapScale);
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 0.65rem;
|
||||
color: rgba(232, 221, 196, 0.8);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.city-dot {
|
||||
|
||||
@@ -149,8 +149,9 @@ const cityStateStyle = computed(() => ({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
<RouterLink
|
||||
class="city-base"
|
||||
:to="{ name: 'current-city', query: { cityId: props.city.id } }"
|
||||
:class="[{ mine: props.city.isMyCity, selected: props.city.selected, 'supply-off': !props.city.supply }]"
|
||||
:style="cityBaseStyle"
|
||||
@mouseenter="emit('hover', props.city.id)"
|
||||
@@ -172,7 +173,7 @@ const cityStateStyle = computed(() => ({
|
||||
<div v-if="stateIcon" class="city-state" :style="cityStateStyle">
|
||||
<img :src="stateIcon" />
|
||||
</div>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -181,6 +182,8 @@ const cityStateStyle = computed(() => ({
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 0.65rem;
|
||||
color: rgba(232, 221, 196, 0.9);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.city-bg {
|
||||
|
||||
@@ -153,10 +153,7 @@ const routes = [
|
||||
path: '/nation/secret',
|
||||
name: 'nation-secret',
|
||||
component: NationSecretView,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresGeneral: true,
|
||||
},
|
||||
meta: { requiresAuth: true, requiresGeneral: true },
|
||||
},
|
||||
{
|
||||
path: '/nation/personnel',
|
||||
|
||||
@@ -1,32 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { formatOfficerLevelText, cityLevelMap, regionMap } from '../utils/nationFormat';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { cityLevelMap, formatOfficerLevelText, regionMap } from '../utils/nationFormat';
|
||||
import { getNpcColor } from '../utils/npcColor';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
type Result = Awaited<ReturnType<typeof trpc.world.getCurrentCity.query>>;
|
||||
type General = Result['generals'][number];
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const data = ref<Result | null>(null);
|
||||
const error = ref('');
|
||||
const selected = ref<number>();
|
||||
const show = (value: number | null) => (value === null ? '?' : value.toLocaleString('ko-KR'));
|
||||
let loadSequence = 0;
|
||||
|
||||
const parseCityId = (): number | undefined => {
|
||||
const raw = route.query.cityId ?? route.query.citylist;
|
||||
const value = Array.isArray(raw) ? raw[0] : raw;
|
||||
if (typeof value !== 'string' || !/^\d+$/.test(value)) return undefined;
|
||||
const cityId = Number(value);
|
||||
return Number.isSafeInteger(cityId) && cityId > 0 ? cityId : undefined;
|
||||
};
|
||||
|
||||
const load = async (cityId?: number) => {
|
||||
const sequence = ++loadSequence;
|
||||
try {
|
||||
data.value = await trpc.world.getCurrentCity.query(cityId ? { cityId } : undefined);
|
||||
selected.value = data.value.city.id;
|
||||
const result = await trpc.world.getCurrentCity.query(cityId ? { cityId } : undefined);
|
||||
if (sequence !== loadSequence) return;
|
||||
data.value = result;
|
||||
selected.value = result.city.id;
|
||||
error.value = '';
|
||||
} catch (cause) {
|
||||
if (sequence !== loadSequence) return;
|
||||
error.value = cause instanceof Error ? cause.message : '도시 정보를 불러오지 못했습니다.';
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [route.query.cityId, route.query.citylist],
|
||||
() => void load(parseCityId()),
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const selectCity = async () => {
|
||||
if (!selected.value) return;
|
||||
await router.push({ name: 'current-city', query: { cityId: selected.value } });
|
||||
};
|
||||
|
||||
const city = computed(() => data.value?.city);
|
||||
onMounted(() => void load());
|
||||
const summary = computed(() => data.value?.forceSummary);
|
||||
const show = (value: number | null) => (value === null ? '?' : value.toLocaleString('ko-KR'));
|
||||
const showPair = (crew: number, generals: number) => `${show(crew)}/${show(generals)}`;
|
||||
const populationRate = computed(() => {
|
||||
if (!city.value || city.value.population === null) return '?';
|
||||
return String(Math.round((city.value.population / city.value.populationMax) * 10_000) / 100);
|
||||
});
|
||||
const contrastColors = new Set([
|
||||
'',
|
||||
'#330000',
|
||||
'#FF0000',
|
||||
'#800000',
|
||||
'#A0522D',
|
||||
'#FF6347',
|
||||
'#808000',
|
||||
'#008000',
|
||||
'#2E8B57',
|
||||
'#008080',
|
||||
'#6495ED',
|
||||
'#0000FF',
|
||||
'#000080',
|
||||
'#483D8B',
|
||||
'#7B68EE',
|
||||
'#800080',
|
||||
'#A9A9A9',
|
||||
'#000000',
|
||||
]);
|
||||
const cityTitleStyle = computed(() => {
|
||||
const backgroundColor = city.value?.nationColor.toUpperCase() ?? '#000000';
|
||||
return {
|
||||
backgroundColor,
|
||||
color: contrastColors.has(backgroundColor) ? '#FFFFFF' : '#000000',
|
||||
};
|
||||
});
|
||||
const woundedStat = (value: number, injury: number) =>
|
||||
injury === 0 ? value : Math.floor((value * (100 - injury)) / 100);
|
||||
const defenceTrainText = (value: number | null) => {
|
||||
if (value === null) return '?';
|
||||
if (value === 999) return '×';
|
||||
if (value >= 90) return '☆';
|
||||
if (value >= 80) return '◎';
|
||||
if (value >= 60) return '○';
|
||||
return '△';
|
||||
};
|
||||
const generalImage = (general: General) => {
|
||||
const picture = general.picture ?? 'default.jpg';
|
||||
return general.imageServer ? `${import.meta.env.BASE_URL}d_pic/${picture}` : `/image/icons/${picture}`;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="city-page">
|
||||
<table class="legacy-table legacy-bg0 center">
|
||||
<table class="legacy-table legacy-bg0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>도 시 정 보<br /><RouterLink to="/">돌아가기</RouterLink></td>
|
||||
<td>도 시 정 보<br /><RouterLink class="back-link" to="/">돌아가기</RouterLink></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -34,32 +112,57 @@ onMounted(() => void load());
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
도시선택 :
|
||||
<select v-model.number="selected" @change="load(selected)">
|
||||
<option v-for="option in data?.options ?? []" :key="option.id" :value="option.id">
|
||||
【{{ option.name.padEnd(4, '_') }}】{{
|
||||
option.nationId === data?.me.nationId
|
||||
? '본국'
|
||||
: option.nationId === 0
|
||||
? '공백지'
|
||||
: '타국'
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
<p>명령 화면에서 도시를 클릭하셔도 됩니다.</p>
|
||||
<form @submit.prevent="selectCity">
|
||||
<div>
|
||||
도시선택 :
|
||||
<select id="citySelector" v-model.number="selected" @change="selectCity">
|
||||
<option v-for="option in data?.options ?? []" :key="option.id" :value="option.id">
|
||||
【{{ option.name.padEnd(4, '_') }}】{{
|
||||
option.nationId === data?.me.nationId
|
||||
? '본국'
|
||||
: option.nationId === 0
|
||||
? '공백지'
|
||||
: '타국'
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<p>명령 화면에서 도시를 클릭하셔도 됩니다.</p>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-if="error" class="error" role="alert">{{ error }}</p>
|
||||
<template v-if="data && city">
|
||||
<table class="legacy-table legacy-bg2 stats">
|
||||
<table class="legacy-table legacy-bg0 back-row">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colspan="11" class="city-title">
|
||||
<td><RouterLink class="back-link" to="/">돌아가기</RouterLink></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table class="legacy-table legacy-bg2 stats">
|
||||
<colgroup>
|
||||
<col class="label-col" />
|
||||
<col class="first-value-col" />
|
||||
<col class="label-col" />
|
||||
<col class="value-col" />
|
||||
<col class="label-col" />
|
||||
<col class="value-col" />
|
||||
<col class="label-col" />
|
||||
<col class="value-col" />
|
||||
<col class="label-col" />
|
||||
<col class="value-col" />
|
||||
<col class="label-col" />
|
||||
<col class="value-col" />
|
||||
</colgroup>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colspan="11" class="city-title" :style="cityTitleStyle">
|
||||
【 {{ regionMap[city.region] }} | {{ cityLevelMap[city.level] }} 】 {{ city.name }}
|
||||
</td>
|
||||
<td class="city-title">{{ data.lastExecute }}</td>
|
||||
<td class="city-title" :style="cityTitleStyle">{{ data.lastExecute }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>주민</th>
|
||||
@@ -79,15 +182,9 @@ onMounted(() => void load());
|
||||
<th>민심</th>
|
||||
<td>{{ show(city.trust) }}</td>
|
||||
<th>시세</th>
|
||||
<td>{{ city.trade ?? '-' }}%</td>
|
||||
<td>{{ city.trade ?? '- ' }}%</td>
|
||||
<th>인구</th>
|
||||
<td>
|
||||
{{
|
||||
city.population === null
|
||||
? '?'
|
||||
: ((city.population / city.populationMax) * 100).toFixed(2)
|
||||
}}%
|
||||
</td>
|
||||
<td>{{ populationRate }}%</td>
|
||||
<th>태수</th>
|
||||
<td>{{ city.officers[4] }}</td>
|
||||
<th>군사</th>
|
||||
@@ -95,19 +192,60 @@ onMounted(() => void load());
|
||||
<th>종사</th>
|
||||
<td>{{ city.officers[2] }}</td>
|
||||
</tr>
|
||||
<tr v-if="summary">
|
||||
<th>도시명</th>
|
||||
<td>{{ city.name }}</td>
|
||||
<th>적군</th>
|
||||
<td>
|
||||
{{ show(summary.enemyCrew) }}/{{ show(summary.enemyArmedGenerals) }}({{
|
||||
show(summary.enemyGenerals)
|
||||
}})
|
||||
</td>
|
||||
<th>병장(총)</th>
|
||||
<td>
|
||||
{{ show(summary.ownCrew) }}/{{ show(summary.ownArmedGenerals) }}({{
|
||||
show(summary.ownGenerals)
|
||||
}})
|
||||
</td>
|
||||
<th>90병장</th>
|
||||
<td>{{ showPair(summary.ready90Crew, summary.ready90Generals) }}</td>
|
||||
<th>60병장</th>
|
||||
<td>{{ showPair(summary.ready60Crew, summary.ready60Generals) }}</td>
|
||||
<th>수비○</th>
|
||||
<td>{{ showPair(summary.defenceReadyCrew, summary.defenceReadyGenerals) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>장수</th>
|
||||
<td colspan="11">
|
||||
{{
|
||||
data.visibility.detailed
|
||||
? data.generals.map((g) => g.name).join(', ') || '-'
|
||||
: '알 수 없음'
|
||||
}}
|
||||
<td colspan="11" class="general-names">
|
||||
<template v-if="data.visibility.detailed">
|
||||
<template v-if="data.generals.length">
|
||||
<template v-for="(general, index) in data.generals" :key="general.id">
|
||||
<span :style="{ color: getNpcColor(general.npcState) }">{{ general.name }}</span
|
||||
><template v-if="index < data.generals.length - 1">, </template>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>-</template>
|
||||
</template>
|
||||
<span v-else class="unknown">알 수 없음</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table v-if="data.visibility.detailed" class="legacy-table legacy-bg0 generals">
|
||||
<table v-if="data.visibility.detailed" id="general_list" class="legacy-table legacy-bg0 generals">
|
||||
<colgroup>
|
||||
<col style="width: 64px" />
|
||||
<col style="width: 128px" />
|
||||
<col style="width: 48px" />
|
||||
<col style="width: 48px" />
|
||||
<col style="width: 48px" />
|
||||
<col style="width: 78px" />
|
||||
<col style="width: 28px" />
|
||||
<col style="width: 78px" />
|
||||
<col style="width: 78px" />
|
||||
<col style="width: 48px" />
|
||||
<col style="width: 48px" />
|
||||
<col style="width: 280px" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>얼 굴</th>
|
||||
@@ -125,42 +263,53 @@ onMounted(() => void load());
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="general in data.generals" :key="general.id">
|
||||
<td>
|
||||
<img
|
||||
v-if="general.picture"
|
||||
width="64"
|
||||
height="64"
|
||||
:src="`/image/general/${general.picture}`"
|
||||
/>
|
||||
<tr
|
||||
v-for="general in data.generals"
|
||||
:key="general.id"
|
||||
:data-is-our-general="general.train !== null"
|
||||
:data-general-wounded="general.injury"
|
||||
>
|
||||
<td class="icon-cell">
|
||||
<img class="general-icon" width="64" height="64" :src="generalImage(general)" />
|
||||
</td>
|
||||
<td :style="{ color: getNpcColor(general.npcState) }">{{ general.name }}</td>
|
||||
<td :class="{ wounded: general.injury !== 0 }">
|
||||
{{ woundedStat(general.leadership, general.injury)
|
||||
}}<span v-if="general.leadershipBonus" class="leadership-bonus"
|
||||
>+{{ general.leadershipBonus }}</span
|
||||
>
|
||||
</td>
|
||||
<td :class="{ wounded: general.injury !== 0 }">
|
||||
{{ woundedStat(general.strength, general.injury) }}
|
||||
</td>
|
||||
<td :class="{ wounded: general.injury !== 0 }">
|
||||
{{ woundedStat(general.intelligence, general.injury) }}
|
||||
</td>
|
||||
<td>{{ general.name }}</td>
|
||||
<td>{{ general.leadership }}</td>
|
||||
<td>{{ general.strength }}</td>
|
||||
<td>{{ general.intelligence }}</td>
|
||||
<td>{{ formatOfficerLevelText(general.officerLevel) }}</td>
|
||||
<td>{{ general.defenceTrain ?? '?' }}</td>
|
||||
<td>{{ general.crewTypeId ?? '?' }}</td>
|
||||
<td>{{ defenceTrainText(general.defenceTrain) }}</td>
|
||||
<td>{{ general.crewTypeName ?? '?' }}</td>
|
||||
<td>{{ general.crew ?? '?' }}</td>
|
||||
<td>{{ general.train ?? '?' }}</td>
|
||||
<td>{{ general.atmos ?? '?' }}</td>
|
||||
<td class="turns">
|
||||
{{
|
||||
general.turns.length
|
||||
? general.turns.map((turn, index) => `${index + 1} : ${turn}`).join(' / ')
|
||||
: general.npcState > 1
|
||||
? 'NPC 장수'
|
||||
: `【${general.nationName}】 장수`
|
||||
}}
|
||||
<template v-if="general.turns.length">
|
||||
<span v-for="(turn, index) in general.turns" :key="index" class="turn-line"
|
||||
>{{ index + 1 }} : {{ turn }}</span
|
||||
>
|
||||
</template>
|
||||
<template v-else-if="general.npcState > 1">NPC 장수</template>
|
||||
<template v-else-if="general.nationId !== data.me.nationId">
|
||||
{{ general.nationId === 0 ? '재 야' : `【${general.nationName}】 장수` }}
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
<table class="legacy-table legacy-bg0 center footer">
|
||||
<table class="legacy-table legacy-bg0 footer">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><RouterLink to="/">돌아가기</RouterLink></td>
|
||||
<td><RouterLink class="back-link" to="/">돌아가기</RouterLink></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -170,60 +319,138 @@ onMounted(() => void load());
|
||||
<style scoped>
|
||||
.city-page {
|
||||
width: 1000px;
|
||||
margin: 0 auto;
|
||||
font-size: 14px;
|
||||
margin: 8px auto 0;
|
||||
font-family: 'Times New Roman', serif;
|
||||
font-size: 16px;
|
||||
line-height: normal;
|
||||
}
|
||||
.legacy-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border-collapse: separate;
|
||||
border-spacing: 2px;
|
||||
}
|
||||
.legacy-table td,
|
||||
.legacy-table th {
|
||||
border: 1px solid #777;
|
||||
padding: 3px;
|
||||
border: 0;
|
||||
padding: 1px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.center {
|
||||
.center,
|
||||
.selector,
|
||||
.stats td,
|
||||
.stats th,
|
||||
.generals th,
|
||||
.generals td:not(:last-child) {
|
||||
text-align: center;
|
||||
}
|
||||
.selector {
|
||||
text-align: center;
|
||||
margin-top: 0;
|
||||
}
|
||||
.selector select {
|
||||
display: inline-block;
|
||||
min-width: 400px;
|
||||
height: 19px;
|
||||
padding: 0;
|
||||
border: 1px solid #767676;
|
||||
background: #6b6b6b;
|
||||
color: #fff;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 13.3333px;
|
||||
}
|
||||
.selector {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.selector p {
|
||||
margin: 1em 0;
|
||||
}
|
||||
.back-row {
|
||||
margin-top: 14px;
|
||||
}
|
||||
.stats {
|
||||
margin-top: 14px;
|
||||
margin-top: 0;
|
||||
table-layout: fixed;
|
||||
}
|
||||
.label-col {
|
||||
width: 48px;
|
||||
}
|
||||
.value-col {
|
||||
width: 108px;
|
||||
}
|
||||
.first-value-col {
|
||||
width: 112px;
|
||||
}
|
||||
.stats th,
|
||||
.generals th {
|
||||
background-color: #14241b;
|
||||
background-image: url('/image/game/back_green.jpg');
|
||||
text-align: center;
|
||||
}
|
||||
.stats td {
|
||||
text-align: center;
|
||||
}
|
||||
.city-title {
|
||||
text-align: center;
|
||||
}
|
||||
.generals {
|
||||
margin-top: 14px;
|
||||
.stats {
|
||||
height: 136px;
|
||||
}
|
||||
.generals td {
|
||||
text-align: center;
|
||||
.general-names {
|
||||
text-align: left !important;
|
||||
}
|
||||
.unknown {
|
||||
color: gray;
|
||||
}
|
||||
.generals {
|
||||
width: 1024px;
|
||||
margin: 18px 0 0 50%;
|
||||
table-layout: fixed;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
.generals td:last-child {
|
||||
text-align: left;
|
||||
padding-left: 1em;
|
||||
}
|
||||
.icon-cell {
|
||||
height: 64px;
|
||||
padding: 0 !important;
|
||||
}
|
||||
.generals tbody tr {
|
||||
height: 72px;
|
||||
}
|
||||
.general-icon {
|
||||
display: block;
|
||||
width: 64px;
|
||||
min-width: 64px;
|
||||
height: 64px;
|
||||
object-fit: fill;
|
||||
}
|
||||
.turns {
|
||||
font-size: x-small;
|
||||
}
|
||||
.turn-line {
|
||||
display: block;
|
||||
}
|
||||
.wounded {
|
||||
color: red;
|
||||
}
|
||||
.leadership-bonus {
|
||||
color: cyan;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 14px;
|
||||
}
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
border: 1px solid #6c757d;
|
||||
border-radius: 0.2rem;
|
||||
background: #6c757d;
|
||||
color: #fff;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
text-decoration: none;
|
||||
}
|
||||
.back-link:hover,
|
||||
.back-link:focus,
|
||||
.back-link:active {
|
||||
border-color: #565e64;
|
||||
background: #5c636a;
|
||||
color: #fff;
|
||||
}
|
||||
.error {
|
||||
text-align: center;
|
||||
color: #ff7373;
|
||||
@@ -233,8 +460,5 @@ onMounted(() => void load());
|
||||
width: 1000px;
|
||||
transform-origin: top left;
|
||||
}
|
||||
.selector select {
|
||||
min-width: 300px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import PanelCard from '../components/ui/PanelCard.vue';
|
||||
import SkeletonLines from '../components/ui/SkeletonLines.vue';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
type InheritStatus = Awaited<ReturnType<typeof trpc.inherit.getStatus.query>>;
|
||||
@@ -12,8 +10,8 @@ type BuffKey =
|
||||
| 'warAvoidRatio'
|
||||
| 'warCriticalRatio'
|
||||
| 'warMagicTrialProb'
|
||||
| 'success'
|
||||
| 'fail'
|
||||
| 'domesticSuccessProb'
|
||||
| 'domesticFailProb'
|
||||
| 'warAvoidRatioOppose'
|
||||
| 'warCriticalRatioOppose'
|
||||
| 'warMagicTrialProbOppose';
|
||||
@@ -22,8 +20,8 @@ const buffKeys: BuffKey[] = [
|
||||
'warAvoidRatio',
|
||||
'warCriticalRatio',
|
||||
'warMagicTrialProb',
|
||||
'success',
|
||||
'fail',
|
||||
'domesticSuccessProb',
|
||||
'domesticFailProb',
|
||||
'warAvoidRatioOppose',
|
||||
'warCriticalRatioOppose',
|
||||
'warMagicTrialProbOppose',
|
||||
@@ -33,8 +31,8 @@ const buffLabels: Record<BuffKey, string> = {
|
||||
warAvoidRatio: '회피 확률 증가',
|
||||
warCriticalRatio: '필살 확률 증가',
|
||||
warMagicTrialProb: '전투계략 시도 확률 증가',
|
||||
success: '내정 성공률 증가',
|
||||
fail: '내정 실패율 감소',
|
||||
domesticSuccessProb: '내정 성공 확률 증가',
|
||||
domesticFailProb: '내정 실패 확률 감소',
|
||||
warAvoidRatioOppose: '상대 회피 확률 감소',
|
||||
warCriticalRatioOppose: '상대 필살 확률 감소',
|
||||
warMagicTrialProbOppose: '상대 전투계략 시도 확률 감소',
|
||||
@@ -43,20 +41,21 @@ const buffLabels: Record<BuffKey, string> = {
|
||||
const pointLabels: Record<string, string> = {
|
||||
previous: '보유',
|
||||
lived_month: '생존 턴',
|
||||
max_domestic_critical: '내정 최고치',
|
||||
active_action: '활동',
|
||||
combat: '전투',
|
||||
sabotage: '계략',
|
||||
dex: '숙련',
|
||||
unifier: '통일 보상',
|
||||
max_domestic_critical: '최대 연속 내정 성공',
|
||||
active_action: '능동 행동 수',
|
||||
combat: '전투 횟수',
|
||||
sabotage: '계략 성공 횟수',
|
||||
dex: '숙련도',
|
||||
unifier: '천통 기여',
|
||||
tournament: '토너먼트',
|
||||
betting: '베팅',
|
||||
max_belong: '최대 충성',
|
||||
betting: '베팅 당첨',
|
||||
max_belong: '최대 임관년 수',
|
||||
};
|
||||
|
||||
const pointOrder = [
|
||||
'previous',
|
||||
'lived_month',
|
||||
'max_belong',
|
||||
'max_domestic_critical',
|
||||
'active_action',
|
||||
'combat',
|
||||
@@ -65,9 +64,33 @@ const pointOrder = [
|
||||
'unifier',
|
||||
'tournament',
|
||||
'betting',
|
||||
'max_belong',
|
||||
] as const;
|
||||
|
||||
const pointHelp: Record<string, string> = {
|
||||
previous: '이전에 물려받은 포인트입니다.',
|
||||
lived_month: '살아남은 기간입니다. (1개월 단위)',
|
||||
max_belong: '가장 오래 임관했던 국가의 연도입니다.',
|
||||
max_domestic_critical: '성공한 내정 중 최대 연속값입니다.',
|
||||
active_action: '장수 동향에 본인의 이름이 직접 나타난 수입니다. 일부 사령턴은 제외됩니다.',
|
||||
combat: '전투 횟수입니다.',
|
||||
sabotage: '계략 성공 횟수입니다.',
|
||||
unifier: '천통에 기여한 포인트입니다. 각 국의 군주, 천통 수뇌, 천통 군주가 받습니다.',
|
||||
dex: '총 숙련도합입니다. 최대 숙련 이후에는 상승량이 1/3로 감소합니다.',
|
||||
tournament: '토너먼트 입상 포인트입니다.',
|
||||
betting: '성공적인 베팅을 했습니다. 수익율과 베팅 성공 횟수를 따릅니다.',
|
||||
};
|
||||
|
||||
const buffHelp: Record<BuffKey, string> = {
|
||||
warAvoidRatio: '전투 시 회피 확률이 1%p ~ 5%p 증가합니다.',
|
||||
warCriticalRatio: '전투 시 필살 확률이 1%p ~ 5%p 증가합니다.',
|
||||
warMagicTrialProb: '전투 시 계략을 시도할 확률이 1%p ~ 5%p 증가합니다. 무장도 계략을 시도합니다.',
|
||||
domesticSuccessProb: '민심, 인구, 농업, 상업, 치안, 수비, 성벽, 기술 내정의 성공 확률이 증가합니다.',
|
||||
domesticFailProb: '민심, 인구, 농업, 상업, 치안, 수비, 성벽, 기술 내정의 실패 확률이 감소합니다.',
|
||||
warAvoidRatioOppose: '전투 시 상대의 회피 확률이 1%p ~ 5%p 감소합니다.',
|
||||
warCriticalRatioOppose: '전투 시 상대의 필살 확률이 1%p ~ 5%p 감소합니다.',
|
||||
warMagicTrialProbOppose: '전투 시 상대의 계략 시도 확률이 1%p ~ 5%p 감소합니다.',
|
||||
};
|
||||
|
||||
const loading = ref(true);
|
||||
const error = ref<string | null>(null);
|
||||
const status = ref<InheritStatus | null>(null);
|
||||
@@ -86,8 +109,8 @@ const buffTargets = reactive<Record<BuffKey, number>>({
|
||||
warAvoidRatio: 1,
|
||||
warCriticalRatio: 1,
|
||||
warMagicTrialProb: 1,
|
||||
success: 1,
|
||||
fail: 1,
|
||||
domesticSuccessProb: 1,
|
||||
domesticFailProb: 1,
|
||||
warAvoidRatioOppose: 1,
|
||||
warCriticalRatioOppose: 1,
|
||||
warMagicTrialProbOppose: 1,
|
||||
@@ -115,7 +138,7 @@ const statRules = computed(() => joinConfig.value?.rules.stat ?? null);
|
||||
const resetStatTotal = computed(() => resetStatForm.leadership + resetStatForm.strength + resetStatForm.intel);
|
||||
const resetBonusSum = computed(() => resetStatForm.bonus.reduce((acc, value) => acc + value, 0));
|
||||
const resetStatCost = computed(() =>
|
||||
resetBonusSum.value > 0 ? status.value?.inheritConst.inheritBornStatPoint ?? 0 : 0
|
||||
resetBonusSum.value > 0 ? (status.value?.inheritConst.inheritBornStatPoint ?? 0) : 0
|
||||
);
|
||||
|
||||
const resetStatErrors = computed(() => {
|
||||
@@ -166,6 +189,9 @@ const pointEntries = computed(() => {
|
||||
}));
|
||||
});
|
||||
|
||||
const previousPoint = computed(() => status.value?.items.previous ?? 0);
|
||||
const newPoint = computed(() => (status.value?.totalPoint ?? 0) - previousPoint.value);
|
||||
|
||||
const specialNameMap = computed(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const entry of status.value?.availableSpecialWar ?? []) {
|
||||
@@ -174,29 +200,12 @@ const specialNameMap = computed(() => {
|
||||
return map;
|
||||
});
|
||||
|
||||
const currentSpecialName = computed(() => {
|
||||
if (!status.value) {
|
||||
return '-';
|
||||
}
|
||||
return specialNameMap.value.get(status.value.currentSpecialWar) ?? status.value.currentSpecialWar ?? '-';
|
||||
});
|
||||
|
||||
const buffCost = (key: BuffKey, target: number): number => {
|
||||
const points = status.value?.inheritConst.inheritBuffPoints ?? [0, 0, 0, 0, 0, 0];
|
||||
const current = status.value?.buffLevels[key] ?? 0;
|
||||
return Math.max(0, (points[target] ?? 0) - (points[current] ?? 0));
|
||||
};
|
||||
|
||||
const buffTargetOptions = (key: BuffKey): number[] => {
|
||||
const current = status.value?.buffLevels[key] ?? 0;
|
||||
const start = Math.min(5, Math.max(1, current + 1));
|
||||
const result: number[] = [];
|
||||
for (let level = start; level <= 5; level += 1) {
|
||||
result.push(level);
|
||||
}
|
||||
return result.length > 0 ? result : [5];
|
||||
};
|
||||
|
||||
const resolveErrorMessage = (value: unknown): string => {
|
||||
if (value instanceof Error) {
|
||||
return value.message;
|
||||
@@ -207,17 +216,6 @@ const resolveErrorMessage = (value: unknown): string => {
|
||||
return 'unknown_error';
|
||||
};
|
||||
|
||||
const applyResetBalanced = () => {
|
||||
const rules = statRules.value;
|
||||
if (!rules) {
|
||||
return;
|
||||
}
|
||||
const base = Math.floor(rules.total / 3);
|
||||
resetStatForm.leadership = rules.total - base * 2;
|
||||
resetStatForm.strength = base;
|
||||
resetStatForm.intel = base;
|
||||
};
|
||||
|
||||
const syncSelections = () => {
|
||||
if (!status.value) {
|
||||
return;
|
||||
@@ -235,6 +233,14 @@ const syncSelections = () => {
|
||||
if (!uniqueForm.amount) {
|
||||
uniqueForm.amount = status.value.inheritConst.inheritItemUniqueMinPoint;
|
||||
}
|
||||
if (!uniqueForm.itemId) {
|
||||
uniqueForm.itemId = status.value.availableUnique[0]?.key ?? '';
|
||||
}
|
||||
if (resetStatForm.leadership === 0 && resetStatForm.strength === 0 && resetStatForm.intel === 0) {
|
||||
resetStatForm.leadership = status.value.currentStat.leadership;
|
||||
resetStatForm.strength = status.value.currentStat.strength;
|
||||
resetStatForm.intel = status.value.currentStat.intel;
|
||||
}
|
||||
};
|
||||
|
||||
const loadStatus = async () => {
|
||||
@@ -377,7 +383,7 @@ const buyRandomUnique = async () => {
|
||||
|
||||
const openUniqueAuction = async () => {
|
||||
if (!uniqueForm.itemId.trim()) {
|
||||
actionError.value = '유니크 아이템 ID를 입력해주세요.';
|
||||
actionError.value = '유니크를 선택해주세요.';
|
||||
return;
|
||||
}
|
||||
const amount = Math.max(0, Math.floor(uniqueForm.amount));
|
||||
@@ -412,12 +418,6 @@ const checkOwner = async () => {
|
||||
});
|
||||
};
|
||||
|
||||
watch(statRules, (rules) => {
|
||||
if (rules) {
|
||||
applyResetBalanced();
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
void loadStatus();
|
||||
void loadJoinConfig();
|
||||
@@ -426,464 +426,561 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="inherit-page">
|
||||
<header class="inherit-header">
|
||||
<div>
|
||||
<h1 class="inherit-title">유산 포인트</h1>
|
||||
<p class="inherit-subtitle">숨김 강화와 유산 상점 기능을 관리합니다.</p>
|
||||
</div>
|
||||
<div class="inherit-actions">
|
||||
<button class="ghost" @click="loadStatus">새로고침</button>
|
||||
<button class="ghost" @click="loadLogs(true)">로그 갱신</button>
|
||||
</div>
|
||||
</header>
|
||||
<header class="top-back-bar legacy-bg0">
|
||||
<RouterLink class="top-button legacy-button" to="/">돌아가기</RouterLink>
|
||||
<strong>유산 관리</strong>
|
||||
<button class="top-button legacy-button" type="button" :disabled="loading" @click="loadStatus">갱신</button>
|
||||
</header>
|
||||
|
||||
<div v-if="error" class="inherit-error">{{ error }}</div>
|
||||
<div v-if="actionError" class="inherit-error">{{ actionError }}</div>
|
||||
<div v-if="actionMessage" class="inherit-message">{{ actionMessage }}</div>
|
||||
<main id="container" class="inherit-page legacy-bg0">
|
||||
<div v-if="error || actionError" class="notice error" role="alert">{{ error ?? actionError }}</div>
|
||||
<div v-if="actionMessage" class="notice success">{{ actionMessage }}</div>
|
||||
<div v-if="loading" class="loading-state">불러오는 중...</div>
|
||||
|
||||
<div v-if="loading">
|
||||
<SkeletonLines :lines="4" />
|
||||
</div>
|
||||
<template v-else-if="status">
|
||||
<section id="inheritance_list" class="point-grid">
|
||||
<article id="inherit_sum" class="inherit-item">
|
||||
<label for="inherit_sum_value">총 포인트</label>
|
||||
<input id="inherit_sum_value" :value="Math.floor(status.totalPoint).toLocaleString()" readonly />
|
||||
<small>다음 플레이에서 사용할 수 있는 총 포인트입니다.</small>
|
||||
</article>
|
||||
<article id="inherit_previous" class="inherit-item">
|
||||
<label for="inherit_previous_value">기존 포인트</label>
|
||||
<input id="inherit_previous_value" :value="Math.floor(previousPoint).toLocaleString()" readonly />
|
||||
<small>이전에 물려받은 포인트입니다.</small>
|
||||
</article>
|
||||
<article id="inherit_new" class="inherit-item">
|
||||
<label for="inherit_new_value">신규 포인트</label>
|
||||
<input id="inherit_new_value" :value="Math.floor(newPoint).toLocaleString()" readonly />
|
||||
<small>이번 플레이에서 얻은 총 포인트입니다.</small>
|
||||
</article>
|
||||
<div class="divider"></div>
|
||||
<article
|
||||
v-for="entry in pointEntries.filter((item) => item.key !== 'previous')"
|
||||
:id="`inherit_${entry.key}`"
|
||||
:key="entry.key"
|
||||
class="inherit-item"
|
||||
>
|
||||
<label :for="`inherit_${entry.key}_value`">{{ entry.label }}</label>
|
||||
<input
|
||||
:id="`inherit_${entry.key}_value`"
|
||||
:value="Math.floor(entry.value).toLocaleString()"
|
||||
readonly
|
||||
/>
|
||||
<small>{{ pointHelp[entry.key] }}</small>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section v-else class="inherit-grid">
|
||||
<PanelCard title="포인트 요약" subtitle="유산 포인트 구성 현황">
|
||||
<div v-if="!status" class="muted">포인트 정보를 불러오지 못했습니다.</div>
|
||||
<div v-else class="summary-panel">
|
||||
<div class="summary-head">
|
||||
<div class="summary-total">총 {{ status.totalPoint }} 포인트</div>
|
||||
<div class="summary-state" :class="{ united: status.isUnited }">
|
||||
{{ status.isUnited ? '통일 완료' : '진행 중' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-list">
|
||||
<div v-for="entry in pointEntries" :key="entry.key" class="summary-row">
|
||||
<span>{{ entry.label }}</span>
|
||||
<span>{{ entry.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-footer">
|
||||
<div>현재 전투 특기: {{ currentSpecialName }}</div>
|
||||
<div>특기 초기화 단계: {{ status.resetLevels.resetSpecialWar }}회</div>
|
||||
<div>턴 시간 초기화 단계: {{ status.resetLevels.resetTurnTime }}회</div>
|
||||
</div>
|
||||
</div>
|
||||
</PanelCard>
|
||||
<section id="inheritance_store">
|
||||
<h2 class="section-title legacy-bg1">유산 포인트 상점</h2>
|
||||
|
||||
<PanelCard title="숨김 강화" subtitle="숨김 강화 효과를 구입합니다.">
|
||||
<div v-if="!status" class="muted">숨김 강화 정보를 불러오지 못했습니다.</div>
|
||||
<div v-else class="buff-list">
|
||||
<div v-for="key in buffKeys" :key="key" class="buff-row">
|
||||
<div class="buff-info">
|
||||
<div class="buff-name">{{ buffLabels[key] }}</div>
|
||||
<div class="buff-level">현재 {{ status.buffLevels[key] ?? 0 }} 단계</div>
|
||||
</div>
|
||||
<div class="buff-action">
|
||||
<select v-model.number="buffTargets[key]" class="form-input">
|
||||
<option
|
||||
v-for="level in buffTargetOptions(key)"
|
||||
:key="level"
|
||||
:value="level"
|
||||
>
|
||||
{{ level }} 단계
|
||||
<div class="action-grid leading-actions">
|
||||
<article class="shop-item">
|
||||
<div class="control-row">
|
||||
<label for="next-special">다음 전투 특기 선택</label>
|
||||
<select id="next-special" v-model="nextSpecialKey">
|
||||
<option v-for="entry in status.availableSpecialWar" :key="entry.key" :value="entry.key">
|
||||
{{ entry.name }}
|
||||
</option>
|
||||
</select>
|
||||
<div class="buff-cost">비용 {{ buffCost(key, buffTargets[key]) }}</div>
|
||||
</div>
|
||||
<small
|
||||
>{{ specialNameMap.get(nextSpecialKey) }} 특기를 다음에 얻도록 지정합니다.<br /><b
|
||||
>필요 포인트: {{ status.inheritConst.inheritSpecificSpecialPoint }}</b
|
||||
></small
|
||||
>
|
||||
<button
|
||||
class="legacy-button buy-button"
|
||||
:disabled="isUnited || actionBusy"
|
||||
@click="reserveSpecialWar"
|
||||
>
|
||||
구입
|
||||
</button>
|
||||
</article>
|
||||
|
||||
<article class="shop-item">
|
||||
<div class="control-row">
|
||||
<label for="specific-unique">유니크 경매</label>
|
||||
<select id="specific-unique" v-model="uniqueForm.itemId">
|
||||
<option disabled value="">유니크 선택</option>
|
||||
<option v-for="item in status.availableUnique" :key="item.key" :value="item.key">
|
||||
{{ item.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="control-row">
|
||||
<label for="specific-unique-amount">입찰 포인트</label>
|
||||
<input
|
||||
id="specific-unique-amount"
|
||||
v-model.number="uniqueForm.amount"
|
||||
type="number"
|
||||
:min="status.inheritConst.inheritItemUniqueMinPoint"
|
||||
:max="previousPoint"
|
||||
/>
|
||||
</div>
|
||||
<small
|
||||
>얻고자 하는 유니크 아이템으로 경매를 시작합니다. 24턴 동안 진행됩니다.<br />{{
|
||||
status.availableUnique.find((item) => item.key === uniqueForm.itemId)?.info
|
||||
}}</small
|
||||
>
|
||||
<button
|
||||
class="legacy-button buy-button"
|
||||
:disabled="isUnited || actionBusy"
|
||||
@click="openUniqueAuction"
|
||||
>
|
||||
경매 시작
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="action-grid">
|
||||
<article class="shop-item simple-item">
|
||||
<div class="control-row">
|
||||
<span>랜덤 턴 초기화</span
|
||||
><button class="legacy-button" :disabled="isUnited || actionBusy" @click="resetTurnTime">
|
||||
구입
|
||||
</button>
|
||||
</div>
|
||||
<small
|
||||
>다다음턴부터 시간이 랜덤하게 바뀝니다. (필요 포인트가 피보나치식으로 증가합니다)<br /><b
|
||||
>필요 포인트: {{ status.resetCosts.resetTurnTime }}</b
|
||||
><template v-if="turnTimeLabel"><br />적용 시간: {{ turnTimeLabel }}</template></small
|
||||
>
|
||||
</article>
|
||||
<article class="shop-item simple-item">
|
||||
<div class="control-row">
|
||||
<span>랜덤 유니크 획득</span
|
||||
><button class="legacy-button" :disabled="isUnited || actionBusy" @click="buyRandomUnique">
|
||||
구입
|
||||
</button>
|
||||
</div>
|
||||
<small
|
||||
>다음 턴에 랜덤 유니크를 얻습니다.<br /><b
|
||||
>필요 포인트: {{ status.inheritConst.inheritItemRandomPoint }}</b
|
||||
></small
|
||||
>
|
||||
</article>
|
||||
<article class="shop-item simple-item">
|
||||
<div class="control-row">
|
||||
<span>즉시 전투 특기 초기화</span
|
||||
><button class="legacy-button" :disabled="isUnited || actionBusy" @click="resetSpecialWar">
|
||||
구입
|
||||
</button>
|
||||
</div>
|
||||
<small
|
||||
>즉시 전투 특기를 초기화합니다. (필요 포인트가 피보나치식으로 증가합니다)<br /><b
|
||||
>필요 포인트: {{ status.resetCosts.resetSpecialWar }}</b
|
||||
></small
|
||||
>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="buff-grid">
|
||||
<article v-for="key in buffKeys" :key="key" class="shop-item buff-item">
|
||||
<div class="control-row">
|
||||
<label :for="`buff-${key}`">{{ buffLabels[key] }}</label>
|
||||
<input
|
||||
:id="`buff-${key}`"
|
||||
v-model.number="buffTargets[key]"
|
||||
type="number"
|
||||
:min="status.buffLevels[key] ?? 0"
|
||||
max="5"
|
||||
/>
|
||||
</div>
|
||||
<small
|
||||
>{{ buffHelp[key] }}<br /><b>필요 포인트: {{ buffCost(key, buffTargets[key]) }}</b></small
|
||||
>
|
||||
<div class="dual-buttons">
|
||||
<button
|
||||
:disabled="isUnited || actionBusy || (status.buffLevels[key] ?? 0) >= 5"
|
||||
class="legacy-button secondary"
|
||||
:disabled="actionBusy"
|
||||
@click="buffTargets[key] = status.buffLevels[key] ?? 0"
|
||||
>
|
||||
리셋
|
||||
</button>
|
||||
<button
|
||||
class="legacy-button"
|
||||
:disabled="isUnited || actionBusy"
|
||||
@click="buyHiddenBuff(key)"
|
||||
>
|
||||
구입
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</PanelCard>
|
||||
|
||||
<PanelCard title="전투 특기 제어" subtitle="다음 특기 지정 및 초기화">
|
||||
<div v-if="!status" class="muted">전투 특기 정보를 불러오지 못했습니다.</div>
|
||||
<div v-else class="action-stack">
|
||||
<div class="action-row">
|
||||
<label class="form-field">
|
||||
<span>다음 전투 특기</span>
|
||||
<select v-model="nextSpecialKey" class="form-input">
|
||||
<option v-for="special in status.availableSpecialWar" :key="special.key" :value="special.key">
|
||||
{{ special.name }}
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="action-grid bottom-actions">
|
||||
<article class="shop-item">
|
||||
<div class="control-row">
|
||||
<label for="owner-target">장수 소유자 확인</label>
|
||||
<select id="owner-target" v-model="ownerTargetId">
|
||||
<option disabled value="">장수 선택</option>
|
||||
<option
|
||||
v-for="general in status.availableTargetGenerals"
|
||||
:key="general.id"
|
||||
:value="String(general.id)"
|
||||
>
|
||||
{{ general.name }}
|
||||
</option>
|
||||
</select>
|
||||
<small class="muted">비용 {{ status.inheritConst.inheritSpecificSpecialPoint }} 포인트</small>
|
||||
</label>
|
||||
<button :disabled="isUnited || actionBusy" @click="reserveSpecialWar">예약</button>
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<div>
|
||||
<div class="muted">현재 전투 특기: {{ currentSpecialName }}</div>
|
||||
<div class="muted">
|
||||
초기화 비용 {{ status.resetCosts.resetSpecialWar }} 포인트
|
||||
({{ status.resetLevels.resetSpecialWar }}회)
|
||||
</div>
|
||||
<small
|
||||
>장수의 소유자를 찾습니다. 대상에게도 알림이 전송됩니다.<br /><b
|
||||
>필요 포인트: {{ status.inheritConst.inheritCheckOwnerPoint }}</b
|
||||
></small
|
||||
>
|
||||
<button class="legacy-button buy-button" :disabled="isUnited || actionBusy" @click="checkOwner">
|
||||
소유자 찾기
|
||||
</button>
|
||||
<p v-if="ownerResult" class="owner-result">
|
||||
{{ ownerResult.targetName }}의 소유자: {{ ownerResult.ownerName }}
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article class="shop-item stat-reset">
|
||||
<div class="stat-layout">
|
||||
<span>능력치 초기화</span>
|
||||
<div>
|
||||
<strong>기본 능력치</strong>
|
||||
<label
|
||||
>통
|
||||
<input
|
||||
v-model.number="resetStatForm.leadership"
|
||||
type="number"
|
||||
:min="statRules?.min"
|
||||
:max="statRules?.max"
|
||||
/></label>
|
||||
<label
|
||||
>무
|
||||
<input
|
||||
v-model.number="resetStatForm.strength"
|
||||
type="number"
|
||||
:min="statRules?.min"
|
||||
:max="statRules?.max"
|
||||
/></label>
|
||||
<label
|
||||
>지
|
||||
<input
|
||||
v-model.number="resetStatForm.intel"
|
||||
type="number"
|
||||
:min="statRules?.min"
|
||||
:max="statRules?.max"
|
||||
/></label>
|
||||
<strong>추가 능력치</strong>
|
||||
<label
|
||||
>통 <input v-model.number="resetStatForm.bonus[0]" type="number" min="0" max="5"
|
||||
/></label>
|
||||
<label
|
||||
>무 <input v-model.number="resetStatForm.bonus[1]" type="number" min="0" max="5"
|
||||
/></label>
|
||||
<label
|
||||
>지 <input v-model.number="resetStatForm.bonus[2]" type="number" min="0" max="5"
|
||||
/></label>
|
||||
</div>
|
||||
</div>
|
||||
<button :disabled="isUnited || actionBusy" @click="resetSpecialWar">초기화</button>
|
||||
</div>
|
||||
<small
|
||||
>시즌 당 1회에 한 해 능력치를 초기화합니다.<br /><b>필요 포인트: {{ resetStatCost }}</b
|
||||
><br /><span v-if="resetStatErrors.length">{{ resetStatErrors[0] }}</span></small
|
||||
>
|
||||
<button
|
||||
class="legacy-button buy-button"
|
||||
:disabled="isUnited || actionBusy || resetStatErrors.length > 0"
|
||||
@click="resetStats"
|
||||
>
|
||||
능력치 초기화
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
</PanelCard>
|
||||
</section>
|
||||
|
||||
<PanelCard title="턴 시간 초기화" subtitle="턴 시간대를 재설정합니다.">
|
||||
<div v-if="!status" class="muted">턴 시간 정보를 불러오지 못했습니다.</div>
|
||||
<div v-else class="action-stack">
|
||||
<div class="action-row">
|
||||
<div class="muted">
|
||||
비용 {{ status.resetCosts.resetTurnTime }} 포인트 ({{ status.resetLevels.resetTurnTime }}회)
|
||||
</div>
|
||||
<button :disabled="isUnited || actionBusy" @click="resetTurnTime">턴 시간 변경</button>
|
||||
</div>
|
||||
<div v-if="turnTimeLabel" class="muted">다음 적용 시각: {{ turnTimeLabel }}</div>
|
||||
<section class="inherit-logs">
|
||||
<h2 class="section-title legacy-bg1">유산 포인트 변경 내역</h2>
|
||||
<div v-if="logLoading && logs.length === 0" class="log-empty">불러오는 중...</div>
|
||||
<div v-else-if="logs.length === 0" class="log-empty">기록이 없습니다.</div>
|
||||
<div v-for="entry in logs" v-else :key="entry.id" class="log-row">
|
||||
<small>[{{ new Date(entry.createdAt).toLocaleString('ko-KR') }}]</small>
|
||||
<span>{{ entry.text }}</span>
|
||||
</div>
|
||||
</PanelCard>
|
||||
|
||||
<PanelCard title="능력치 초기화" subtitle="능력치를 다시 배분합니다.">
|
||||
<div v-if="!status" class="muted">능력치 정보를 불러오지 못했습니다.</div>
|
||||
<div v-else class="stat-panel">
|
||||
<div class="stat-grid">
|
||||
<label class="form-field">
|
||||
<span>통솔</span>
|
||||
<input v-model.number="resetStatForm.leadership" type="number" class="form-input" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>무력</span>
|
||||
<input v-model.number="resetStatForm.strength" type="number" class="form-input" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>지력</span>
|
||||
<input v-model.number="resetStatForm.intel" type="number" class="form-input" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="stat-grid">
|
||||
<label class="form-field">
|
||||
<span>보너스 통솔</span>
|
||||
<input v-model.number="resetStatForm.bonus[0]" type="number" min="0" max="5" class="form-input" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>보너스 무력</span>
|
||||
<input v-model.number="resetStatForm.bonus[1]" type="number" min="0" max="5" class="form-input" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>보너스 지력</span>
|
||||
<input v-model.number="resetStatForm.bonus[2]" type="number" min="0" max="5" class="form-input" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="stat-summary">
|
||||
<div>총합 {{ resetStatTotal }} / {{ statRules?.total ?? '-' }}</div>
|
||||
<div>보너스 합 {{ resetBonusSum }} · 비용 {{ resetStatCost }}</div>
|
||||
<div v-if="resetStatErrors.length" class="stat-errors">
|
||||
<div v-for="item in resetStatErrors" :key="item">{{ item }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-row">
|
||||
<button :disabled="isUnited || actionBusy" class="ghost" @click="applyResetBalanced">균형형</button>
|
||||
<button :disabled="isUnited || actionBusy" @click="resetStats">능력치 초기화</button>
|
||||
</div>
|
||||
</div>
|
||||
</PanelCard>
|
||||
|
||||
<PanelCard title="유니크 상점" subtitle="유니크 아이템 관련 기능">
|
||||
<div v-if="!status" class="muted">유니크 정보를 불러오지 못했습니다.</div>
|
||||
<div v-else class="action-stack">
|
||||
<div class="action-row">
|
||||
<div class="muted">랜덤 유니크 구매 ({{ status.inheritConst.inheritItemRandomPoint }} 포인트)</div>
|
||||
<button :disabled="isUnited || actionBusy" @click="buyRandomUnique">구입</button>
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<label class="form-field">
|
||||
<span>유니크 아이템 ID</span>
|
||||
<input v-model="uniqueForm.itemId" type="text" class="form-input" />
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>입찰 포인트</span>
|
||||
<input v-model.number="uniqueForm.amount" type="number" class="form-input" />
|
||||
<small class="muted">최소 {{ status.inheritConst.inheritItemUniqueMinPoint }} 포인트</small>
|
||||
</label>
|
||||
<button :disabled="isUnited || actionBusy" @click="openUniqueAuction">신청</button>
|
||||
</div>
|
||||
</div>
|
||||
</PanelCard>
|
||||
|
||||
<PanelCard title="소유자 확인" subtitle="상대 장수의 소유자를 확인합니다.">
|
||||
<div v-if="!status" class="muted">대상 장수 목록을 불러오지 못했습니다.</div>
|
||||
<div v-else class="action-stack">
|
||||
<label class="form-field">
|
||||
<span>대상 장수</span>
|
||||
<select v-model="ownerTargetId" class="form-input">
|
||||
<option v-for="general in status.availableTargetGenerals" :key="general.id" :value="String(general.id)">
|
||||
{{ general.name }}
|
||||
</option>
|
||||
</select>
|
||||
<small class="muted">비용 {{ status.inheritConst.inheritCheckOwnerPoint }} 포인트</small>
|
||||
</label>
|
||||
<button :disabled="isUnited || actionBusy" @click="checkOwner">확인</button>
|
||||
<div v-if="ownerResult" class="muted">
|
||||
{{ ownerResult.targetName }}의 소유자: {{ ownerResult.ownerName }}
|
||||
</div>
|
||||
</div>
|
||||
</PanelCard>
|
||||
|
||||
<PanelCard title="유산 기록" subtitle="최근 유산 로그">
|
||||
<template #actions>
|
||||
<button class="ghost" :disabled="logLoading" @click="loadLogs(true)">갱신</button>
|
||||
</template>
|
||||
<div v-if="logLoading && logs.length === 0">
|
||||
<SkeletonLines :lines="3" />
|
||||
</div>
|
||||
<div v-else-if="logs.length === 0" class="muted">기록이 없습니다.</div>
|
||||
<div v-else class="log-list">
|
||||
<div v-for="entry in logs" :key="entry.id" class="log-entry">
|
||||
<span class="log-date">{{ entry.year }}년 {{ entry.month }}월</span>
|
||||
<span>{{ entry.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-footer">
|
||||
<button class="ghost" :disabled="logLoading || logEnd" @click="loadLogs()">더 보기</button>
|
||||
</div>
|
||||
</PanelCard>
|
||||
</section>
|
||||
<button class="legacy-button more-button" :disabled="logLoading || logEnd" @click="loadLogs()">
|
||||
더 가져오기
|
||||
</button>
|
||||
</section>
|
||||
</template>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.inherit-page {
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.inherit-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid rgba(201, 164, 90, 0.4);
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.inherit-title {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.inherit-subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: rgba(232, 221, 196, 0.7);
|
||||
}
|
||||
|
||||
.inherit-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.inherit-error {
|
||||
border: 1px solid rgba(240, 90, 90, 0.6);
|
||||
padding: 8px 10px;
|
||||
color: rgba(240, 150, 150, 0.9);
|
||||
}
|
||||
|
||||
.inherit-message {
|
||||
border: 1px solid rgba(120, 190, 120, 0.5);
|
||||
padding: 8px 10px;
|
||||
color: rgba(180, 230, 180, 0.9);
|
||||
}
|
||||
|
||||
.inherit-grid {
|
||||
.top-back-bar {
|
||||
width: min(100%, 1000px);
|
||||
min-height: 38px;
|
||||
margin: 0 auto;
|
||||
border: 1px solid #888;
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
}
|
||||
|
||||
.summary-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.summary-head {
|
||||
display: flex;
|
||||
grid-template-columns: 100px 1fr 100px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
text-align: center;
|
||||
padding: 3px 6px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.summary-total {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.summary-state {
|
||||
font-size: 0.75rem;
|
||||
.top-button {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.summary-state.united {
|
||||
border-color: rgba(240, 120, 120, 0.6);
|
||||
color: rgba(240, 150, 150, 0.9);
|
||||
.inherit-page {
|
||||
width: min(100%, 1000px);
|
||||
margin: 0 auto;
|
||||
border: 1px solid #888;
|
||||
border-top: 0;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
padding: 0 8px 10px;
|
||||
color: #fff;
|
||||
font:
|
||||
14px/1.3 Pretendard,
|
||||
'Apple SD Gothic Neo',
|
||||
'Noto Sans KR',
|
||||
'Malgun Gothic',
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
.summary-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
.notice,
|
||||
.loading-state,
|
||||
.log-empty {
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.summary-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.8rem;
|
||||
color: rgba(232, 221, 196, 0.8);
|
||||
.notice.error {
|
||||
color: #ffb0b0;
|
||||
}
|
||||
|
||||
.summary-footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 0.75rem;
|
||||
color: rgba(232, 221, 196, 0.7);
|
||||
.notice.success {
|
||||
color: #b6efb6;
|
||||
}
|
||||
|
||||
.buff-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.buff-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
border: 1px solid rgba(201, 164, 90, 0.2);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.buff-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.buff-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.buff-level {
|
||||
font-size: 0.7rem;
|
||||
color: rgba(232, 221, 196, 0.7);
|
||||
}
|
||||
|
||||
.buff-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.buff-cost {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(232, 221, 196, 0.7);
|
||||
}
|
||||
|
||||
.action-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.stat-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat-grid {
|
||||
.point-grid,
|
||||
.action-grid,
|
||||
.buff-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.stat-summary {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(232, 221, 196, 0.7);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
.inherit-item,
|
||||
.shop-item {
|
||||
padding: 8px 16px;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stat-errors {
|
||||
color: rgba(240, 150, 150, 0.9);
|
||||
.inherit-item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(100px, 1fr);
|
||||
align-items: start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.form-field {
|
||||
.inherit-item label {
|
||||
text-align: right;
|
||||
padding: 7px 8px 0 0;
|
||||
}
|
||||
|
||||
.inherit-item input,
|
||||
.shop-item input,
|
||||
.shop-item select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 1px solid #6c757d;
|
||||
border-radius: 4px;
|
||||
background: #212529;
|
||||
color: #fff;
|
||||
padding: 6px 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.inherit-item small,
|
||||
.shop-item small {
|
||||
grid-column: 1 / -1;
|
||||
min-height: 34px;
|
||||
text-align: right;
|
||||
color: #aeb2b6;
|
||||
}
|
||||
|
||||
.inherit-item small {
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.divider {
|
||||
grid-column: 1 / -1;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.22);
|
||||
margin: 4px 2px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
text-align: center;
|
||||
margin: 0 -8px;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.leading-actions .shop-item:first-child {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.control-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.control-row > label,
|
||||
.control-row > span {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.shop-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
||||
background: rgba(10, 10, 10, 0.8);
|
||||
padding: 6px 8px;
|
||||
color: inherit;
|
||||
.shop-item .buy-button {
|
||||
width: 50%;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
||||
padding: 6px 12px;
|
||||
font-size: 0.8rem;
|
||||
background: rgba(16, 16, 16, 0.6);
|
||||
color: inherit;
|
||||
.simple-item small {
|
||||
min-height: 55px;
|
||||
}
|
||||
|
||||
button.ghost {
|
||||
background: transparent;
|
||||
.buff-item small {
|
||||
min-height: 72px;
|
||||
}
|
||||
|
||||
.log-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.dual-buttons {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.legacy-button.secondary {
|
||||
border-color: #51585e;
|
||||
background: #5c636a;
|
||||
}
|
||||
|
||||
.bottom-actions .shop-item:first-child {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.stat-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 0.8rem;
|
||||
.stat-layout > div {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.log-date {
|
||||
font-size: 0.7rem;
|
||||
color: rgba(232, 221, 196, 0.6);
|
||||
.stat-layout label {
|
||||
display: grid;
|
||||
grid-template-columns: 22px 1fr;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.log-footer {
|
||||
margin-top: 8px;
|
||||
.stat-layout strong {
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: rgba(232, 221, 196, 0.6);
|
||||
font-size: 0.75rem;
|
||||
.owner-result {
|
||||
margin: 0;
|
||||
color: #fff;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.inherit-logs {
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
|
||||
.log-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(150px, 20ch) 1fr;
|
||||
gap: 8px;
|
||||
padding: 3px 8px;
|
||||
}
|
||||
|
||||
.log-row small {
|
||||
color: #aeb2b6;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.more-button {
|
||||
width: 100%;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
a:focus-visible {
|
||||
outline: 2px solid #f39c12;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.point-grid,
|
||||
.action-grid,
|
||||
.buff-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.leading-actions .shop-item:first-child {
|
||||
grid-column: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 575px) {
|
||||
.top-back-bar,
|
||||
.inherit-page {
|
||||
width: 500px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.point-grid,
|
||||
.action-grid,
|
||||
.buff-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.divider {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.inherit-item,
|
||||
.shop-item {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.log-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.log-row small {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,12 +6,11 @@ import { trpc } from '../utils/trpc';
|
||||
type Result = Awaited<ReturnType<typeof trpc.nation.getGeneralList.query>>;
|
||||
type General = Result['generals'][number];
|
||||
type Sort = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15;
|
||||
|
||||
const data = ref<Result | null>(null);
|
||||
const error = ref('');
|
||||
const loading = ref(false);
|
||||
const sort = ref<Sort>(1);
|
||||
const sortOptions = [
|
||||
const options = [
|
||||
'관직',
|
||||
'계급',
|
||||
'명성',
|
||||
@@ -28,9 +27,8 @@ const sortOptions = [
|
||||
'사관',
|
||||
'NPC',
|
||||
];
|
||||
|
||||
const visibleCrew = (general: General): number | null => ('crew' in general ? general.crew : null);
|
||||
const load = async () => {
|
||||
if (loading.value) return;
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
@@ -41,67 +39,59 @@ const load = async () => {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const sortedGenerals = computed(() =>
|
||||
[...(data.value?.generals ?? [])].sort((left, right) => {
|
||||
const key = sort.value;
|
||||
if (key === 1) return right.officerLevel - left.officerLevel || left.id - right.id;
|
||||
if (key === 2) return right.dedicationLevel - left.dedicationLevel || left.id - right.id;
|
||||
if (key === 3) return right.experienceLevel - left.experienceLevel || left.id - right.id;
|
||||
if (key === 4) return right.stats.leadership - left.stats.leadership || left.id - right.id;
|
||||
if (key === 5) return right.stats.strength - left.stats.strength || left.id - right.id;
|
||||
if (key === 6) return right.stats.intelligence - left.stats.intelligence || left.id - right.id;
|
||||
if (key === 7) return right.gold - left.gold || left.id - right.id;
|
||||
if (key === 8) return right.rice - left.rice || left.id - right.id;
|
||||
if (key === 9) return (right.detail?.crew ?? -1) - (left.detail?.crew ?? -1) || left.id - right.id;
|
||||
if (key === 10) return right.refreshScoreTotal - left.refreshScoreTotal || left.id - right.id;
|
||||
if (key === 11) return (left.personality?.name ?? '').localeCompare(right.personality?.name ?? '');
|
||||
if (key === 12) return (left.specialDomestic?.name ?? '').localeCompare(right.specialDomestic?.name ?? '');
|
||||
if (key === 13) return (left.specialWar?.name ?? '').localeCompare(right.specialWar?.name ?? '');
|
||||
if (key === 14) return right.belong - left.belong || left.id - right.id;
|
||||
return right.npcState - left.npcState || left.id - right.id;
|
||||
const generals = computed(() =>
|
||||
[...(data.value?.generals ?? [])].sort((a, b) => {
|
||||
if (sort.value === 1) return b.officerLevel - a.officerLevel || a.id - b.id;
|
||||
if (sort.value === 2) return b.dedicationLevel - a.dedicationLevel || a.id - b.id;
|
||||
if (sort.value === 3) return b.experienceLevel - a.experienceLevel || a.id - b.id;
|
||||
if (sort.value === 4) return b.stats.leadership - a.stats.leadership || a.id - b.id;
|
||||
if (sort.value === 5) return b.stats.strength - a.stats.strength || a.id - b.id;
|
||||
if (sort.value === 6) return b.stats.intelligence - a.stats.intelligence || a.id - b.id;
|
||||
if (sort.value === 7) return b.gold - a.gold || a.id - b.id;
|
||||
if (sort.value === 8) return b.rice - a.rice || a.id - b.id;
|
||||
if (sort.value === 9) return (visibleCrew(b) ?? -1) - (visibleCrew(a) ?? -1) || a.id - b.id;
|
||||
if (sort.value === 10) return b.refreshScoreTotal - a.refreshScoreTotal || a.id - b.id;
|
||||
if (sort.value === 11) return (a.personality?.name ?? '').localeCompare(b.personality?.name ?? '');
|
||||
if (sort.value === 12) return (a.specialDomestic?.name ?? '').localeCompare(b.specialDomestic?.name ?? '');
|
||||
if (sort.value === 13) return (a.specialWar?.name ?? '').localeCompare(b.specialWar?.name ?? '');
|
||||
if (sort.value === 14) return b.belong - a.belong || a.id - b.id;
|
||||
if (sort.value === 15) return b.npcState - a.npcState || a.id - b.id;
|
||||
return a.id - b.id;
|
||||
})
|
||||
);
|
||||
|
||||
const imageUrl = (general: General): string => {
|
||||
const picture = general.picture || 'default.jpg';
|
||||
return general.imageServer ? `${import.meta.env.BASE_URL}d_pic/${picture}` : `/image/icons/${picture}`;
|
||||
};
|
||||
const specialText = (general: General): string =>
|
||||
`${general.specialDomestic?.name ?? '-'} / ${general.specialWar?.name ?? '-'}`;
|
||||
|
||||
const special = (general: General) => `${general.specialDomestic?.name ?? '-'} / ${general.specialWar?.name ?? '-'}`;
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="general-page legacy-bg0">
|
||||
<header class="top-bar">
|
||||
<header>
|
||||
<strong>세력 장수</strong>
|
||||
<span class="toolbar">
|
||||
<RouterLink to="/">돌아가기</RouterLink>
|
||||
<button type="button" :disabled="loading" @click="load">새로고침</button>
|
||||
</span>
|
||||
<span
|
||||
><RouterLink to="/">돌아가기</RouterLink>
|
||||
<button :disabled="loading" @click="load">새로고침</button></span
|
||||
>
|
||||
</header>
|
||||
<section class="sort-bar">
|
||||
<section class="sort">
|
||||
정렬순서 :
|
||||
<select v-model.number="sort" aria-label="세력 장수 정렬">
|
||||
<option v-for="(label, index) in sortOptions" :key="label" :value="index + 1">{{ label }}</option>
|
||||
<option v-for="(label, index) in options" :key="label" :value="index + 1">{{ label }}</option>
|
||||
</select>
|
||||
<button type="button">정렬하기</button>
|
||||
<span v-if="data" class="permission">열람 등급 {{ data.viewer.permission }}</span>
|
||||
<button>정렬하기</button>
|
||||
<small v-if="data">열람 등급 {{ data.viewer.permission }}</small>
|
||||
</section>
|
||||
<p v-if="error" class="error" role="alert">{{ error }}</p>
|
||||
<p v-if="error" class="state error" role="alert">{{ error }}</p>
|
||||
<p v-else-if="loading" class="state">불러오는 중...</p>
|
||||
<div v-else class="grid-scroll">
|
||||
<table id="nation-general-list" class="general-table">
|
||||
<div v-else class="scroll">
|
||||
<table id="nation-general-list">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="icon-column">아이콘</th>
|
||||
<th class="name-column">장수명</th>
|
||||
<th>관직</th>
|
||||
<th>통|무|지</th>
|
||||
<th>이 름</th>
|
||||
<th>관 직</th>
|
||||
<th>통무지</th>
|
||||
<th>명성/계급</th>
|
||||
<th>금/쌀</th>
|
||||
<th>자금</th>
|
||||
<th>군량</th>
|
||||
<th>도시</th>
|
||||
<th>부대</th>
|
||||
<th>병사</th>
|
||||
@@ -112,28 +102,29 @@ onMounted(load);
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="general in sortedGenerals" :key="general.id">
|
||||
<td class="icon"><img :src="imageUrl(general)" width="64" height="64" alt="" /></td>
|
||||
<td class="name" :class="`npc-${general.npcState}`">
|
||||
<RouterLink :to="`/battle-center?generalId=${general.id}`">{{ general.name }}</RouterLink>
|
||||
</td>
|
||||
<tr v-for="general in generals" :key="general.id">
|
||||
<td :class="`npc-${general.npcState}`">{{ general.name }}</td>
|
||||
<td>{{ formatOfficerLevelText(general.officerLevel, data?.nation.level) }}</td>
|
||||
<td>
|
||||
<span :class="{ wounded: general.injury > 0 }">{{ general.stats.leadership }}</span
|
||||
><span v-if="general.leadershipBonus" class="bonus">+{{ general.leadershipBonus }}</span>
|
||||
| <span :class="{ wounded: general.injury > 0 }">{{ general.stats.strength }}</span> |
|
||||
<span :class="{ wounded: general.injury > 0 }">{{ general.stats.intelligence }}</span>
|
||||
{{ general.stats.leadership }}∥{{ general.stats.strength }}∥{{ general.stats.intelligence }}
|
||||
</td>
|
||||
<td>Lv {{ general.experienceLevel }}<br />{{ general.dedicationLevelText }}</td>
|
||||
<td>{{ general.gold.toLocaleString() }}<br />{{ general.rice.toLocaleString() }}</td>
|
||||
<td>{{ general.detail?.cityName ?? '?' }}</td>
|
||||
<td>{{ general.detail?.troopName ?? (general.detail ? '-' : '?') }}</td>
|
||||
<td>{{ general.detail?.crew.toLocaleString() ?? '?' }}</td>
|
||||
<td>
|
||||
Lv {{ general.experienceLevel }}<br />{{
|
||||
general.dedicationLevel ? `${11 - general.dedicationLevel}품관` : '무품관'
|
||||
}}
|
||||
</td>
|
||||
<td>{{ general.gold.toLocaleString() }}</td>
|
||||
<td>{{ general.rice.toLocaleString() }}</td>
|
||||
<td>{{ general.cityName ?? '?' }}</td>
|
||||
<td>{{ general.troopName ?? '?' }}</td>
|
||||
<td>{{ visibleCrew(general)?.toLocaleString() ?? '?' }}</td>
|
||||
<td :title="general.personality?.info ?? ''">{{ general.personality?.name ?? '-' }}</td>
|
||||
<td
|
||||
:title="[general.specialDomestic?.info, general.specialWar?.info].filter(Boolean).join('\\n')"
|
||||
:title="
|
||||
[general.specialDomestic?.info, general.specialWar?.info].filter(Boolean).join('\n')
|
||||
"
|
||||
>
|
||||
{{ specialText(general) }}
|
||||
{{ special(general) }}
|
||||
</td>
|
||||
<td>{{ general.belong }}</td>
|
||||
<td>{{ general.refreshScoreTotal }}</td>
|
||||
@@ -149,31 +140,30 @@ onMounted(load);
|
||||
.general-page {
|
||||
width: 1000px;
|
||||
min-height: 100vh;
|
||||
margin: 0 auto;
|
||||
margin: 8px auto 0;
|
||||
font:
|
||||
16px 'Times New Roman',
|
||||
serif;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
}
|
||||
.top-bar,
|
||||
.sort-bar,
|
||||
header,
|
||||
.sort,
|
||||
footer,
|
||||
.state,
|
||||
.error {
|
||||
.state {
|
||||
position: relative;
|
||||
border: 1px solid #777;
|
||||
padding: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
.top-bar {
|
||||
position: relative;
|
||||
header {
|
||||
min-height: 39px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.toolbar {
|
||||
header span {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
button,
|
||||
select {
|
||||
@@ -183,57 +173,41 @@ select {
|
||||
color: #fff;
|
||||
padding: 1px 6px;
|
||||
}
|
||||
.permission {
|
||||
.sort small {
|
||||
float: right;
|
||||
margin-right: 6px;
|
||||
color: #ccc;
|
||||
}
|
||||
.grid-scroll {
|
||||
width: 100%;
|
||||
min-height: calc(100vh - 116px);
|
||||
.scroll {
|
||||
width: 1030px;
|
||||
margin-left: -15px;
|
||||
min-height: calc(100vh - 112px);
|
||||
overflow: auto;
|
||||
}
|
||||
.general-table {
|
||||
width: 100%;
|
||||
min-width: 1000px;
|
||||
border-collapse: collapse;
|
||||
table {
|
||||
width: 1030px;
|
||||
min-width: 1030px;
|
||||
border-collapse: separate;
|
||||
table-layout: fixed;
|
||||
}
|
||||
.general-table th,
|
||||
.general-table td {
|
||||
th,
|
||||
td {
|
||||
border: 1px solid #777;
|
||||
padding: 2px 3px;
|
||||
padding: 3px;
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.general-table th {
|
||||
th {
|
||||
height: 30px;
|
||||
background: #14241b url('/image/game/back_green.jpg');
|
||||
font-weight: 400;
|
||||
}
|
||||
.general-table tbody tr {
|
||||
height: 68px;
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
.icon-column {
|
||||
width: 70px;
|
||||
}
|
||||
.name-column {
|
||||
width: 88px;
|
||||
}
|
||||
.icon {
|
||||
padding: 1px !important;
|
||||
}
|
||||
.icon img {
|
||||
display: block;
|
||||
margin: auto;
|
||||
object-fit: cover;
|
||||
}
|
||||
.name a {
|
||||
color: inherit;
|
||||
tbody tr {
|
||||
height: 66px;
|
||||
background: rgb(0 0 0 / 18%);
|
||||
}
|
||||
.npc-1 {
|
||||
color: #0ff;
|
||||
color: cyan;
|
||||
}
|
||||
.npc-2,
|
||||
.npc-3,
|
||||
@@ -241,21 +215,12 @@ select {
|
||||
.npc-5 {
|
||||
color: #aaa;
|
||||
}
|
||||
.wounded {
|
||||
color: red;
|
||||
}
|
||||
.bonus {
|
||||
color: cyan;
|
||||
}
|
||||
.error {
|
||||
color: #ff7373;
|
||||
}
|
||||
footer {
|
||||
min-height: 25px;
|
||||
}
|
||||
@media (max-width: 1000px) {
|
||||
.general-page {
|
||||
margin: 0;
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
type Result = Awaited<ReturnType<typeof trpc.nation.getSecretGeneralList.query>>;
|
||||
type General = Result['generals'][number];
|
||||
type Sort = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
|
||||
|
||||
const data = ref<Result | null>(null);
|
||||
const error = ref('');
|
||||
const loading = ref(false);
|
||||
const sort = ref<Sort>(7);
|
||||
const sortOptions = ['자금', '군량', '도시', '병종', '병사', '삭제턴', '턴', '부대'];
|
||||
const options = ['자금', '군량', '도시', '병종', '병사', '삭제턴', '턴', '부대'];
|
||||
const load = async () => {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
@@ -23,30 +20,23 @@ const load = async () => {
|
||||
}
|
||||
};
|
||||
const generals = computed(() =>
|
||||
[...(data.value?.generals ?? [])].sort((left, right) => {
|
||||
const leftDetail = left.detail;
|
||||
const rightDetail = right.detail;
|
||||
if (!leftDetail || !rightDetail) return left.id - right.id;
|
||||
if (sort.value === 1) return right.gold - left.gold || left.id - right.id;
|
||||
if (sort.value === 2) return right.rice - left.rice || left.id - right.id;
|
||||
if (sort.value === 3) return leftDetail.cityId - rightDetail.cityId || left.id - right.id;
|
||||
if (sort.value === 4) return rightDetail.crewTypeId - leftDetail.crewTypeId || left.id - right.id;
|
||||
if (sort.value === 5) return rightDetail.crew - leftDetail.crew || left.id - right.id;
|
||||
if (sort.value === 6) return leftDetail.killTurn - rightDetail.killTurn || left.id - right.id;
|
||||
if (sort.value === 7) return leftDetail.turnTime.localeCompare(rightDetail.turnTime) || left.id - right.id;
|
||||
return rightDetail.troopId - leftDetail.troopId || left.id - right.id;
|
||||
[...(data.value?.generals ?? [])].sort((a, b) => {
|
||||
if (sort.value === 1) return b.gold - a.gold || a.id - b.id;
|
||||
if (sort.value === 2) return b.rice - a.rice || a.id - b.id;
|
||||
if (sort.value === 3) return a.cityId - b.cityId || a.id - b.id;
|
||||
if (sort.value === 4) return b.crewTypeId - a.crewTypeId || a.id - b.id;
|
||||
if (sort.value === 5) return b.crew - a.crew || a.id - b.id;
|
||||
if (sort.value === 6) return a.killTurn - b.killTurn || a.id - b.id;
|
||||
if (sort.value === 7) return a.turnTime.localeCompare(b.turnTime) || a.id - b.id;
|
||||
return b.troopId - a.troopId || a.id - b.id;
|
||||
})
|
||||
);
|
||||
const turnTime = (general: General): string => {
|
||||
const value = general.detail?.turnTime;
|
||||
return value ? value.slice(11, 16) : '-';
|
||||
};
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="secret-page">
|
||||
<table class="layout-table legacy-bg0 title">
|
||||
<table class="layout legacy-bg0 title">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>암 행 부<br /><RouterLink to="/">창 닫기</RouterLink></td>
|
||||
@@ -55,20 +45,19 @@ onMounted(load);
|
||||
<td>
|
||||
정렬순서 :
|
||||
<select v-model.number="sort" aria-label="암행부 정렬">
|
||||
<option v-for="(label, index) in sortOptions" :key="label" :value="index + 1">
|
||||
<option v-for="(label, index) in options" :key="label" :value="index + 1">
|
||||
{{ label }}
|
||||
</option>
|
||||
</select>
|
||||
<button type="button">정렬하기</button>
|
||||
<button type="button" :disabled="loading" @click="load">새로고침</button>
|
||||
<button>정렬하기</button> <button :disabled="loading" @click="load">새로고침</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-if="error" class="error legacy-bg0" role="alert">{{ error }}</p>
|
||||
<p v-if="error" class="state error legacy-bg0" role="alert">{{ error }}</p>
|
||||
<p v-else-if="loading" class="state legacy-bg0">불러오는 중...</p>
|
||||
<template v-else-if="data">
|
||||
<table class="layout-table summary legacy-bg0">
|
||||
<table class="layout summary legacy-bg0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>전체 금</th>
|
||||
@@ -76,49 +65,39 @@ onMounted(load);
|
||||
<th>전체 쌀</th>
|
||||
<td>{{ data.summary.rice.toLocaleString() }}</td>
|
||||
<th>평균 금</th>
|
||||
<td>{{ data.summary.averageGold.toLocaleString(undefined, { maximumFractionDigits: 2 }) }}</td>
|
||||
<td>{{ data.summary.averageGold.toFixed(2) }}</td>
|
||||
<th>평균 쌀</th>
|
||||
<td>{{ data.summary.averageRice.toLocaleString(undefined, { maximumFractionDigits: 2 }) }}</td>
|
||||
<td>{{ data.summary.averageRice.toFixed(2) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>전체 병력/장수</th>
|
||||
<td>{{ data.summary.crew.toLocaleString() }}/{{ data.summary.generalCount }}</td>
|
||||
<th>훈사 90 병력/장수</th>
|
||||
<td>
|
||||
{{ data.summary.readiness[90].crew.toLocaleString() }}/{{
|
||||
data.summary.readiness[90].generals
|
||||
}}
|
||||
</td>
|
||||
<th>훈사 80 병력/장수</th>
|
||||
<td>
|
||||
{{ data.summary.readiness[80].crew.toLocaleString() }}/{{
|
||||
data.summary.readiness[80].generals
|
||||
}}
|
||||
</td>
|
||||
<th>훈사 60 병력/장수</th>
|
||||
<td>
|
||||
{{ data.summary.readiness[60].crew.toLocaleString() }}/{{
|
||||
data.summary.readiness[60].generals
|
||||
}}
|
||||
</td>
|
||||
<template v-for="level in [90, 80, 60] as const" :key="level"
|
||||
><th>훈사 {{ level }} 병력/장수</th>
|
||||
<td>
|
||||
{{ data.summary.readiness[level].crew.toLocaleString() }}/{{
|
||||
data.summary.readiness[level].generals
|
||||
}}
|
||||
</td></template
|
||||
>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table id="secret-general-list" class="layout-table general-list legacy-bg0">
|
||||
<table id="secret-general-list" class="layout list legacy-bg0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="name">이 름</th>
|
||||
<th class="stat">통무지</th>
|
||||
<th class="troop">부 대</th>
|
||||
<th>이 름</th>
|
||||
<th>통무지</th>
|
||||
<th>부 대</th>
|
||||
<th>자 금</th>
|
||||
<th>군 량</th>
|
||||
<th>도시</th>
|
||||
<th class="mode">守</th>
|
||||
<th>守</th>
|
||||
<th>병 종</th>
|
||||
<th>병 사</th>
|
||||
<th>훈련</th>
|
||||
<th>사기</th>
|
||||
<th class="turns">명 령</th>
|
||||
<th class="commands">명 령</th>
|
||||
<th>삭턴</th>
|
||||
<th>턴</th>
|
||||
</tr>
|
||||
@@ -127,38 +106,32 @@ onMounted(load);
|
||||
<tr v-for="general in generals" :key="general.id">
|
||||
<td>{{ general.name }}<br />Lv {{ general.experienceLevel }}</td>
|
||||
<td>
|
||||
<span :class="{ wounded: general.injury > 0 }">{{ general.stats.leadership }}</span
|
||||
><span v-if="general.leadershipBonus" class="bonus">+{{ general.leadershipBonus }}</span
|
||||
>∥<span :class="{ wounded: general.injury > 0 }">{{ general.stats.strength }}</span
|
||||
>∥<span :class="{ wounded: general.injury > 0 }">{{ general.stats.intelligence }}</span>
|
||||
{{ general.stats.leadership }}∥{{ general.stats.strength }}∥{{ general.stats.intelligence }}
|
||||
</td>
|
||||
<td>{{ general.detail?.troopName ?? '-' }}</td>
|
||||
<td>{{ general.troopName ?? '-' }}</td>
|
||||
<td>{{ general.gold }}</td>
|
||||
<td>{{ general.rice }}</td>
|
||||
<td>{{ general.detail?.cityName ?? '-' }}</td>
|
||||
<td>{{ general.cityName ?? '-' }}</td>
|
||||
<td>{{ general.defenceTrainText }}</td>
|
||||
<td>{{ general.detail?.crewTypeId ?? '-' }}</td>
|
||||
<td>{{ general.detail?.crew ?? '-' }}</td>
|
||||
<td>{{ general.detail?.train ?? '-' }}</td>
|
||||
<td>{{ general.detail?.atmos ?? '-' }}</td>
|
||||
<td class="turn-list">
|
||||
<template v-if="general.npcState >= 2">NPC 장수</template>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="(command, index) in general.detail?.reservedCommands ?? []"
|
||||
:key="`${general.id}-${index}`"
|
||||
>
|
||||
<td>{{ general.crewTypeId }}</td>
|
||||
<td>{{ general.crew }}</td>
|
||||
<td>{{ general.train }}</td>
|
||||
<td>{{ general.atmos }}</td>
|
||||
<td class="turns">
|
||||
<template v-if="general.npcState >= 2">NPC 장수</template
|
||||
><template v-else
|
||||
><div v-for="(command, index) in general.reservedCommands" :key="index">
|
||||
{{ index + 1 }} : {{ command }}
|
||||
</div>
|
||||
</template>
|
||||
</div></template
|
||||
>
|
||||
</td>
|
||||
<td>{{ general.detail?.killTurn ?? '-' }}</td>
|
||||
<td>{{ turnTime(general) }}</td>
|
||||
<td>{{ general.killTurn }}</td>
|
||||
<td>{{ general.turnTime.slice(11, 16) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
<table class="layout-table legacy-bg0 footer">
|
||||
<table class="layout legacy-bg0 footer">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><RouterLink to="/">창 닫기</RouterLink></td>
|
||||
@@ -171,28 +144,27 @@ onMounted(load);
|
||||
<style scoped>
|
||||
.secret-page {
|
||||
width: 1000px;
|
||||
margin: 0 auto;
|
||||
font-size: 14px;
|
||||
margin: 8px auto 0;
|
||||
font:
|
||||
16px 'Times New Roman',
|
||||
serif;
|
||||
color: #fff;
|
||||
}
|
||||
.layout-table {
|
||||
.layout {
|
||||
width: 1000px;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
.layout-table td,
|
||||
.layout-table th,
|
||||
.state,
|
||||
.error {
|
||||
td,
|
||||
th,
|
||||
.state {
|
||||
border: 1px solid #777;
|
||||
padding: 3px;
|
||||
text-align: center;
|
||||
font-weight: 400;
|
||||
text-align: center;
|
||||
}
|
||||
.title {
|
||||
text-align: center;
|
||||
}
|
||||
.title button,
|
||||
.title select {
|
||||
button,
|
||||
select {
|
||||
border: 1px solid #888;
|
||||
border-radius: 2px;
|
||||
background: #222;
|
||||
@@ -203,39 +175,27 @@ onMounted(load);
|
||||
margin: 5px auto;
|
||||
}
|
||||
.summary th,
|
||||
.general-list th {
|
||||
.list th {
|
||||
background: #14241b url('/image/game/back_green.jpg');
|
||||
}
|
||||
.summary th {
|
||||
width: 120px;
|
||||
}
|
||||
.general-list {
|
||||
table-layout: fixed;
|
||||
.list {
|
||||
width: 1030px;
|
||||
margin-left: -15px;
|
||||
border-collapse: separate;
|
||||
}
|
||||
.general-list .name,
|
||||
.general-list .stat,
|
||||
.general-list .troop {
|
||||
width: 98px;
|
||||
.list tbody tr {
|
||||
height: 39px;
|
||||
}
|
||||
.general-list .mode {
|
||||
width: 28px;
|
||||
}
|
||||
.general-list .turns {
|
||||
.commands {
|
||||
width: 213px;
|
||||
}
|
||||
.general-list tbody tr {
|
||||
height: 50px;
|
||||
}
|
||||
.turn-list {
|
||||
text-align: left !important;
|
||||
.turns {
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
}
|
||||
.wounded {
|
||||
color: red;
|
||||
}
|
||||
.bonus {
|
||||
color: cyan;
|
||||
}
|
||||
.error {
|
||||
color: #ff7373;
|
||||
}
|
||||
@@ -244,7 +204,7 @@ onMounted(load);
|
||||
}
|
||||
@media (max-width: 1000px) {
|
||||
.secret-page {
|
||||
margin: 0;
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,78 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import PanelCard from '../components/ui/PanelCard.vue';
|
||||
import SkeletonLines from '../components/ui/SkeletonLines.vue';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
import { npcPriorityHelp } from '../utils/npcPriorityHelp';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
type NpcPolicyResponse = Awaited<ReturnType<typeof trpc.npc.getPolicy.query>>;
|
||||
type NationPolicy = NpcPolicyResponse['currentNationPolicy'];
|
||||
type PolicyKey = keyof NationPolicy;
|
||||
type PolicyField = {
|
||||
key: PolicyKey;
|
||||
type NumericPolicyKey = Exclude<keyof NationPolicy, 'CombatForce' | 'SupportForce' | 'DevelopForce'>;
|
||||
type PrioritySectionKey = 'nation' | 'general';
|
||||
type PriorityBucket = 'active' | 'inactive';
|
||||
|
||||
interface PolicyField {
|
||||
key: NumericPolicyKey;
|
||||
label: string;
|
||||
step: number;
|
||||
description: string;
|
||||
hint?: string;
|
||||
percent?: boolean;
|
||||
};
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
type PolicySection = {
|
||||
title: string;
|
||||
fields: PolicyField[];
|
||||
};
|
||||
|
||||
const NUMERIC_POLICY_KEYS = [
|
||||
'reqNationGold',
|
||||
'reqNationRice',
|
||||
'reqHumanWarUrgentGold',
|
||||
'reqHumanWarUrgentRice',
|
||||
'reqHumanWarRecommandGold',
|
||||
'reqHumanWarRecommandRice',
|
||||
'reqHumanDevelGold',
|
||||
'reqHumanDevelRice',
|
||||
'reqNPCWarGold',
|
||||
'reqNPCWarRice',
|
||||
'reqNPCDevelGold',
|
||||
'reqNPCDevelRice',
|
||||
'minimumResourceActionAmount',
|
||||
'maximumResourceActionAmount',
|
||||
'minNPCWarLeadership',
|
||||
'minWarCrew',
|
||||
'minNPCRecruitCityPopulation',
|
||||
'safeRecruitCityPopulationRatio',
|
||||
'properWarTrainAtmos',
|
||||
'cureThreshold',
|
||||
] as const;
|
||||
|
||||
type NumericPolicyKey = (typeof NUMERIC_POLICY_KEYS)[number];
|
||||
|
||||
type PrioritySectionKey = 'nation' | 'general';
|
||||
|
||||
type PriorityListState = {
|
||||
interface PriorityListState {
|
||||
active: string[];
|
||||
inactive: string[];
|
||||
available: string[];
|
||||
};
|
||||
}
|
||||
|
||||
interface PriorityPanel {
|
||||
key: PrioritySectionKey;
|
||||
title: string;
|
||||
description: string[];
|
||||
setter: NpcPolicyResponse['lastSetters']['nation'];
|
||||
state: PriorityListState;
|
||||
}
|
||||
|
||||
interface DragState {
|
||||
section: PrioritySectionKey;
|
||||
bucket: PriorityBucket;
|
||||
index: number;
|
||||
}
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const notice = ref<string | null>(null);
|
||||
const data = ref<NpcPolicyResponse | null>(null);
|
||||
const policyDraft = ref<NationPolicy | null>(null);
|
||||
const lastSavedPolicy = ref<NationPolicy | null>(null);
|
||||
|
||||
const nationPriority = ref<PriorityListState | null>(null);
|
||||
const generalPriority = ref<PriorityListState | null>(null);
|
||||
const lastSavedNationPriority = ref<string[]>([]);
|
||||
const lastSavedGeneralPriority = ref<string[]>([]);
|
||||
const dragState = ref<DragState | null>(null);
|
||||
|
||||
const resolveErrorMessage = (value: unknown): string => {
|
||||
if (value instanceof Error) {
|
||||
return value.message;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
if (value instanceof Error) return value.message;
|
||||
if (typeof value === 'string') return value;
|
||||
return 'unknown_error';
|
||||
};
|
||||
|
||||
@@ -85,851 +68,870 @@ const clonePolicy = (source: NationPolicy): NationPolicy => ({
|
||||
|
||||
const assignPriorityState = (active: string[], available: string[]): PriorityListState => {
|
||||
const activeSet = new Set(active);
|
||||
const inactive = available.filter((item) => !activeSet.has(item));
|
||||
return {
|
||||
active: [...active],
|
||||
inactive,
|
||||
inactive: available.filter((item) => !activeSet.has(item)),
|
||||
available: [...available],
|
||||
};
|
||||
};
|
||||
|
||||
const loadPolicy = async () => {
|
||||
if (loading.value) {
|
||||
return;
|
||||
}
|
||||
if (loading.value) return;
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
data.value = await trpc.npc.getPolicy.query();
|
||||
} catch (err) {
|
||||
error.value = resolveErrorMessage(err);
|
||||
} catch (caught) {
|
||||
error.value = resolveErrorMessage(caught);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
void loadPolicy();
|
||||
watch(data, (value) => {
|
||||
if (!value) return;
|
||||
policyDraft.value = clonePolicy(value.currentNationPolicy);
|
||||
lastSavedPolicy.value = clonePolicy(value.currentNationPolicy);
|
||||
nationPriority.value = assignPriorityState(value.currentNationPriority, value.availableNationPriorityItems);
|
||||
generalPriority.value = assignPriorityState(
|
||||
value.currentGeneralActionPriority,
|
||||
value.availableGeneralActionPriorityItems
|
||||
);
|
||||
lastSavedNationPriority.value = [...value.currentNationPriority];
|
||||
lastSavedGeneralPriority.value = [...value.currentGeneralActionPriority];
|
||||
});
|
||||
|
||||
watch(
|
||||
() => data.value,
|
||||
(value) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
policyDraft.value = clonePolicy(value.currentNationPolicy);
|
||||
lastSavedPolicy.value = clonePolicy(value.currentNationPolicy);
|
||||
nationPriority.value = assignPriorityState(value.currentNationPriority, value.availableNationPriorityItems);
|
||||
generalPriority.value = assignPriorityState(
|
||||
value.currentGeneralActionPriority,
|
||||
value.availableGeneralActionPriorityItems
|
||||
);
|
||||
lastSavedNationPriority.value = [...value.currentNationPriority];
|
||||
lastSavedGeneralPriority.value = [...value.currentGeneralActionPriority];
|
||||
}
|
||||
);
|
||||
onMounted(() => void loadPolicy());
|
||||
|
||||
const formatNumber = (value: number): string => new Intl.NumberFormat('ko-KR').format(Math.round(value));
|
||||
|
||||
const calcPolicyValue = (key: NumericPolicyKey): number => {
|
||||
if (!data.value || !policyDraft.value) {
|
||||
return 0;
|
||||
}
|
||||
if (!data.value || !policyDraft.value) return 0;
|
||||
const value = policyDraft.value[key];
|
||||
if (value === 0) {
|
||||
return data.value.zeroPolicy[key];
|
||||
}
|
||||
return value;
|
||||
return value === 0 ? data.value.zeroPolicy[key] : value;
|
||||
};
|
||||
|
||||
const safeRecruitPercent = computed({
|
||||
get: () => (policyDraft.value?.safeRecruitCityPopulationRatio ?? 0) * 100,
|
||||
set: (value: number) => {
|
||||
if (!policyDraft.value) {
|
||||
return;
|
||||
}
|
||||
policyDraft.value.safeRecruitCityPopulationRatio = value / 100;
|
||||
if (policyDraft.value) policyDraft.value.safeRecruitCityPopulationRatio = value / 100;
|
||||
},
|
||||
});
|
||||
|
||||
const policySections = computed<PolicySection[]>(() => {
|
||||
if (!data.value) {
|
||||
return [];
|
||||
}
|
||||
const policyFields = computed<PolicyField[]>(() => {
|
||||
if (!data.value) return [];
|
||||
const statMax = data.value.defaultStatMax;
|
||||
const statNpcMax = data.value.defaultStatNpcMax;
|
||||
|
||||
return [
|
||||
{
|
||||
title: '국가 재정',
|
||||
fields: [
|
||||
{
|
||||
key: 'reqNationGold',
|
||||
label: '국가 권장 금',
|
||||
step: 100,
|
||||
description: '이보다 많으면 포상, 적으면 몰수/헌납합니다.(긴급포상 제외)',
|
||||
},
|
||||
{
|
||||
key: 'reqNationRice',
|
||||
label: '국가 권장 쌀',
|
||||
step: 100,
|
||||
description: '이보다 많으면 포상, 적으면 몰수/헌납합니다.(긴급포상 제외)',
|
||||
},
|
||||
],
|
||||
key: 'reqNationGold',
|
||||
label: '국가 권장 금',
|
||||
step: 100,
|
||||
description: '이보다 많으면 포상, 적으면 몰수/헌납합니다.(긴급포상 제외)',
|
||||
},
|
||||
{
|
||||
title: '유저 전투장',
|
||||
fields: [
|
||||
{
|
||||
key: 'reqHumanWarUrgentGold',
|
||||
label: '긴급포상 금',
|
||||
step: 100,
|
||||
description:
|
||||
'유저장긴급포상시 이보다 금이 적은 장수에게 포상합니다.',
|
||||
hint: `0이면 보병 6회 징병(${formatNumber(statMax * 100 * 6)}) 가능한 금을 기준으로 하며, 현재 ${formatNumber(
|
||||
data.value.zeroPolicy.reqHumanWarUrgentGold
|
||||
)}입니다.`,
|
||||
},
|
||||
{
|
||||
key: 'reqHumanWarUrgentRice',
|
||||
label: '긴급포상 쌀',
|
||||
step: 100,
|
||||
description:
|
||||
'유저장긴급포상시 이보다 쌀이 적은 장수에게 포상합니다.',
|
||||
hint: `0이면 기본 병종으로 ${formatNumber(statMax * 100 * 6)}명 사살 가능한 쌀을 기준으로 하며, 현재 ${formatNumber(
|
||||
data.value.zeroPolicy.reqHumanWarUrgentRice
|
||||
)}입니다.`,
|
||||
},
|
||||
{
|
||||
key: 'reqHumanWarRecommandGold',
|
||||
label: '권장 금',
|
||||
step: 100,
|
||||
description: '유저전투장에게 주는 금입니다. 이보다 적으면 포상합니다.',
|
||||
hint: `0이면 긴급포상 금의 2배를 기준으로 하며, 현재 ${formatNumber(
|
||||
calcPolicyValue('reqHumanWarUrgentGold') * 2
|
||||
)}입니다.`,
|
||||
},
|
||||
{
|
||||
key: 'reqHumanWarRecommandRice',
|
||||
label: '권장 쌀',
|
||||
step: 100,
|
||||
description: '유저전투장에게 주는 쌀입니다. 이보다 적으면 포상합니다.',
|
||||
hint: `0이면 긴급포상 쌀의 2배를 기준으로 하며, 현재 ${formatNumber(
|
||||
calcPolicyValue('reqHumanWarUrgentRice') * 2
|
||||
)}입니다.`,
|
||||
},
|
||||
],
|
||||
key: 'reqNationRice',
|
||||
label: '국가 권장 쌀',
|
||||
step: 100,
|
||||
description: '이보다 많으면 포상, 적으면 몰수/헌납합니다.(긴급포상 제외)',
|
||||
},
|
||||
{
|
||||
title: '유저 내정장',
|
||||
fields: [
|
||||
{
|
||||
key: 'reqHumanDevelGold',
|
||||
label: '권장 금',
|
||||
step: 100,
|
||||
description: '유저내정장에게 주는 금입니다. 이보다 적으면 포상합니다.',
|
||||
},
|
||||
{
|
||||
key: 'reqHumanDevelRice',
|
||||
label: '권장 쌀',
|
||||
step: 100,
|
||||
description: '유저내정장에게 주는 쌀입니다. 이보다 적으면 포상합니다.',
|
||||
},
|
||||
],
|
||||
key: 'reqHumanWarUrgentGold',
|
||||
label: '유저전투장 긴급포상 금',
|
||||
step: 100,
|
||||
description: '유저장긴급포상시 이보다 금이 적은 장수에게 포상합니다.',
|
||||
hint: `0이면 보병 6회 징병(${formatNumber(statMax * 100)} * 6) 가능한 금을 기준으로 하며, 그 수치는 현재 ${formatNumber(data.value.zeroPolicy.reqHumanWarUrgentGold)}입니다.`,
|
||||
},
|
||||
{
|
||||
title: 'NPC 전투장',
|
||||
fields: [
|
||||
{
|
||||
key: 'reqNPCWarGold',
|
||||
label: '권장 금',
|
||||
step: 100,
|
||||
description: 'NPC전투장에게 주는 금입니다. 이보다 적으면 포상합니다.',
|
||||
hint: `0이면 기본 병종 4회(${formatNumber(statNpcMax * 100 * 4)}) 징병비를 기준으로 하며, 현재 ${formatNumber(
|
||||
data.value.zeroPolicy.reqNPCWarGold
|
||||
)}입니다.`,
|
||||
},
|
||||
{
|
||||
key: 'reqNPCWarRice',
|
||||
label: '권장 쌀',
|
||||
step: 100,
|
||||
description: 'NPC전투장에게 주는 쌀입니다. 이보다 적으면 포상합니다.',
|
||||
hint: `0이면 기본 병종으로 ${formatNumber(statNpcMax * 100 * 4)}명 사살 가능한 쌀을 기준으로 하며, 현재 ${formatNumber(
|
||||
data.value.zeroPolicy.reqNPCWarRice
|
||||
)}입니다.`,
|
||||
},
|
||||
],
|
||||
key: 'reqHumanWarUrgentRice',
|
||||
label: '유저전투장 긴급포상 쌀',
|
||||
step: 100,
|
||||
description: '유저장긴급포상시 이보다 쌀이 적은 장수에게 포상합니다.',
|
||||
hint: `0이면 기본 병종으로 ${formatNumber(statMax * 100)} * 6명 사살 가능한 쌀을 기준으로 하며, 그 수치는 현재 ${formatNumber(data.value.zeroPolicy.reqHumanWarUrgentRice)}입니다.`,
|
||||
},
|
||||
{
|
||||
title: 'NPC 내정장',
|
||||
fields: [
|
||||
{
|
||||
key: 'reqNPCDevelGold',
|
||||
label: '권장 금',
|
||||
step: 100,
|
||||
description: 'NPC내정장에게 주는 금입니다. 이보다 5배 더 많다면 헌납합니다.',
|
||||
hint: `0이면 30턴 내정 가능한 금을 기준으로 하며, 현재 ${formatNumber(
|
||||
data.value.zeroPolicy.reqNPCDevelGold
|
||||
)}입니다.`,
|
||||
},
|
||||
{
|
||||
key: 'reqNPCDevelRice',
|
||||
label: '권장 쌀',
|
||||
step: 100,
|
||||
description: 'NPC내정장에게 주는 쌀입니다. 이보다 5배 더 많다면 헌납합니다.',
|
||||
},
|
||||
],
|
||||
key: 'reqHumanWarRecommandGold',
|
||||
label: '유저전투장 권장 금',
|
||||
step: 100,
|
||||
description: '유저전투장에게 주는 금입니다. 이보다 적으면 포상합니다.',
|
||||
hint: `0이면 유저전투장 긴급포상 금의 2배를 기준으로 하며, 그 수치는 현재 ${formatNumber(calcPolicyValue('reqHumanWarUrgentGold') * 2)}입니다.`,
|
||||
},
|
||||
{
|
||||
title: '자원 정책',
|
||||
fields: [
|
||||
{
|
||||
key: 'minimumResourceActionAmount',
|
||||
label: '포상/몰수/헌납 최소 단위',
|
||||
step: 100,
|
||||
description: '연산결과가 이 단위보다 적다면 수행하지 않습니다.',
|
||||
},
|
||||
{
|
||||
key: 'maximumResourceActionAmount',
|
||||
label: '포상/몰수/헌납 최대 단위',
|
||||
step: 100,
|
||||
description: '연산결과가 이 단위보다 크다면 이 값에 맞춥니다.',
|
||||
},
|
||||
],
|
||||
key: 'reqHumanWarRecommandRice',
|
||||
label: '유저전투장 권장 쌀',
|
||||
step: 100,
|
||||
description: '유저전투장에게 주는 쌀입니다. 이보다 적으면 포상합니다.',
|
||||
hint: `0이면 유저전투장 긴급포상 쌀의 2배를 기준으로 하며, 그 수치는 현재 ${formatNumber(calcPolicyValue('reqHumanWarUrgentRice') * 2)}입니다.`,
|
||||
},
|
||||
{
|
||||
title: '전투/징병 기준',
|
||||
fields: [
|
||||
{
|
||||
key: 'minWarCrew',
|
||||
label: '최소 전투 가능 병력 수',
|
||||
step: 50,
|
||||
description: '이보다 적을 때에는 징병을 시도합니다.',
|
||||
},
|
||||
{
|
||||
key: 'minNPCRecruitCityPopulation',
|
||||
label: 'NPC 최소 징병 가능 인구 수',
|
||||
step: 100,
|
||||
description:
|
||||
'도시의 인구가 이보다 낮으면 NPC는 도시에서 징병하지 않고 후방 워프합니다.',
|
||||
},
|
||||
{
|
||||
key: 'safeRecruitCityPopulationRatio',
|
||||
label: '제자리 징병 허용 인구율(%)',
|
||||
step: 0.5,
|
||||
description:
|
||||
'전쟁 시 후방 발령, 후방 워프의 기준 인구입니다. 이보다 많다면 충분하다고 판단합니다.',
|
||||
percent: true,
|
||||
},
|
||||
{
|
||||
key: 'minNPCWarLeadership',
|
||||
label: 'NPC 전투 참여 통솔 기준',
|
||||
step: 5,
|
||||
description: '이 수치보다 같거나 높으면 NPC전투장으로 분류됩니다.',
|
||||
},
|
||||
],
|
||||
key: 'reqHumanDevelGold',
|
||||
label: '유저내정장 권장 금',
|
||||
step: 100,
|
||||
description: '유저내정장에게 주는 금입니다. 이보다 적으면 포상합니다.',
|
||||
},
|
||||
{
|
||||
title: '상태 기준',
|
||||
fields: [
|
||||
{
|
||||
key: 'properWarTrainAtmos',
|
||||
label: '훈련/사기진작 목표치',
|
||||
step: 5,
|
||||
description: '훈련/사기진작 기준치입니다. 이보다 같거나 높으면 출병합니다.',
|
||||
},
|
||||
{
|
||||
key: 'cureThreshold',
|
||||
label: '요양 기준(%)',
|
||||
step: 5,
|
||||
description: '요양 기준입니다. 이보다 많이 부상을 입으면 요양합니다.',
|
||||
},
|
||||
],
|
||||
key: 'reqHumanDevelRice',
|
||||
label: '유저내정장 권장 쌀',
|
||||
step: 100,
|
||||
description: '유저내정장에게 주는 쌀입니다. 이보다 적으면 포상합니다.',
|
||||
},
|
||||
{
|
||||
key: 'reqNPCWarGold',
|
||||
label: 'NPC전투장 권장 금',
|
||||
step: 100,
|
||||
description: 'NPC전투장에게 주는 금입니다. 이보다 적으면 포상합니다.',
|
||||
hint: `0이면 기본 병종 4회(${formatNumber(statNpcMax * 100)} * 4) 징병비를 기준으로 하며, 그 수치는 현재 ${formatNumber(data.value.zeroPolicy.reqNPCWarGold)}입니다.`,
|
||||
},
|
||||
{
|
||||
key: 'reqNPCWarRice',
|
||||
label: 'NPC전투장 권장 쌀',
|
||||
step: 100,
|
||||
description: 'NPC전투장에게 주는 쌀입니다. 이보다 적으면 포상합니다.',
|
||||
hint: `0이면 기본 병종으로 ${formatNumber(statNpcMax * 100)} * 4명 사살 가능한 쌀을 기준으로 하며, 그 수치는 현재 ${formatNumber(data.value.zeroPolicy.reqNPCWarRice)}입니다.`,
|
||||
},
|
||||
{
|
||||
key: 'reqNPCDevelGold',
|
||||
label: 'NPC내정장 권장 금',
|
||||
step: 100,
|
||||
description: 'NPC내정장에게 주는 금입니다. 이보다 5배 더 많다면 헌납합니다.',
|
||||
hint: `0이면 30턴 내정 가능한 금을 기준으로 하며, 그 수치는 현재 ${formatNumber(data.value.zeroPolicy.reqNPCDevelGold)}입니다.`,
|
||||
},
|
||||
{
|
||||
key: 'reqNPCDevelRice',
|
||||
label: 'NPC내정장 권장 쌀',
|
||||
step: 100,
|
||||
description: 'NPC내정장에게 주는 쌀입니다. 이보다 5배 더 많다면 헌납합니다.',
|
||||
},
|
||||
{
|
||||
key: 'minimumResourceActionAmount',
|
||||
label: '포상/몰수/헌납/삼/팜 최소 단위',
|
||||
step: 100,
|
||||
min: 100,
|
||||
description: '연산결과가 이 단위보다 적다면 수행하지 않습니다.',
|
||||
},
|
||||
{
|
||||
key: 'maximumResourceActionAmount',
|
||||
label: '포상/몰수/헌납/삼/팜 최대 단위',
|
||||
step: 100,
|
||||
min: 100,
|
||||
description: '연산결과가 이 단위보다 크다면, 이 값에 맞춥니다.',
|
||||
},
|
||||
{
|
||||
key: 'minWarCrew',
|
||||
label: '최소 전투 가능 병력 수',
|
||||
step: 50,
|
||||
description: '이보다 적을 때에는 징병을 시도합니다.',
|
||||
},
|
||||
{
|
||||
key: 'minNPCRecruitCityPopulation',
|
||||
label: 'NPC 최소 징병 가능 인구 수',
|
||||
step: 100,
|
||||
description: '도시의 인구가 이보다 낮으면 NPC는 도시에서 징병하지 않고 후방 워프합니다.',
|
||||
hint: 'NPC의 최대 병력수보다 낮게 설정하면 제자리에서 정착장려를 합니다.',
|
||||
},
|
||||
{
|
||||
key: 'safeRecruitCityPopulationRatio',
|
||||
label: '제자리 징병 허용 인구율(%)',
|
||||
step: 0.5,
|
||||
min: 0,
|
||||
max: 100,
|
||||
percent: true,
|
||||
description: '전쟁 시 후방 발령, 후방 워프의 기준 인구입니다. 이보다 많다면 충분하다고 판단합니다.',
|
||||
hint: 'NPC의 최대 병력수보다 낮게 설정하면 제자리에서 정착장려를 합니다.',
|
||||
},
|
||||
{
|
||||
key: 'minNPCWarLeadership',
|
||||
label: 'NPC 전투 참여 통솔 기준',
|
||||
step: 5,
|
||||
description: '이 수치보다 같거나 높으면 NPC전투장으로 분류됩니다.',
|
||||
},
|
||||
{
|
||||
key: 'properWarTrainAtmos',
|
||||
label: '훈련/사기진작 목표치',
|
||||
step: 5,
|
||||
min: 20,
|
||||
max: 100,
|
||||
description: '훈련/사기진작 기준치입니다. 이보다 같거나 높으면 출병합니다.',
|
||||
},
|
||||
{
|
||||
key: 'cureThreshold',
|
||||
label: '요양 기준',
|
||||
step: 5,
|
||||
min: 10,
|
||||
max: 100,
|
||||
description: '요양 기준 %입니다. 이보다 많이 부상을 입으면 요양합니다.',
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const canEdit = computed(() => (data.value?.permissionLevel ?? 0) >= 3);
|
||||
const priorityPanels = computed<PriorityPanel[]>(() => {
|
||||
if (!data.value || !nationPriority.value || !generalPriority.value) return [];
|
||||
return [
|
||||
{
|
||||
key: 'nation',
|
||||
title: 'NPC 사령턴 우선순위',
|
||||
description: ['예턴이 없거나, 지정되어 있더라도 실패하면', '아래 순위에 따라 사령턴을 시도합니다.'],
|
||||
setter: data.value.lastSetters.nation,
|
||||
state: nationPriority.value,
|
||||
},
|
||||
{
|
||||
key: 'general',
|
||||
title: 'NPC 일반턴 우선순위',
|
||||
description: [
|
||||
'순위가 높은 것부터 시도합니다.',
|
||||
'아무것도 실행할 수 없으면 물자조달이나 인재탐색을 합니다.',
|
||||
],
|
||||
setter: data.value.lastSetters.general,
|
||||
state: generalPriority.value,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const resetPolicy = () => {
|
||||
if (!data.value) {
|
||||
return;
|
||||
}
|
||||
if (!window.confirm('초기 설정으로 되돌릴까요?')) {
|
||||
return;
|
||||
}
|
||||
if (!data.value || !window.confirm('초기 설정으로 되돌릴까요?')) return;
|
||||
policyDraft.value = clonePolicy(data.value.defaultNationPolicy);
|
||||
notice.value = '서버 초깃값을 적용했습니다. 설정 버튼을 누르면 반영됩니다.';
|
||||
};
|
||||
|
||||
const rollbackPolicy = () => {
|
||||
if (!lastSavedPolicy.value) {
|
||||
return;
|
||||
}
|
||||
if (!window.confirm('이전 설정으로 되돌릴까요?')) {
|
||||
return;
|
||||
}
|
||||
if (!lastSavedPolicy.value || !window.confirm('이전 설정으로 되돌릴까요?')) return;
|
||||
policyDraft.value = clonePolicy(lastSavedPolicy.value);
|
||||
notice.value = '이전 설정으로 되돌렸습니다.';
|
||||
};
|
||||
|
||||
const submitPolicy = async () => {
|
||||
if (!policyDraft.value) {
|
||||
return;
|
||||
}
|
||||
if (!window.confirm('저장할까요?')) {
|
||||
return;
|
||||
}
|
||||
if (!policyDraft.value || !window.confirm('저장할까요?')) return;
|
||||
error.value = null;
|
||||
notice.value = null;
|
||||
try {
|
||||
await trpc.npc.setNationPolicy.mutate(policyDraft.value);
|
||||
await loadPolicy();
|
||||
} catch (err) {
|
||||
error.value = resolveErrorMessage(err);
|
||||
lastSavedPolicy.value = clonePolicy(policyDraft.value);
|
||||
notice.value = 'NPC 정책이 반영되었습니다.';
|
||||
} catch (caught) {
|
||||
error.value = `설정하지 못했습니다: ${resolveErrorMessage(caught)}`;
|
||||
}
|
||||
};
|
||||
|
||||
const moveItem = (list: string[], from: number, to: number) => {
|
||||
if (to < 0 || to >= list.length) {
|
||||
return;
|
||||
}
|
||||
const [item] = list.splice(from, 1);
|
||||
list.splice(to, 0, item);
|
||||
};
|
||||
|
||||
const insertByOrder = (list: string[], item: string, orderMap: Map<string, number>) => {
|
||||
const targetOrder = orderMap.get(item) ?? Number.MAX_SAFE_INTEGER;
|
||||
const index = list.findIndex((entry) => (orderMap.get(entry) ?? Number.MAX_SAFE_INTEGER) > targetOrder);
|
||||
if (index === -1) {
|
||||
list.push(item);
|
||||
} else {
|
||||
list.splice(index, 0, item);
|
||||
}
|
||||
};
|
||||
|
||||
const togglePriority = (section: PrioritySectionKey, item: string, enable: boolean) => {
|
||||
const target = section === 'nation' ? nationPriority.value : generalPriority.value;
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
if (enable) {
|
||||
const index = target.inactive.indexOf(item);
|
||||
if (index >= 0) {
|
||||
target.inactive.splice(index, 1);
|
||||
const orderMap = new Map(target.available.map((entry, idx) => [entry, idx]));
|
||||
insertByOrder(target.active, item, orderMap);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const index = target.active.indexOf(item);
|
||||
if (index >= 0) {
|
||||
target.active.splice(index, 1);
|
||||
const orderMap = new Map(target.available.map((entry, idx) => [entry, idx]));
|
||||
insertByOrder(target.inactive, item, orderMap);
|
||||
}
|
||||
};
|
||||
|
||||
const reorderPriority = (section: PrioritySectionKey, index: number, direction: number) => {
|
||||
const target = section === 'nation' ? nationPriority.value : generalPriority.value;
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
moveItem(target.active, index, index + direction);
|
||||
};
|
||||
|
||||
const resetPriority = (section: PrioritySectionKey) => {
|
||||
if (!data.value) {
|
||||
return;
|
||||
}
|
||||
if (!window.confirm('초기 설정으로 되돌릴까요?')) {
|
||||
return;
|
||||
}
|
||||
if (!data.value || !window.confirm('초기 설정으로 되돌릴까요?')) return;
|
||||
if (section === 'nation') {
|
||||
nationPriority.value = assignPriorityState(data.value.defaultNationPriority, data.value.availableNationPriorityItems);
|
||||
nationPriority.value = assignPriorityState(
|
||||
data.value.defaultNationPriority,
|
||||
data.value.availableNationPriorityItems
|
||||
);
|
||||
} else {
|
||||
generalPriority.value = assignPriorityState(
|
||||
data.value.defaultGeneralActionPriority,
|
||||
data.value.availableGeneralActionPriorityItems
|
||||
);
|
||||
}
|
||||
notice.value = '서버 초깃값을 적용했습니다. 설정 버튼을 누르면 반영됩니다.';
|
||||
};
|
||||
|
||||
const rollbackPriority = (section: PrioritySectionKey) => {
|
||||
if (!window.confirm('이전 설정으로 되돌릴까요?')) {
|
||||
return;
|
||||
}
|
||||
if (section === 'nation' && data.value) {
|
||||
nationPriority.value = assignPriorityState(lastSavedNationPriority.value, data.value.availableNationPriorityItems);
|
||||
}
|
||||
if (section === 'general' && data.value) {
|
||||
if (!data.value || !window.confirm('이전 설정으로 되돌릴까요?')) return;
|
||||
if (section === 'nation') {
|
||||
nationPriority.value = assignPriorityState(
|
||||
lastSavedNationPriority.value,
|
||||
data.value.availableNationPriorityItems
|
||||
);
|
||||
} else {
|
||||
generalPriority.value = assignPriorityState(
|
||||
lastSavedGeneralPriority.value,
|
||||
data.value.availableGeneralActionPriorityItems
|
||||
);
|
||||
}
|
||||
notice.value = '이전 설정으로 되돌렸습니다.';
|
||||
};
|
||||
|
||||
const submitPriority = async (section: PrioritySectionKey) => {
|
||||
if (!data.value) {
|
||||
return;
|
||||
}
|
||||
if (!window.confirm('저장할까요?')) {
|
||||
return;
|
||||
}
|
||||
const state = section === 'nation' ? nationPriority.value : generalPriority.value;
|
||||
if (!state || !window.confirm('저장할까요?')) return;
|
||||
error.value = null;
|
||||
notice.value = null;
|
||||
try {
|
||||
if (section === 'nation' && nationPriority.value) {
|
||||
await trpc.npc.setNationPriority.mutate(nationPriority.value.active);
|
||||
if (section === 'nation') {
|
||||
await trpc.npc.setNationPriority.mutate(state.active);
|
||||
lastSavedNationPriority.value = [...state.active];
|
||||
} else {
|
||||
await trpc.npc.setGeneralPriority.mutate(state.active);
|
||||
lastSavedGeneralPriority.value = [...state.active];
|
||||
}
|
||||
if (section === 'general' && generalPriority.value) {
|
||||
await trpc.npc.setGeneralPriority.mutate(generalPriority.value.active);
|
||||
}
|
||||
await loadPolicy();
|
||||
} catch (err) {
|
||||
error.value = resolveErrorMessage(err);
|
||||
notice.value = 'NPC 정책이 반영되었습니다.';
|
||||
} catch (caught) {
|
||||
error.value = `설정하지 못했습니다: ${resolveErrorMessage(caught)}`;
|
||||
}
|
||||
};
|
||||
|
||||
const startDrag = (event: DragEvent, section: PrioritySectionKey, bucket: PriorityBucket, index: number) => {
|
||||
dragState.value = { section, bucket, index };
|
||||
event.dataTransfer?.setData('text/plain', `${section}:${bucket}:${index}`);
|
||||
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
|
||||
};
|
||||
|
||||
const dropPriority = (event: DragEvent, section: PrioritySectionKey, bucket: PriorityBucket, targetIndex?: number) => {
|
||||
event.preventDefault();
|
||||
const source = dragState.value;
|
||||
const state = section === 'nation' ? nationPriority.value : generalPriority.value;
|
||||
if (!source || source.section !== section || !state) return;
|
||||
const sourceList = state[source.bucket];
|
||||
const targetList = state[bucket];
|
||||
const [item] = sourceList.splice(source.index, 1);
|
||||
if (!item) return;
|
||||
let index = targetIndex ?? targetList.length;
|
||||
if (sourceList === targetList && source.index < index) index -= 1;
|
||||
targetList.splice(Math.max(0, Math.min(index, targetList.length)), 0, item);
|
||||
dragState.value = null;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="npc-page">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">NPC 정책</h1>
|
||||
<p class="page-subtitle">사령/일반 AI 우선순위 및 자원 기준</p>
|
||||
<main id="npc-policy-page" class="npc-page">
|
||||
<nav class="top-back-bar legacy-bg0">
|
||||
<RouterLink class="back-button" to="/">돌아가기</RouterLink>
|
||||
<strong>NPC 정책</strong>
|
||||
</nav>
|
||||
|
||||
<div v-if="loading && !data" class="page-state legacy-bg0">불러오는 중...</div>
|
||||
<div v-else-if="!data" class="page-state error-state legacy-bg0" role="alert">
|
||||
{{ error ?? 'NPC 정책을 불러오지 못했습니다.' }}
|
||||
<button type="button" @click="loadPolicy">다시 시도</button>
|
||||
</div>
|
||||
|
||||
<section v-else-if="policyDraft" id="container" class="policy-container legacy-bg0">
|
||||
<div class="section_bar legacy-bg1">국가 정책</div>
|
||||
<div class="setter">
|
||||
최근 설정: {{ data.lastSetters.policy.setter ?? '-없음-' }} ({{
|
||||
data.lastSetters.policy.date ?? '설정 기록 없음'
|
||||
}})
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<RouterLink class="ghost" to="/">메인</RouterLink>
|
||||
<button class="ghost" @click="loadPolicy">새로고침</button>
|
||||
|
||||
<div v-if="error" class="feedback error-feedback" role="alert">{{ error }}</div>
|
||||
<div v-if="notice" class="feedback notice-feedback" role="status">{{ notice }}</div>
|
||||
|
||||
<div class="form_list">
|
||||
<div v-for="field in policyFields" :key="field.key" class="policy-field">
|
||||
<div class="field-row">
|
||||
<label :for="`npc-policy-${field.key}`">{{ field.label }}</label>
|
||||
<input
|
||||
v-if="field.percent"
|
||||
:id="`npc-policy-${field.key}`"
|
||||
v-model.number="safeRecruitPercent"
|
||||
type="number"
|
||||
:step="field.step"
|
||||
:min="field.min"
|
||||
:max="field.max"
|
||||
/>
|
||||
<input
|
||||
v-else
|
||||
:id="`npc-policy-${field.key}`"
|
||||
v-model.number="policyDraft[field.key]"
|
||||
type="number"
|
||||
:step="field.step"
|
||||
:min="field.min"
|
||||
:max="field.max"
|
||||
/>
|
||||
</div>
|
||||
<p>{{ field.description }}</p>
|
||||
<p v-if="field.hint">{{ field.hint }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<div class="work-in-progress">
|
||||
전투 부대는 작업중입니다(json양식: {부대번호:[시작도시번호(아국),도착도시번호(적국)],...})
|
||||
<br />후방 징병 부대는 작업중입니다(json양식: [부대번호,...]) <br />내정 부대는 작업중입니다(json양식:
|
||||
[부대번호,...])
|
||||
<input type="hidden" :value="JSON.stringify(policyDraft.CombatForce)" />
|
||||
<input type="hidden" :value="JSON.stringify(policyDraft.SupportForce)" />
|
||||
<input type="hidden" :value="JSON.stringify(policyDraft.DevelopForce)" />
|
||||
</div>
|
||||
|
||||
<section v-if="loading && !data">
|
||||
<PanelCard title="NPC 정책 로딩">
|
||||
<SkeletonLines :lines="6" />
|
||||
</PanelCard>
|
||||
</section>
|
||||
|
||||
<section v-else-if="data && policyDraft" class="npc-layout">
|
||||
<PanelCard title="국가 정책" subtitle="NPC 자원 기준과 전투 판단 기준">
|
||||
<div class="setter">
|
||||
최근 설정: {{ data.lastSetters.policy.setter ?? '-없음-' }} ({{
|
||||
data.lastSetters.policy.date ?? '설정 기록 없음'
|
||||
}})
|
||||
<div class="control_bar">
|
||||
<div class="button-group">
|
||||
<button class="reset_btn" type="button" @click="resetPolicy">초깃값으로</button>
|
||||
<button class="revert_btn" type="button" @click="rollbackPolicy">이전값으로</button>
|
||||
</div>
|
||||
<div v-if="!canEdit" class="readonly-note">권한이 부족하여 읽기 전용으로 표시됩니다.</div>
|
||||
<div v-for="section in policySections" :key="section.title" class="policy-section">
|
||||
<h3 class="section-title">{{ section.title }}</h3>
|
||||
<div class="policy-grid">
|
||||
<div
|
||||
v-for="field in section.fields"
|
||||
:key="field.key"
|
||||
class="policy-field"
|
||||
>
|
||||
<label class="field-label">{{ field.label }}</label>
|
||||
<input
|
||||
v-if="field.percent"
|
||||
v-model.number="safeRecruitPercent"
|
||||
type="number"
|
||||
class="field-input"
|
||||
:step="field.step"
|
||||
min="0"
|
||||
max="100"
|
||||
:disabled="!canEdit"
|
||||
/>
|
||||
<input
|
||||
v-else
|
||||
v-model.number="policyDraft[field.key as PolicyKey]"
|
||||
type="number"
|
||||
class="field-input"
|
||||
:step="field.step"
|
||||
min="0"
|
||||
:disabled="!canEdit"
|
||||
/>
|
||||
<p class="field-desc">{{ field.description }}</p>
|
||||
<p v-if="field.hint" class="field-hint">{{ field.hint }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control-bar">
|
||||
<div class="btn-group">
|
||||
<button class="ghost" :disabled="!canEdit" @click="resetPolicy">초깃값으로</button>
|
||||
<button class="ghost" :disabled="!canEdit" @click="rollbackPolicy">이전값으로</button>
|
||||
</div>
|
||||
<button class="primary" :disabled="!canEdit" @click="submitPolicy">설정</button>
|
||||
</div>
|
||||
</PanelCard>
|
||||
<button class="submit_btn" type="button" @click="submitPolicy">설정</button>
|
||||
</div>
|
||||
|
||||
<div class="priority-grid">
|
||||
<PanelCard title="NPC 사령턴 우선순위" subtitle="예턴 실패 시 우선순위대로 실행">
|
||||
<div class="setter">
|
||||
최근 설정: {{ data.lastSetters.nation.setter ?? '-없음-' }} ({{
|
||||
data.lastSetters.nation.date ?? '설정 기록 없음'
|
||||
}})
|
||||
<div class="priority-sections">
|
||||
<section
|
||||
v-for="panel in priorityPanels"
|
||||
:key="panel.key"
|
||||
:class="['priority-panel', panel.key === 'nation' ? 'half_section_left' : 'half_section_right']"
|
||||
>
|
||||
<div class="section_bar legacy-bg1">{{ panel.title }}</div>
|
||||
<div class="priority-meta">
|
||||
<small>
|
||||
최근 설정: {{ panel.setter.setter ?? '-없음-' }} ({{
|
||||
panel.setter.date ?? '설정 기록 없음'
|
||||
}})
|
||||
</small>
|
||||
</div>
|
||||
<div class="priority-description">
|
||||
<small>{{ panel.description[0] }}<br />{{ panel.description[1] }}</small>
|
||||
</div>
|
||||
<div v-if="!canEdit" class="readonly-note">권한이 부족하여 읽기 전용으로 표시됩니다.</div>
|
||||
<div class="priority-columns">
|
||||
<div class="priority-column">
|
||||
<div class="column-title">비활성</div>
|
||||
<div class="priority-list">
|
||||
<div class="sub_bar legacy-bg2">비활성</div>
|
||||
<div
|
||||
class="priority-list"
|
||||
@dragover.prevent
|
||||
@drop="dropPriority($event, panel.key, 'inactive')"
|
||||
>
|
||||
<div class="inactive-header"><비활성화 항목들></div>
|
||||
<div
|
||||
v-for="item in nationPriority?.inactive ?? []"
|
||||
v-for="(item, index) in panel.state.inactive"
|
||||
:key="item"
|
||||
class="priority-item"
|
||||
draggable="true"
|
||||
@dragstart="startDrag($event, panel.key, 'inactive', index)"
|
||||
@dragover.prevent
|
||||
@drop.stop="dropPriority($event, panel.key, 'inactive', index)"
|
||||
>
|
||||
<span class="priority-name">{{ item }}</span>
|
||||
<span
|
||||
class="priority-help"
|
||||
:data-text="npcPriorityHelp[item] ?? '설명 없음'"
|
||||
>
|
||||
?
|
||||
</span>
|
||||
<button class="ghost" :disabled="!canEdit" @click="togglePriority('nation', item, true)">
|
||||
활성
|
||||
</button>
|
||||
<div class="priority_info">
|
||||
<span class="drag-handle">≡</span>
|
||||
<span>{{ item }}</span>
|
||||
<button
|
||||
class="help-button"
|
||||
type="button"
|
||||
:aria-label="`${item} 설명`"
|
||||
:data-text="npcPriorityHelp[item] ?? '설명 없음'"
|
||||
>
|
||||
?
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="priority-column">
|
||||
<div class="column-title">활성</div>
|
||||
<div class="priority-list">
|
||||
<div class="sub_bar legacy-bg2">활성</div>
|
||||
<div
|
||||
class="priority-list"
|
||||
@dragover.prevent
|
||||
@drop="dropPriority($event, panel.key, 'active')"
|
||||
>
|
||||
<div
|
||||
v-for="(item, idx) in nationPriority?.active ?? []"
|
||||
:key="item"
|
||||
v-for="(item, index) in panel.state.active"
|
||||
:key="`${item}-${index}`"
|
||||
class="priority-item"
|
||||
draggable="true"
|
||||
@dragstart="startDrag($event, panel.key, 'active', index)"
|
||||
@dragover.prevent
|
||||
@drop.stop="dropPriority($event, panel.key, 'active', index)"
|
||||
>
|
||||
<div class="priority-main">
|
||||
<span class="priority-name">{{ item }}</span>
|
||||
<span
|
||||
class="priority-help"
|
||||
<div class="priority_info">
|
||||
<span class="drag-handle">≡</span>
|
||||
<span>{{ item }}</span>
|
||||
<button
|
||||
class="help-button"
|
||||
type="button"
|
||||
:aria-label="`${item} 설명`"
|
||||
:data-text="npcPriorityHelp[item] ?? '설명 없음'"
|
||||
>
|
||||
?
|
||||
</span>
|
||||
</div>
|
||||
<div class="priority-actions">
|
||||
<button class="ghost" :disabled="!canEdit" @click="reorderPriority('nation', idx, -1)">위</button>
|
||||
<button class="ghost" :disabled="!canEdit" @click="reorderPriority('nation', idx, 1)">아래</button>
|
||||
<button class="ghost" :disabled="!canEdit" @click="togglePriority('nation', item, false)">
|
||||
비활성
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control-bar">
|
||||
<div class="btn-group">
|
||||
<button class="ghost" :disabled="!canEdit" @click="resetPriority('nation')">초깃값으로</button>
|
||||
<button class="ghost" :disabled="!canEdit" @click="rollbackPriority('nation')">이전값으로</button>
|
||||
<div class="control_bar priority-control">
|
||||
<div class="button-group">
|
||||
<button class="reset_btn" type="button" @click="resetPriority(panel.key)">
|
||||
초깃값으로
|
||||
</button>
|
||||
<button class="revert_btn" type="button" @click="rollbackPriority(panel.key)">
|
||||
이전값으로
|
||||
</button>
|
||||
</div>
|
||||
<button class="primary" :disabled="!canEdit" @click="submitPriority('nation')">설정</button>
|
||||
<button class="submit_btn" type="button" @click="submitPriority(panel.key)">설정</button>
|
||||
</div>
|
||||
</PanelCard>
|
||||
|
||||
<PanelCard title="NPC 일반턴 우선순위" subtitle="순위가 높은 것부터 시도">
|
||||
<div class="setter">
|
||||
최근 설정: {{ data.lastSetters.general.setter ?? '-없음-' }} ({{
|
||||
data.lastSetters.general.date ?? '설정 기록 없음'
|
||||
}})
|
||||
</div>
|
||||
<div v-if="!canEdit" class="readonly-note">권한이 부족하여 읽기 전용으로 표시됩니다.</div>
|
||||
<div class="priority-columns">
|
||||
<div class="priority-column">
|
||||
<div class="column-title">비활성</div>
|
||||
<div class="priority-list">
|
||||
<div
|
||||
v-for="item in generalPriority?.inactive ?? []"
|
||||
:key="item"
|
||||
class="priority-item"
|
||||
>
|
||||
<span class="priority-name">{{ item }}</span>
|
||||
<span
|
||||
class="priority-help"
|
||||
:data-text="npcPriorityHelp[item] ?? '설명 없음'"
|
||||
>
|
||||
?
|
||||
</span>
|
||||
<button class="ghost" :disabled="!canEdit" @click="togglePriority('general', item, true)">
|
||||
활성
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="priority-column">
|
||||
<div class="column-title">활성</div>
|
||||
<div class="priority-list">
|
||||
<div
|
||||
v-for="(item, idx) in generalPriority?.active ?? []"
|
||||
:key="item"
|
||||
class="priority-item"
|
||||
>
|
||||
<div class="priority-main">
|
||||
<span class="priority-name">{{ item }}</span>
|
||||
<span
|
||||
class="priority-help"
|
||||
:data-text="npcPriorityHelp[item] ?? '설명 없음'"
|
||||
>
|
||||
?
|
||||
</span>
|
||||
</div>
|
||||
<div class="priority-actions">
|
||||
<button class="ghost" :disabled="!canEdit" @click="reorderPriority('general', idx, -1)">위</button>
|
||||
<button class="ghost" :disabled="!canEdit" @click="reorderPriority('general', idx, 1)">아래</button>
|
||||
<button class="ghost" :disabled="!canEdit" @click="togglePriority('general', item, false)">
|
||||
비활성
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control-bar">
|
||||
<div class="btn-group">
|
||||
<button class="ghost" :disabled="!canEdit" @click="resetPriority('general')">초깃값으로</button>
|
||||
<button class="ghost" :disabled="!canEdit" @click="rollbackPriority('general')">이전값으로</button>
|
||||
</div>
|
||||
<button class="primary" :disabled="!canEdit" @click="submitPriority('general')">설정</button>
|
||||
</div>
|
||||
</PanelCard>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:global(html),
|
||||
:global(body),
|
||||
:global(#app) {
|
||||
min-width: 500px;
|
||||
margin: 0;
|
||||
background: #000;
|
||||
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 21px;
|
||||
}
|
||||
|
||||
:global(body:has(#npc-policy-page)) {
|
||||
min-width: 500px;
|
||||
line-height: 21px;
|
||||
}
|
||||
|
||||
.npc-page {
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
color: #fff;
|
||||
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 21px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid rgba(201, 164, 90, 0.4);
|
||||
padding-bottom: 12px;
|
||||
.legacy-bg0 {
|
||||
background-image: url('/image/game/back_walnut.jpg');
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 600;
|
||||
.legacy-bg1 {
|
||||
background-image: url('/image/game/back_green.jpg');
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: rgba(232, 221, 196, 0.7);
|
||||
.legacy-bg2 {
|
||||
background-image: url('/image/game/back_blue.jpg');
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
.top-back-bar {
|
||||
position: relative;
|
||||
height: 32px;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.ghost {
|
||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
||||
padding: 6px 12px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
background: rgba(16, 16, 16, 0.6);
|
||||
color: inherit;
|
||||
.top-back-bar strong {
|
||||
font-size: 24px;
|
||||
line-height: 32px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.primary {
|
||||
border: 1px solid rgba(201, 164, 90, 0.6);
|
||||
padding: 6px 12px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
background: rgba(201, 164, 90, 0.2);
|
||||
color: inherit;
|
||||
.back-button {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 88px;
|
||||
color: #fff;
|
||||
background: #087f45;
|
||||
border: 1px solid #0a9960;
|
||||
border-radius: 0 0 4px;
|
||||
font-weight: 700;
|
||||
line-height: 30px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #f5b7b1;
|
||||
font-size: 0.85rem;
|
||||
.back-button:hover,
|
||||
.back-button:focus-visible {
|
||||
background: #0a9960;
|
||||
outline: 2px solid #fff;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.npc-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
.policy-container,
|
||||
.page-state {
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
border: 1px solid #888;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.setter {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(232, 221, 196, 0.6);
|
||||
margin-bottom: 8px;
|
||||
.page-state {
|
||||
padding: 16px;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.readonly-note {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(245, 208, 138, 0.8);
|
||||
margin-bottom: 8px;
|
||||
.page-state button {
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.policy-section {
|
||||
margin-top: 12px;
|
||||
.section_bar {
|
||||
min-height: 23px;
|
||||
border: 0.5px solid #aaa;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 8px;
|
||||
color: rgba(232, 221, 196, 0.9);
|
||||
.setter,
|
||||
.priority-meta {
|
||||
min-height: 21px;
|
||||
padding: 0 12px;
|
||||
color: #8e8e8e;
|
||||
font-size: 12.25px;
|
||||
line-height: 18.375px;
|
||||
text-align: right;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.policy-grid {
|
||||
.feedback {
|
||||
margin: 4px 12px;
|
||||
padding: 5px 10px;
|
||||
border: 1px solid;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.error-feedback,
|
||||
.error-state {
|
||||
color: #ffd4d4;
|
||||
border-color: #a94442;
|
||||
background-color: rgba(120, 20, 20, 0.75);
|
||||
}
|
||||
|
||||
.notice-feedback {
|
||||
color: #d9ffd9;
|
||||
border-color: #3c763d;
|
||||
background-color: rgba(20, 90, 20, 0.7);
|
||||
}
|
||||
|
||||
.form_list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
margin: 8px;
|
||||
}
|
||||
|
||||
.policy-field {
|
||||
border: 1px solid rgba(201, 164, 90, 0.2);
|
||||
background: rgba(16, 16, 16, 0.5);
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
padding: 0 10.5px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.field-input {
|
||||
background: rgba(12, 12, 12, 0.8);
|
||||
border: 1px solid rgba(201, 164, 90, 0.3);
|
||||
color: inherit;
|
||||
padding: 6px 8px;
|
||||
font-size: 0.8rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.field-desc {
|
||||
font-size: 0.7rem;
|
||||
color: rgba(232, 221, 196, 0.6);
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
font-size: 0.7rem;
|
||||
color: rgba(232, 221, 196, 0.45);
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.control-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.priority-grid {
|
||||
.field-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 16px;
|
||||
grid-template-columns: minmax(0, 1fr) 224px;
|
||||
align-items: center;
|
||||
min-height: 34px;
|
||||
}
|
||||
|
||||
.field-row label {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.field-row input {
|
||||
width: 224px;
|
||||
height: 34px;
|
||||
padding: 5.25px 10.5px;
|
||||
color: #303030;
|
||||
background: #ddd;
|
||||
border: 1px solid #000;
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.field-row input:focus {
|
||||
border-color: #66afe9;
|
||||
outline: 2px solid rgba(102, 175, 233, 0.7);
|
||||
}
|
||||
|
||||
.policy-field p {
|
||||
min-height: 18.375px;
|
||||
margin: 0;
|
||||
color: #888;
|
||||
font-size: 12.25px;
|
||||
line-height: 18.375px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.work-in-progress {
|
||||
margin: 0 11px 15px;
|
||||
padding: 14px;
|
||||
color: #fff;
|
||||
background: #444;
|
||||
border: 1px solid #444;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.control_bar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 0 10.6667px 10.6667px;
|
||||
min-height: 56.8125px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.control_bar button {
|
||||
width: 150px;
|
||||
height: 35.5px;
|
||||
margin-top: 10.6667px;
|
||||
padding: 5.25px 10.5px;
|
||||
color: #fff;
|
||||
border: 1px solid;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reset_btn {
|
||||
background: #303030;
|
||||
border-color: #2b2b2b !important;
|
||||
border-radius: 4px 0 0 4px;
|
||||
}
|
||||
|
||||
.revert_btn {
|
||||
background: #444;
|
||||
border-color: #3d3d3d !important;
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
|
||||
.submit_btn {
|
||||
margin-left: 14px;
|
||||
background: #375a7f;
|
||||
border-color: #325172 !important;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.control_bar button:hover {
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
|
||||
.control_bar button:focus-visible,
|
||||
.help-button:focus-visible {
|
||||
outline: 2px solid #fff;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.priority-sections {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.priority-panel {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.half_section_left {
|
||||
border-right: 0.5px solid #aaa;
|
||||
}
|
||||
|
||||
.priority-meta {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.priority-description {
|
||||
clear: both;
|
||||
min-height: 42px;
|
||||
padding: 0 8px;
|
||||
color: #888;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.priority-description small {
|
||||
font-size: 12.25px;
|
||||
line-height: 18.375px;
|
||||
}
|
||||
|
||||
.priority-columns {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.priority-column {
|
||||
border: 1px solid rgba(201, 164, 90, 0.2);
|
||||
padding: 8px;
|
||||
background: rgba(12, 12, 12, 0.5);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.column-title {
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 6px;
|
||||
color: rgba(232, 221, 196, 0.7);
|
||||
.sub_bar {
|
||||
height: 22px;
|
||||
margin: 0 5px;
|
||||
border: 0.5px solid #aaa;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.priority-list {
|
||||
display: flex;
|
||||
min-height: 37px;
|
||||
margin: 0 10px;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.inactive-header,
|
||||
.priority-item {
|
||||
height: 37px;
|
||||
padding: 7px 14px;
|
||||
border: 1px solid #444;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.inactive-header {
|
||||
color: #1d1d1d;
|
||||
background: #d6d6d6;
|
||||
}
|
||||
|
||||
.priority-item {
|
||||
border: 1px solid rgba(201, 164, 90, 0.2);
|
||||
padding: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
background: rgba(16, 16, 16, 0.6);
|
||||
color: #fff;
|
||||
background: #303030;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.priority-main {
|
||||
display: flex;
|
||||
.priority-item:active {
|
||||
cursor: grabbing;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.priority_info {
|
||||
display: grid;
|
||||
height: 21px;
|
||||
grid-template-columns: 24px minmax(0, 1fr) 24px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.priority-name {
|
||||
font-size: 0.78rem;
|
||||
.drag-handle {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.priority-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.priority-help {
|
||||
.help-button {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: 0.7rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
||||
cursor: default;
|
||||
width: 24px;
|
||||
height: 22.375px;
|
||||
padding: 0 3.5px;
|
||||
color: #fff;
|
||||
background: #444;
|
||||
border: 1px solid #3d3d3d;
|
||||
border-radius: 3px;
|
||||
font-size: 12.25px;
|
||||
line-height: 18.375px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.priority-help::after {
|
||||
content: attr(data-text);
|
||||
.help-button::after {
|
||||
position: absolute;
|
||||
bottom: 125%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(16, 16, 16, 0.9);
|
||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
||||
padding: 8px;
|
||||
font-size: 0.7rem;
|
||||
width: 220px;
|
||||
right: 0;
|
||||
bottom: calc(100% + 5px);
|
||||
z-index: 10;
|
||||
width: 300px;
|
||||
padding: 7px;
|
||||
color: #fff;
|
||||
background: #111;
|
||||
border: 1px solid #777;
|
||||
border-radius: 4px;
|
||||
content: attr(data-text);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
text-align: left;
|
||||
white-space: pre-line;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease;
|
||||
z-index: 10;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.priority-help:hover::after {
|
||||
.help-button:hover::after,
|
||||
.help-button:focus-visible::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.npc-page {
|
||||
padding: 16px;
|
||||
.priority-control {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 991px) {
|
||||
.form_list,
|
||||
.priority-sections {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.half_section_left {
|
||||
border-right: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
현재 ref MariaDB와 core memory를 잇는 공통 runner, 성공 경로 55개,
|
||||
실행 중 확률 실패 9개, full constraint fallback 12개와 모략 확률 clamp
|
||||
8개, 모략 결과값 경계 5개, 부상 경계 3개, alternative 5개와 pre-required
|
||||
turn 중간 경계 6개가 구현됐다.
|
||||
turn 중간 경계 6개, post-required cooldown 경계 3개가 구현됐다.
|
||||
실패 9개는 내정 critical
|
||||
`주민선정/정착장려/상업투자/기술연구/물자조달`과 모략
|
||||
`화계/선동/파괴/탈취`이며 RNG 전체 trace, semantic state delta와 실패
|
||||
@@ -27,9 +27,17 @@ state delta를 비교한다. 결과값 5개는 화계 농업·상업, 선동 치
|
||||
비교한다. alternative 5개는 해산·랜덤임관·무작위건국·출병의 모든 대체
|
||||
분기에서 최초 명령 RNG의 연속 소비와 최종 상태를 비교한다. pre-required
|
||||
turn 6개는 전투태세 1/2/3턴, 내정·전투 특기 초기화 1턴, 은퇴 1턴의
|
||||
`last_turn`과 진행 로그, RNG 무소비를 비교한다. 나머지 명령별 제약
|
||||
실패·값 경계와 전체 core PostgreSQL 재조회가 완료 기준을 통과하기 전까지
|
||||
55개 명령 전체의 동적 호환 상태를 `확인`으로 올리지 않는다.
|
||||
`last_turn`과 진행 로그, RNG 무소비를 비교한다. cooldown 3개는 특기
|
||||
초기화 완료 직후 `current + 60 - preReq`, 1턴 전 차단, 경계 월 허용을
|
||||
ref `next_execute` KV와 core general meta의 공통 projection으로 비교한다.
|
||||
존재하지 않는 대상 경계 4개는 증여·등용의 장수 ID와 첩보·이동의 도시 ID를
|
||||
고정해 원 명령 미완료, 휴식 fallback, RNG 무소비와 semantic delta를
|
||||
비교한다.
|
||||
필수 인자 객체 자체를 생략한 요청은 ref runner가 제한 시간 안에 종료되지
|
||||
않아 동적 호환 판정에서 제외한다.
|
||||
나머지 명령별 제약 실패·값 경계와 전체 core PostgreSQL 재조회가 완료
|
||||
기준을 통과하기 전까지 55개 명령 전체의 동적 호환 상태를 `확인`으로
|
||||
올리지 않는다.
|
||||
|
||||
## 결정 요약
|
||||
|
||||
|
||||
@@ -206,6 +206,16 @@ instance and current consumption position. Six pre-required-turn cases cover
|
||||
battle-preparation terms 1 through 3 plus the first intermediate turn of both
|
||||
trait resets and retirement. They compare the exact intermediate `lastTurn`,
|
||||
progress log, zero command-RNG consumption and semantic state delta.
|
||||
Three post-required cooldown cases project the legacy `next_execute` KV and
|
||||
core general meta into the same world-level cooldown record. They cover the
|
||||
stored `current + 60 - preReq` value, rejection one turn before availability,
|
||||
and successful execution exactly at the boundary.
|
||||
Four missing-target cases cover nonexistent general IDs for gift and
|
||||
employment plus nonexistent city IDs for spying and movement. Both engines
|
||||
reject the requested command, execute rest without command RNG, and produce
|
||||
the same semantic state delta.
|
||||
Requests that omit the required argument object remain unverified because the
|
||||
reference runner did not terminate within the bounded comparison run.
|
||||
This is not yet a claim that every command-specific
|
||||
constraint, clamp and persistence boundary has been dynamically
|
||||
compared.
|
||||
|
||||
@@ -43,22 +43,25 @@ storage, route guards, and image loading.
|
||||
|
||||
## Enforced contracts
|
||||
|
||||
| Screen | Ref entry point | Current automated contract |
|
||||
| -------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| gateway login/status | `index.php` | 450/700px desktop widths, mobile collapse, Pretendard title, real login mutation/session storage, actual seasonal map asset |
|
||||
| gateway account | `i_entrance/user_info.php` | 550px × minimum 575px panel, 14px Pretendard, three legacy textures, success and API-error password flows |
|
||||
| gateway OAuth join | `oauth_kakao/join.php` | 700px centered registration card, Kakao exchange/register success, retained-input API error, hover/focus |
|
||||
| game login hand-off | unauthenticated `hwe/index.php` redirect | `/che/login` delegates to `/gateway/` |
|
||||
| troop | `hwe/v_troop.php` | existing `app/game-frontend/e2e/troop.spec.ts` desktop/mobile geometry and interaction suite |
|
||||
| hall of fame | `hwe/a_hallOfFame.php` | 500/1000px container, 100px ranking cells, 64px natural image, walnut/green textures, Pretendard, close-button focus |
|
||||
| yearbook | `hwe/v_history.php` | 1000px 700+300 desktop grid, 500px stacked grid, month navigation, legacy textures, success and API-error flows |
|
||||
| nation betting | `hwe/v_nationBetting.php` | 1000px/6-column desktop and 500px/3-column mobile grids, picked card style, payout table, success and retained-form error |
|
||||
| public NPC list | `hwe/a_npcList.php` | 1000px 12-column table with Chromium-expanded legacy widths, NPC color, eight sorts, retained table/sort after API error |
|
||||
| survey | `hwe/v_vote.php`, `hwe/ts/PageVote.vue` | 1000/500px fixed container, blue title/green table textures, list/detail/results/comments, selection/focus/hover, submit and retained-selection API error |
|
||||
| nation personnel | `hwe/b_myBossInfo.php` | fixed 1000px document at both viewports, chief icon columns, officer/permission/city/kick controls, role redaction |
|
||||
| nation finance | `hwe/v_nationStratFinan.php` | 1000/500px at the legacy 940px breakpoint, exact diplomacy grid, policy controls, role gating and failed-mutation rollback |
|
||||
| tournament | `hwe/b_tournament.php` | fixed 2000px canvas, 16×125px bracket, eight 250px group tables, walnut texture, 1024px overflow, hover/focus |
|
||||
| tournament betting | `hwe/b_betting.php` | fixed 1120px canvas, 16×70px candidates, four 280px rank tables, exact title/button geometry, retained selection on error |
|
||||
| Screen | Ref entry point | Current automated contract |
|
||||
| -------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| gateway login/status | `index.php` | 450/700px desktop widths, mobile collapse, Pretendard title, real login mutation/session storage, actual seasonal map asset |
|
||||
| gateway account | `i_entrance/user_info.php` | 550px × minimum 575px panel, 14px Pretendard, three legacy textures, success and API-error password flows |
|
||||
| gateway OAuth join | `oauth_kakao/join.php` | 700px centered registration card, Kakao exchange/register success, retained-input API error, hover/focus |
|
||||
| game login hand-off | unauthenticated `hwe/index.php` redirect | `/che/login` delegates to `/gateway/` |
|
||||
| troop | `hwe/v_troop.php` | existing `app/game-frontend/e2e/troop.spec.ts` desktop/mobile geometry and interaction suite |
|
||||
| current city | `hwe/b_currentCity.php` | ref-specific 16px Times New Roman, 1000px summary/1024px general tables, 400px selector, 64px icon, nation title color, force summary, actor/spy/admin redaction, and map-click query navigation |
|
||||
| hall of fame | `hwe/a_hallOfFame.php` | 500/1000px container, 100px ranking cells, 64px natural image, walnut/green textures, Pretendard, close-button focus |
|
||||
| yearbook | `hwe/v_history.php` | 1000px 700+300 desktop grid, 500px stacked grid, month navigation, legacy textures, success and API-error flows |
|
||||
| inheritance | `hwe/v_inheritPoint.php` | 1000px 3-column desktop and 500px stacked layout, walnut/green textures, Pretendard 14px, scenario unique selector, buff purchase success and retained-input API error |
|
||||
| nation betting | `hwe/v_nationBetting.php` | 1000px/6-column desktop and 500px/3-column mobile grids, picked card style, payout table, success and retained-form error |
|
||||
| public NPC list | `hwe/a_npcList.php` | 1000px 12-column table with Chromium-expanded legacy widths, NPC color, eight sorts, retained table/sort after API error |
|
||||
| survey | `hwe/v_vote.php`, `hwe/ts/PageVote.vue` | 1000/500px fixed container, blue title/green table textures, list/detail/results/comments, selection/focus/hover, submit and retained-selection API error |
|
||||
| nation personnel | `hwe/b_myBossInfo.php` | fixed 1000px document at both viewports, chief icon columns, officer/permission/city/kick controls, role redaction |
|
||||
| nation finance | `hwe/v_nationStratFinan.php` | 1000/500px at the legacy 940px breakpoint, exact diplomacy grid, policy controls, role gating and failed-mutation rollback |
|
||||
| NPC policy | `hwe/v_NPCControl.php` | 1000/500px form and priority-list geometry, walnut/green textures, dynamic zero hints, drag/focus/tooltip, successful save and permission failures |
|
||||
| tournament | `hwe/b_tournament.php` | fixed 2000px canvas, 16×125px bracket, eight 250px group tables, walnut texture, 1024px overflow, hover/focus |
|
||||
| tournament betting | `hwe/b_betting.php` | fixed 1120px canvas, 16×70px candidates, four 280px rank tables, exact title/button geometry, retained selection on error |
|
||||
|
||||
The global game baseline is black, white, Pretendard 14px. Legacy texture
|
||||
helpers intentionally follow `common.orig.css`: `bg0` is walnut, `bg1` is
|
||||
@@ -96,6 +99,30 @@ reference collection, `tools/frontend-legacy-parity/reference-nation-offices.mjs
|
||||
records desktop/500px computed DOM and screenshots from the PHP service without
|
||||
changing its product code.
|
||||
|
||||
The NPC policy suite and its reference collector can be run independently:
|
||||
|
||||
```sh
|
||||
PLAYWRIGHT_FRONTEND_PORT=15126 \
|
||||
pnpm --filter @sammo-ts/game-frontend test:e2e:npc-policy
|
||||
|
||||
REF_PARITY_USER=refuser1 \
|
||||
REF_PARITY_PASSWORD_FILE=/path/to/password-file \
|
||||
REF_PARITY_BASE_URL=http://127.0.0.1:3400/sam/ \
|
||||
node tools/frontend-legacy-parity/reference-npc-policy.mjs
|
||||
```
|
||||
|
||||
The collector writes desktop and 500px screenshots plus computed DOM JSON only
|
||||
when `REF_PARITY_ARTIFACT_DIR` is set. It requires an existing reference general
|
||||
owned by the supplied account and never accepts a password on the command line.
|
||||
|
||||
The current-city reference can be collected independently with
|
||||
`tools/frontend-legacy-parity/reference-current-city.mjs`. It requires
|
||||
`REF_PARITY_PASSWORD_FILE` and accepts `REF_PARITY_URL`, `REF_PARITY_USER`, and
|
||||
`REF_PARITY_ARTIFACT_DIR`; the password is read from the ignored secret file
|
||||
and is never written to the artifact. The matching core fixture is
|
||||
`app/game-frontend/e2e/inGameInfo.spec.ts`, which writes its computed DOM and
|
||||
screenshot only when `CITY_PARITY_ARTIFACT_DIR` is set.
|
||||
|
||||
For a review run that also writes full-page screenshots, create an ignored
|
||||
artifact directory and set `FRONTEND_PARITY_ARTIFACT_DIR` before invoking the
|
||||
suite. The ordinary CI run does not write screenshots after successful tests.
|
||||
|
||||
@@ -7,8 +7,8 @@ export type InheritBuffType =
|
||||
| 'warAvoidRatio'
|
||||
| 'warCriticalRatio'
|
||||
| 'warMagicTrialProb'
|
||||
| 'success'
|
||||
| 'fail'
|
||||
| 'domesticSuccessProb'
|
||||
| 'domesticFailProb'
|
||||
| 'warAvoidRatioOppose'
|
||||
| 'warCriticalRatioOppose'
|
||||
| 'warMagicTrialProbOppose';
|
||||
@@ -25,7 +25,8 @@ const DOMESTIC_TARGETS = new Set<TriggerDomesticActionType>([
|
||||
]);
|
||||
|
||||
const readBuffLevel = (buff: Record<string, unknown>, key: InheritBuffType): number => {
|
||||
const raw = buff[key];
|
||||
const compatibilityKey = key === 'domesticSuccessProb' ? 'success' : key === 'domesticFailProb' ? 'fail' : null;
|
||||
const raw = buff[key] ?? (compatibilityKey ? buff[compatibilityKey] : undefined);
|
||||
if (typeof raw !== 'number' || !Number.isFinite(raw)) {
|
||||
return 0;
|
||||
}
|
||||
@@ -40,7 +41,9 @@ const parseInheritBuff = (value: unknown): Record<string, unknown> => {
|
||||
return asRecord(value);
|
||||
};
|
||||
|
||||
const resolveBuffRecord = (context: { general: { meta: Record<string, unknown>; triggerState: { meta: Record<string, unknown> } } }): Record<string, unknown> => {
|
||||
const resolveBuffRecord = (context: {
|
||||
general: { meta: Record<string, unknown>; triggerState: { meta: Record<string, unknown> } };
|
||||
}): Record<string, unknown> => {
|
||||
const fromTrigger = parseInheritBuff(context.general.triggerState.meta.inheritBuff);
|
||||
if (Object.keys(fromTrigger).length > 0) {
|
||||
return fromTrigger;
|
||||
@@ -58,11 +61,11 @@ const applyDomesticBuff = (
|
||||
return value;
|
||||
}
|
||||
if (varType === 'success') {
|
||||
const level = readBuffLevel(buff, 'success');
|
||||
const level = readBuffLevel(buff, 'domesticSuccessProb');
|
||||
return value + level * 0.01;
|
||||
}
|
||||
if (varType === 'fail') {
|
||||
const level = readBuffLevel(buff, 'fail');
|
||||
const level = readBuffLevel(buff, 'domesticFailProb');
|
||||
return value - level * 0.01;
|
||||
}
|
||||
return value;
|
||||
@@ -84,11 +87,7 @@ const applyWarBuff = (buff: Record<string, unknown>, statName: WarStatName, valu
|
||||
return value;
|
||||
};
|
||||
|
||||
const applyOpposeWarBuff = (
|
||||
buff: Record<string, unknown>,
|
||||
statName: WarStatName,
|
||||
value: number | [number, number]
|
||||
) => {
|
||||
const applyOpposeWarBuff = (buff: Record<string, unknown>, statName: WarStatName, value: number | [number, number]) => {
|
||||
if (typeof value !== 'number') {
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { General } from '../src/domain/entities.js';
|
||||
import { createInheritBuffModules } from '../src/inheritance/inheritBuff.js';
|
||||
import { GeneralActionPipeline } from '../src/triggers/general-action.js';
|
||||
|
||||
const buildGeneral = (inheritBuff: Record<string, number>): General => ({
|
||||
id: 1,
|
||||
name: 'Tester',
|
||||
nationId: 1,
|
||||
cityId: 1,
|
||||
troopId: 0,
|
||||
stats: { leadership: 70, strength: 60, intelligence: 50 },
|
||||
experience: 0,
|
||||
dedication: 0,
|
||||
officerLevel: 0,
|
||||
role: {
|
||||
personality: null,
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
items: { horse: null, weapon: null, book: null, item: null },
|
||||
},
|
||||
injury: 0,
|
||||
gold: 0,
|
||||
rice: 0,
|
||||
crew: 0,
|
||||
crewTypeId: 0,
|
||||
train: 0,
|
||||
atmos: 0,
|
||||
age: 20,
|
||||
npcState: 0,
|
||||
triggerState: {
|
||||
flags: {},
|
||||
counters: {},
|
||||
modifiers: {},
|
||||
meta: {},
|
||||
},
|
||||
meta: { killturn: 24, inheritBuff: JSON.stringify(inheritBuff) },
|
||||
});
|
||||
|
||||
describe('inheritance buff legacy keys', () => {
|
||||
it('applies the canonical legacy domestic buff names', () => {
|
||||
const pipeline = new GeneralActionPipeline([createInheritBuffModules().general]);
|
||||
const context = {
|
||||
general: buildGeneral({
|
||||
domesticSuccessProb: 3,
|
||||
domesticFailProb: 2,
|
||||
}),
|
||||
};
|
||||
|
||||
expect(pipeline.onCalcDomestic(context, '농업', 'success', 0.5)).toBeCloseTo(0.53);
|
||||
expect(pipeline.onCalcDomestic(context, '상업', 'fail', 0.2)).toBeCloseTo(0.18);
|
||||
});
|
||||
|
||||
it('continues to read the earlier core success and fail aliases', () => {
|
||||
const pipeline = new GeneralActionPipeline([createInheritBuffModules().general]);
|
||||
const context = {
|
||||
general: buildGeneral({
|
||||
success: 2,
|
||||
fail: 1,
|
||||
}),
|
||||
};
|
||||
|
||||
expect(pipeline.onCalcDomestic(context, '치안', 'success', 0.5)).toBeCloseTo(0.52);
|
||||
expect(pipeline.onCalcDomestic(context, '성벽', 'fail', 0.2)).toBeCloseTo(0.19);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { dirname, extname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const imageRoot = resolve(repositoryRoot, '../../image');
|
||||
const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR;
|
||||
const gameUrl = `http://127.0.0.1:${process.env.FRONTEND_PARITY_GAME_PORT ?? '15102'}/che/inherit`;
|
||||
|
||||
const response = (data: unknown) => ({ result: { data } });
|
||||
|
||||
const operations = (route: Route): string[] => {
|
||||
const pathname = new URL(route.request().url()).pathname;
|
||||
return decodeURIComponent(pathname.slice(pathname.lastIndexOf('/trpc/') + 6)).split(',');
|
||||
};
|
||||
|
||||
const installImages = async (page: Page): Promise<void> => {
|
||||
await page.route('**/image/**', async (route) => {
|
||||
const relative = decodeURIComponent(new URL(route.request().url()).pathname).replace(/^\/image\//, '');
|
||||
for (const candidate of [
|
||||
resolve(imageRoot, relative),
|
||||
resolve(imageRoot, 'game', relative),
|
||||
resolve(imageRoot, 'icons', '22.jpg'),
|
||||
]) {
|
||||
try {
|
||||
const body = await readFile(candidate);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: extname(candidate).toLowerCase() === '.png' ? 'image/png' : 'image/jpeg',
|
||||
body,
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
// 다음 공개 image root 후보를 확인한다.
|
||||
}
|
||||
}
|
||||
await route.abort('failed');
|
||||
});
|
||||
};
|
||||
|
||||
const statusFixture = {
|
||||
items: {
|
||||
previous: 12_000,
|
||||
lived_month: 240,
|
||||
max_domestic_critical: 80,
|
||||
active_action: 35,
|
||||
combat: 150,
|
||||
sabotage: 60,
|
||||
dex: 42,
|
||||
unifier: 0,
|
||||
tournament: 30,
|
||||
betting: 20,
|
||||
max_belong: 8,
|
||||
},
|
||||
totalPoint: 12_665,
|
||||
inheritConst: {
|
||||
minMonthToAllowInheritItem: 4,
|
||||
inheritBornSpecialPoint: 6000,
|
||||
inheritBornTurntimePoint: 2500,
|
||||
inheritBornCityPoint: 1000,
|
||||
inheritBornStatPoint: 1000,
|
||||
inheritItemUniqueMinPoint: 5000,
|
||||
inheritItemRandomPoint: 3000,
|
||||
inheritBuffPoints: [0, 200, 600, 1200, 2000, 3000],
|
||||
inheritSpecificSpecialPoint: 4000,
|
||||
inheritResetAttrPointBase: [1000, 1000, 2000, 3000],
|
||||
inheritCheckOwnerPoint: 1000,
|
||||
},
|
||||
buffLevels: {
|
||||
warAvoidRatio: 0,
|
||||
warCriticalRatio: 1,
|
||||
warMagicTrialProb: 0,
|
||||
domesticSuccessProb: 0,
|
||||
domesticFailProb: 0,
|
||||
warAvoidRatioOppose: 0,
|
||||
warCriticalRatioOppose: 0,
|
||||
warMagicTrialProbOppose: 0,
|
||||
},
|
||||
resetCosts: { resetSpecialWar: 1000, resetTurnTime: 1000 },
|
||||
resetLevels: { resetSpecialWar: 0, resetTurnTime: 0 },
|
||||
availableSpecialWar: [{ key: 'che_선봉', name: '선봉', info: '공격에 유리합니다.' }],
|
||||
availableUnique: [
|
||||
{
|
||||
key: 'che_무기_12_칠성검',
|
||||
name: '칠성검(+12)',
|
||||
rawName: '칠성검',
|
||||
info: '무력을 올려주는 유니크 무기입니다.',
|
||||
},
|
||||
],
|
||||
availableTargetGenerals: [{ id: 8, name: '조조' }],
|
||||
turnTimeZones: ['00:00'],
|
||||
isUnited: false,
|
||||
currentSpecialWar: 'che_선봉',
|
||||
currentStat: { leadership: 70, strength: 45, intel: 85 },
|
||||
};
|
||||
|
||||
const installFixture = async (page: Page, options: { failBuff?: boolean } = {}) => {
|
||||
let buffMutationCount = 0;
|
||||
await installImages(page);
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem('sammo-game-token', 'ga_inherit-visual-token');
|
||||
window.localStorage.setItem('sammo-game-profile', 'che');
|
||||
});
|
||||
await page.route('**/che/api/trpc/**', async (route) => {
|
||||
const names = operations(route);
|
||||
if (options.failBuff && names.includes('inherit.buyHiddenBuff')) {
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: { message: '의도한 유산 구입 오류' } }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const result = names.map((name) => {
|
||||
if (name === 'inherit.getStatus') return response(statusFixture);
|
||||
if (name === 'lobby.info') {
|
||||
return response({
|
||||
profile: { id: 'che', scenario: 'default', name: '체섭' },
|
||||
world: { year: 200, month: 4 },
|
||||
myGeneral: { id: 7, name: '유비', nationId: 1 },
|
||||
});
|
||||
}
|
||||
if (name === 'inherit.getLogs') {
|
||||
return response([
|
||||
{
|
||||
id: 2,
|
||||
year: 200,
|
||||
month: 4,
|
||||
text: '1000 포인트로 장수 소유자 확인',
|
||||
createdAt: '2026-07-26T00:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (name === 'join.getConfig') {
|
||||
return response({ rules: { stat: { total: 200, min: 10, max: 100 } } });
|
||||
}
|
||||
if (name === 'inherit.buyHiddenBuff') {
|
||||
buffMutationCount += 1;
|
||||
return response({ ok: true, remainPoint: 11_800 });
|
||||
}
|
||||
throw new Error(`Unhandled inheritance fixture operation: ${name}`);
|
||||
});
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(result),
|
||||
});
|
||||
});
|
||||
return { buffMutationCount: () => buffMutationCount };
|
||||
};
|
||||
|
||||
test.describe('inheritance management legacy parity', () => {
|
||||
test('matches the ref 1000px grid and computed styles on desktop and mobile', async ({ page }) => {
|
||||
await installFixture(page);
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await page.goto(gameUrl);
|
||||
await expect(page.locator('#container')).toBeVisible();
|
||||
await expect(page.locator('#specific-unique')).toHaveValue('che_무기_12_칠성검');
|
||||
|
||||
const desktop = await page.evaluate(() => {
|
||||
const rect = (selector: string) => {
|
||||
const box = document.querySelector<HTMLElement>(selector)!.getBoundingClientRect();
|
||||
return { x: box.x, width: box.width };
|
||||
};
|
||||
const container = getComputedStyle(document.querySelector<HTMLElement>('#container')!);
|
||||
const title = getComputedStyle(document.querySelector<HTMLElement>('.section-title')!);
|
||||
const button = getComputedStyle(document.querySelector<HTMLElement>('.buy-button')!);
|
||||
return {
|
||||
container: rect('#container'),
|
||||
firstPoint: rect('#inherit_sum'),
|
||||
fontFamily: container.fontFamily,
|
||||
fontSize: container.fontSize,
|
||||
backgroundImage: container.backgroundImage,
|
||||
titleBackgroundImage: title.backgroundImage,
|
||||
buttonBackground: button.backgroundColor,
|
||||
};
|
||||
});
|
||||
|
||||
expect(desktop.container.width).toBe(1000);
|
||||
expect(desktop.container.x).toBe(140);
|
||||
expect(desktop.firstPoint.width).toBeCloseTo(327.3, 0);
|
||||
expect(desktop.fontFamily).toContain('Pretendard');
|
||||
expect(desktop.fontSize).toBe('14px');
|
||||
expect(desktop.backgroundImage).toContain('back_walnut.jpg');
|
||||
expect(desktop.titleBackgroundImage).toContain('back_green.jpg');
|
||||
|
||||
const buyButton = page.locator('.buy-button').first();
|
||||
const beforeHover = await buyButton.evaluate((element) => getComputedStyle(element).backgroundColor);
|
||||
await buyButton.hover();
|
||||
const afterHover = await buyButton.evaluate((element) => getComputedStyle(element).backgroundColor);
|
||||
expect(afterHover).not.toBe(beforeHover);
|
||||
await buyButton.focus();
|
||||
await expect(buyButton).toBeFocused();
|
||||
|
||||
if (artifactRoot) {
|
||||
await page.screenshot({ path: resolve(artifactRoot, 'inherit-core-desktop.png'), fullPage: true });
|
||||
}
|
||||
|
||||
await page.setViewportSize({ width: 500, height: 900 });
|
||||
await page.reload();
|
||||
await expect(page.locator('#container')).toBeVisible();
|
||||
const mobile = await page.evaluate(() => {
|
||||
const container = document.querySelector<HTMLElement>('#container')!.getBoundingClientRect();
|
||||
const first = document.querySelector<HTMLElement>('#inherit_sum')!.getBoundingClientRect();
|
||||
const second = document.querySelector<HTMLElement>('#inherit_previous')!.getBoundingClientRect();
|
||||
return {
|
||||
containerWidth: container.width,
|
||||
firstWidth: first.width,
|
||||
stacked: second.y > first.y,
|
||||
};
|
||||
});
|
||||
expect(mobile.containerWidth).toBe(500);
|
||||
expect(mobile.firstWidth).toBeCloseTo(482, 0);
|
||||
expect(mobile.stacked).toBe(true);
|
||||
});
|
||||
|
||||
test('submits a legacy buff purchase and refreshes status and logs', async ({ page }) => {
|
||||
const fixture = await installFixture(page);
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
await page.goto(gameUrl);
|
||||
await page.locator('#buff-warAvoidRatio').fill('1');
|
||||
await page.locator('#buff-warAvoidRatio').locator('xpath=../..').getByRole('button', { name: '구입' }).click();
|
||||
await expect.poll(fixture.buffMutationCount).toBe(1);
|
||||
await expect(page.locator('#inherit_previous_value')).toHaveValue('12,000');
|
||||
});
|
||||
|
||||
test('keeps controls usable and renders an API mutation error', async ({ page }) => {
|
||||
await installFixture(page, { failBuff: true });
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
await page.goto(gameUrl);
|
||||
await page.locator('#buff-warAvoidRatio').fill('1');
|
||||
await page.locator('#buff-warAvoidRatio').locator('xpath=../..').getByRole('button', { name: '구입' }).click();
|
||||
await expect(page.locator('[role="alert"]')).toBeVisible();
|
||||
await expect(page.locator('#buff-warAvoidRatio')).toHaveValue('1');
|
||||
await expect(page.locator('#buff-warAvoidRatio')).toBeEnabled();
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ export default defineConfig({
|
||||
'public-gaps.spec.ts',
|
||||
'instant-diplomacy-message.spec.ts',
|
||||
'tournament-betting.spec.ts',
|
||||
'inheritance-management.spec.ts',
|
||||
],
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { chromium } from '@playwright/test';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const baseUrl = process.env.REF_PARITY_URL ?? 'http://127.0.0.1:3400/sam/';
|
||||
const username = process.env.REF_PARITY_USER ?? 'refadmin';
|
||||
const passwordFile = process.env.REF_PARITY_PASSWORD_FILE;
|
||||
const artifactRoot = resolve(process.env.REF_PARITY_ARTIFACT_DIR ?? 'test-results/reference-current-city');
|
||||
|
||||
if (!passwordFile) {
|
||||
throw new Error('REF_PARITY_PASSWORD_FILE is required.');
|
||||
}
|
||||
|
||||
const password = (await readFile(passwordFile, 'utf8')).trim();
|
||||
await mkdir(artifactRoot, { recursive: true });
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1200, height: 900 },
|
||||
deviceScaleFactor: 1,
|
||||
locale: 'ko-KR',
|
||||
timezoneId: 'Asia/Seoul',
|
||||
colorScheme: 'dark',
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.goto(baseUrl, { waitUntil: 'networkidle' });
|
||||
const globalSalt = await page.locator('#global_salt').inputValue();
|
||||
const passwordHash = createHash('sha512')
|
||||
.update(globalSalt + password + globalSalt)
|
||||
.digest('hex');
|
||||
const loginResponse = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), {
|
||||
data: { username, password: passwordHash },
|
||||
});
|
||||
const loginResult = await loginResponse.json();
|
||||
if (!loginResponse.ok() || loginResult.result !== true) {
|
||||
throw new Error('Reference login failed.');
|
||||
}
|
||||
|
||||
await page.goto(new URL('hwe/', baseUrl).toString(), { waitUntil: 'networkidle' });
|
||||
const mapCity = page.locator('a[href*="b_currentCity.php?citylist="]').first();
|
||||
const hasMapCity = await mapCity.isVisible({ timeout: 8_000 }).catch(() => false);
|
||||
let mapInteraction;
|
||||
if (hasMapCity) {
|
||||
mapInteraction = await mapCity.evaluate((element) => ({
|
||||
available: true,
|
||||
href: element.getAttribute('href'),
|
||||
cursor: getComputedStyle(element).cursor,
|
||||
rect: (() => {
|
||||
const box = element.getBoundingClientRect();
|
||||
return { x: box.x, y: box.y, width: box.width, height: box.height };
|
||||
})(),
|
||||
}));
|
||||
await mapCity.click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
if (!page.url().includes('b_currentCity.php?citylist=')) {
|
||||
throw new Error(`Reference map click did not open current city: ${page.url()}`);
|
||||
}
|
||||
} else {
|
||||
mapInteraction = {
|
||||
available: false,
|
||||
pageUrl: page.url(),
|
||||
pageTitle: await page.title(),
|
||||
};
|
||||
await page.goto(new URL('hwe/b_currentCity.php', baseUrl).toString(), { waitUntil: 'networkidle' });
|
||||
}
|
||||
|
||||
const measurements = await page.evaluate(() => {
|
||||
const measure = (element) => {
|
||||
const box = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
rect: { x: box.x, y: box.y, width: box.width, height: box.height },
|
||||
style: {
|
||||
fontFamily: style.fontFamily,
|
||||
fontSize: style.fontSize,
|
||||
lineHeight: style.lineHeight,
|
||||
color: style.color,
|
||||
backgroundColor: style.backgroundColor,
|
||||
backgroundImage: style.backgroundImage,
|
||||
borderCollapse: style.borderCollapse,
|
||||
padding: style.padding,
|
||||
textAlign: style.textAlign,
|
||||
},
|
||||
};
|
||||
};
|
||||
const tables = [...document.querySelectorAll('table')];
|
||||
const selector = document.querySelector('#citySelector');
|
||||
const stats = tables.find((table) => table.textContent?.includes('90병장'));
|
||||
const generals = document.querySelector('#general_list')?.closest('table');
|
||||
const firstIcon = document.querySelector('.generalIcon');
|
||||
const title = stats?.querySelector('tr:first-child td');
|
||||
return {
|
||||
body: measure(document.body),
|
||||
tables: tables.map(measure),
|
||||
selector: selector ? measure(selector) : null,
|
||||
stats: stats ? measure(stats) : null,
|
||||
generals: generals ? measure(generals) : null,
|
||||
firstIcon: firstIcon
|
||||
? {
|
||||
...measure(firstIcon),
|
||||
naturalWidth: firstIcon.naturalWidth,
|
||||
naturalHeight: firstIcon.naturalHeight,
|
||||
}
|
||||
: null,
|
||||
title: title ? measure(title) : null,
|
||||
document: {
|
||||
width: document.documentElement.scrollWidth,
|
||||
height: document.documentElement.scrollHeight,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
await page.screenshot({
|
||||
path: resolve(artifactRoot, 'reference-current-city-desktop.png'),
|
||||
fullPage: true,
|
||||
animations: 'disabled',
|
||||
});
|
||||
await writeFile(
|
||||
resolve(artifactRoot, 'reference-current-city-computed-dom.json'),
|
||||
`${JSON.stringify({ mapInteraction, currentCity: measurements }, null, 2)}\n`
|
||||
);
|
||||
process.stdout.write(`${JSON.stringify({ ok: true, artifactRoot })}\n`);
|
||||
await context.close();
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { chromium } from '@playwright/test';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const baseUrl = process.env.REF_GENERAL_URL ?? 'http://127.0.0.1:3400/sam/';
|
||||
const username = process.env.REF_GENERAL_USER ?? 'refuser1';
|
||||
const passwordFile = process.env.REF_GENERAL_PASSWORD_FILE;
|
||||
const artifactRoot = resolve(process.env.REF_GENERAL_ARTIFACT_DIR ?? 'test-results/reference-general-lists');
|
||||
if (!passwordFile) throw new Error('REF_GENERAL_PASSWORD_FILE is required.');
|
||||
const password = (await readFile(passwordFile, 'utf8')).trim();
|
||||
await mkdir(artifactRoot, { recursive: true });
|
||||
|
||||
const measure = async (page, selectors) =>
|
||||
page.evaluate((items) => {
|
||||
const result = {};
|
||||
for (const [name, selector] of Object.entries(items)) {
|
||||
const element = document.querySelector(selector);
|
||||
if (!element) {
|
||||
result[name] = null;
|
||||
continue;
|
||||
}
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
result[name] = {
|
||||
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
||||
style: {
|
||||
fontFamily: style.fontFamily,
|
||||
fontSize: style.fontSize,
|
||||
borderCollapse: style.borderCollapse,
|
||||
backgroundImage: style.backgroundImage,
|
||||
color: style.color,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { elements: result, documentWidth: document.documentElement.scrollWidth };
|
||||
}, selectors);
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1200, height: 900 },
|
||||
deviceScaleFactor: 1,
|
||||
locale: 'ko-KR',
|
||||
colorScheme: 'dark',
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.goto(baseUrl, { waitUntil: 'networkidle' });
|
||||
const salt = await page.locator('#global_salt').inputValue();
|
||||
const passwordHash = createHash('sha512')
|
||||
.update(salt + password + salt)
|
||||
.digest('hex');
|
||||
const login = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), {
|
||||
data: { username, password: passwordHash },
|
||||
});
|
||||
const loginResult = await login.json();
|
||||
if (!login.ok() || loginResult.result !== true) throw new Error('Reference login failed.');
|
||||
|
||||
const output = {};
|
||||
for (const [name, path, selectors] of [
|
||||
[
|
||||
'generals',
|
||||
'hwe/b_myGenInfo.php',
|
||||
{
|
||||
body: 'body',
|
||||
title: 'body > table:first-of-type',
|
||||
list: 'body > table:nth-of-type(2)',
|
||||
firstRow: 'body > table:nth-of-type(2) tr:nth-child(2)',
|
||||
},
|
||||
],
|
||||
[
|
||||
'secret',
|
||||
'hwe/b_genList.php',
|
||||
{
|
||||
body: 'body',
|
||||
title: 'body > table:first-of-type',
|
||||
summary: 'body > table:nth-of-type(2)',
|
||||
list: '#general_list',
|
||||
firstRow: '#general_list tbody tr:first-child',
|
||||
},
|
||||
],
|
||||
]) {
|
||||
await page.goto(new URL(path, baseUrl).toString(), { waitUntil: 'networkidle' });
|
||||
output[name] = await measure(page, selectors);
|
||||
await page.screenshot({ path: resolve(artifactRoot, `ref-${name}.png`), fullPage: true });
|
||||
}
|
||||
await writeFile(resolve(artifactRoot, 'computed-dom.json'), `${JSON.stringify(output, null, 2)}\n`);
|
||||
process.stdout.write(`${JSON.stringify({ ok: true, artifactRoot, output })}\n`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { chromium } from '@playwright/test';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const baseUrl = process.env.REF_PARITY_URL ?? 'http://127.0.0.1:3400/sam/';
|
||||
const username = process.env.REF_PARITY_USER ?? 'refadmin';
|
||||
const passwordFile = process.env.REF_PARITY_PASSWORD_FILE;
|
||||
const artifactRoot = resolve(process.env.REF_PARITY_ARTIFACT_DIR ?? 'test-results/reference-npc-policy');
|
||||
|
||||
if (!passwordFile) {
|
||||
throw new Error('REF_PARITY_PASSWORD_FILE is required.');
|
||||
}
|
||||
|
||||
const password = (await readFile(passwordFile, 'utf8')).trim();
|
||||
await mkdir(artifactRoot, { recursive: true });
|
||||
|
||||
const login = async (context, page) => {
|
||||
await page.goto(baseUrl, { waitUntil: 'networkidle' });
|
||||
const globalSalt = await page.locator('#global_salt').inputValue();
|
||||
const passwordHash = createHash('sha512')
|
||||
.update(globalSalt + password + globalSalt)
|
||||
.digest('hex');
|
||||
const response = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), {
|
||||
data: { username, password: passwordHash },
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok() || result.result !== true) {
|
||||
throw new Error('Reference login failed.');
|
||||
}
|
||||
};
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const result = {};
|
||||
for (const viewport of [
|
||||
{ name: 'desktop', width: 1000, height: 900 },
|
||||
{ name: 'mobile', width: 500, height: 900 },
|
||||
]) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: viewport.width, height: viewport.height },
|
||||
deviceScaleFactor: 1,
|
||||
locale: 'ko-KR',
|
||||
timezoneId: 'Asia/Seoul',
|
||||
colorScheme: 'dark',
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await login(context, page);
|
||||
await page.goto(new URL('hwe/', baseUrl).toString(), { waitUntil: 'networkidle' });
|
||||
await page.goto(new URL('hwe/v_NPCControl.php', baseUrl).toString(), { waitUntil: 'networkidle' });
|
||||
try {
|
||||
await page.locator('#container').waitFor({ timeout: 10_000 });
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Reference NPC policy failed to mount: ${JSON.stringify({
|
||||
url: page.url(),
|
||||
text: (await page.locator('body').innerText()).slice(0, 500),
|
||||
})}`
|
||||
);
|
||||
}
|
||||
result[viewport.name] = await page.evaluate(() => {
|
||||
const measure = (selector) => {
|
||||
const element = document.querySelector(selector);
|
||||
if (!element) return null;
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
||||
style: {
|
||||
display: style.display,
|
||||
gridTemplateColumns: style.gridTemplateColumns,
|
||||
fontFamily: style.fontFamily,
|
||||
fontSize: style.fontSize,
|
||||
lineHeight: style.lineHeight,
|
||||
color: style.color,
|
||||
backgroundColor: style.backgroundColor,
|
||||
backgroundImage: style.backgroundImage,
|
||||
borderColor: style.borderColor,
|
||||
padding: style.padding,
|
||||
margin: style.margin,
|
||||
cursor: style.cursor,
|
||||
},
|
||||
};
|
||||
};
|
||||
return {
|
||||
body: measure('body'),
|
||||
container: measure('#container'),
|
||||
topBackBar: measure('body > :first-child'),
|
||||
sectionBar: measure('.section_bar'),
|
||||
formList: measure('.form_list'),
|
||||
firstField: measure('.form_list > .col'),
|
||||
firstInput: measure('input[type="number"]'),
|
||||
firstInfoButton: measure('.form_list button'),
|
||||
controlBar: measure('.control_bar'),
|
||||
resetButton: measure('.reset_btn'),
|
||||
submitButton: measure('.submit_btn'),
|
||||
priorityGrid: measure('.half_section_left'),
|
||||
priorityColumn: measure('.priority-list'),
|
||||
priorityItem: measure('.priority-list .list-group-item'),
|
||||
helpButton: measure('.priority_info button'),
|
||||
document: {
|
||||
width: document.documentElement.scrollWidth,
|
||||
height: document.documentElement.scrollHeight,
|
||||
},
|
||||
};
|
||||
});
|
||||
await page.screenshot({
|
||||
path: resolve(artifactRoot, `ref-npc-policy-${viewport.name}.png`),
|
||||
fullPage: true,
|
||||
});
|
||||
await context.close();
|
||||
}
|
||||
await writeFile(resolve(artifactRoot, 'computed-dom.json'), `${JSON.stringify(result, null, 2)}\n`);
|
||||
process.stdout.write(`${JSON.stringify({ ok: true, artifactRoot, viewports: Object.keys(result) })}\n`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -25,6 +25,11 @@ import {
|
||||
type CanonicalTurnSnapshot,
|
||||
} from './canonical.js';
|
||||
|
||||
interface GeneralCooldownSelector {
|
||||
generalId: number;
|
||||
actionName: string;
|
||||
}
|
||||
|
||||
export interface TurnCommandFixtureRequest {
|
||||
kind: 'general' | 'nation';
|
||||
actorGeneralId: number;
|
||||
@@ -47,6 +52,7 @@ export interface TurnCommandFixtureRequest {
|
||||
troops?: Array<Record<string, unknown>>;
|
||||
diplomacy?: Array<Record<string, unknown>>;
|
||||
randomFoundingCandidateCityIds?: number[];
|
||||
generalCooldowns?: Array<GeneralCooldownSelector & { nextAvailableTurn: number }>;
|
||||
};
|
||||
observe?: {
|
||||
generalIds?: number[];
|
||||
@@ -54,6 +60,7 @@ export interface TurnCommandFixtureRequest {
|
||||
nationIds?: number[];
|
||||
logAfterId?: number;
|
||||
messageAfterId?: number;
|
||||
generalCooldowns?: GeneralCooldownSelector[];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -288,6 +295,19 @@ const buildWorldInput = (
|
||||
const month = readNumber(referenceBefore.world, 'month', request.setup?.world?.month ?? 1);
|
||||
const turnTime = new Date(`${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-01T00:00:00.000Z`);
|
||||
const generals = referenceBefore.generals.map((row) => buildGeneral(row, turnTime));
|
||||
const referenceGeneralCooldowns = Array.isArray(referenceBefore.world.generalCooldowns)
|
||||
? referenceBefore.world.generalCooldowns
|
||||
: [];
|
||||
for (const rawCooldown of referenceGeneralCooldowns) {
|
||||
const cooldown = asRecord(rawCooldown);
|
||||
const generalId = readNumber(cooldown, 'generalId');
|
||||
const actionName = readString(cooldown, 'actionName', '');
|
||||
const nextAvailableTurn = cooldown.nextAvailableTurn;
|
||||
const general = generals.find((entry) => entry.id === generalId);
|
||||
if (general && actionName && typeof nextAvailableTurn === 'number' && Number.isFinite(nextAvailableTurn)) {
|
||||
general.meta[`next_execute_${actionName}`] = nextAvailableTurn;
|
||||
}
|
||||
}
|
||||
const fixtureNations = new Map((request.setup?.nations ?? []).map((row) => [readNumber(row, 'id'), row] as const));
|
||||
const nations = referenceBefore.nations.map((row) =>
|
||||
buildNation(
|
||||
@@ -429,6 +449,7 @@ const projectWorld = (
|
||||
generalIds: Set<number>;
|
||||
cityIds: Set<number>;
|
||||
nationIds: Set<number>;
|
||||
generalCooldowns: GeneralCooldownSelector[];
|
||||
}
|
||||
): CanonicalTurnSnapshot => {
|
||||
const state = world.getState();
|
||||
@@ -489,6 +510,15 @@ const projectWorld = (
|
||||
tickMinutes: Math.max(1, Math.round(state.tickSeconds / 60)),
|
||||
turnTime: state.lastTurnTime.toISOString(),
|
||||
isUnited: readNumber(state.meta, 'isUnited'),
|
||||
generalCooldowns: selector.generalCooldowns.map(({ generalId, actionName }) => {
|
||||
const general = world.getGeneralById(generalId);
|
||||
const raw = general?.meta[`next_execute_${actionName}`];
|
||||
return {
|
||||
generalId,
|
||||
actionName,
|
||||
nextAvailableTurn: typeof raw === 'number' && Number.isFinite(raw) ? raw : null,
|
||||
};
|
||||
}),
|
||||
},
|
||||
generals,
|
||||
cities: world
|
||||
@@ -595,6 +625,7 @@ export const runCoreTurnCommandTrace = async (
|
||||
]),
|
||||
cityIds: new Set(referenceBefore.cities.map((row) => readNumber(row, 'id'))),
|
||||
nationIds: new Set(referenceBefore.nations.map((row) => readNumber(row, 'id'))),
|
||||
generalCooldowns: request.observe?.generalCooldowns ?? [],
|
||||
};
|
||||
const reservedTurns = new InMemoryReservedTurnStore(emptyDatabaseClient as never, {
|
||||
maxGeneralTurns: 10,
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs/promises';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
|
||||
|
||||
import type { AppRouter as GatewayAppRouter } from '@sammo-ts/gateway-api';
|
||||
import { createGatewayApiServer } from '@sammo-ts/gateway-api';
|
||||
import type { AppRouter as GameAppRouter } from '@sammo-ts/game-api';
|
||||
import {
|
||||
buildTournamentKeys,
|
||||
createGameApiServer,
|
||||
DatabaseTurnDaemonTransport,
|
||||
processTournamentTick,
|
||||
TournamentStore,
|
||||
} from '@sammo-ts/game-api';
|
||||
import { createTurnDaemonRuntime } from '@sammo-ts/game-engine';
|
||||
import {
|
||||
createGamePostgresConnector,
|
||||
createGatewayPostgresConnector,
|
||||
createRedisConnector,
|
||||
resolvePostgresConfigFromEnv,
|
||||
resolveRedisConfigFromEnv,
|
||||
type GamePrisma,
|
||||
} from '@sammo-ts/infra';
|
||||
|
||||
const workspaceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const parseEnvFile = (rawText: string): Record<string, string> => {
|
||||
const env: Record<string, string> = {};
|
||||
for (const line of rawText.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
const separator = trimmed.indexOf('=');
|
||||
if (separator < 0) {
|
||||
continue;
|
||||
}
|
||||
const key = trimmed.slice(0, separator).trim();
|
||||
let value = trimmed.slice(separator + 1).trim();
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
env[key] = value;
|
||||
}
|
||||
return env;
|
||||
};
|
||||
|
||||
const loadEnv = async (): Promise<void> => {
|
||||
const values = parseEnvFile(await fs.readFile(path.join(workspaceRoot, '.env.ci'), 'utf8'));
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (process.env[key] === undefined) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
process.env.INTEGRATION_JOIN_ALLOW_CITY ??= 'true';
|
||||
process.env.INTEGRATION_WORLD_SEED ??= 'tournament-lifecycle-seed';
|
||||
};
|
||||
|
||||
const execCommand = (command: string, args: string[], env?: NodeJS.ProcessEnv): Promise<void> =>
|
||||
new Promise((resolve, reject) => {
|
||||
execFile(command, args, { env, cwd: workspaceRoot }, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(new Error(`${command} ${args.join(' ')} failed:\n${stdout}\n${stderr}`));
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
const ensureSchema = async (schema: string): Promise<void> => {
|
||||
const connector = createGatewayPostgresConnector({
|
||||
url: resolvePostgresConfigFromEnv({ schema: 'public' }).url,
|
||||
});
|
||||
await connector.connect();
|
||||
try {
|
||||
await connector.prisma.$executeRawUnsafe(`CREATE SCHEMA IF NOT EXISTS "${schema}"`);
|
||||
} finally {
|
||||
await connector.disconnect();
|
||||
}
|
||||
};
|
||||
|
||||
const truncateSchema = async (schema: string): Promise<void> => {
|
||||
const connector = createGatewayPostgresConnector({
|
||||
url: resolvePostgresConfigFromEnv({ schema: 'public' }).url,
|
||||
});
|
||||
await connector.connect();
|
||||
try {
|
||||
const rows = (await connector.prisma.$queryRawUnsafe(
|
||||
`SELECT tablename FROM pg_tables WHERE schemaname = '${schema}'`
|
||||
)) as Array<{ tablename: string }>;
|
||||
if (rows.length === 0) {
|
||||
return;
|
||||
}
|
||||
const tableList = rows.map((row) => `"${schema}"."${row.tablename}"`).join(', ');
|
||||
await connector.prisma.$executeRawUnsafe(`TRUNCATE TABLE ${tableList} RESTART IDENTITY CASCADE`);
|
||||
} finally {
|
||||
await connector.disconnect();
|
||||
}
|
||||
};
|
||||
|
||||
const resetServices = async (): Promise<void> => {
|
||||
await ensureSchema('public');
|
||||
await ensureSchema('che');
|
||||
await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:db:push:gateway', '--accept-data-loss'], {
|
||||
...process.env,
|
||||
POSTGRES_SCHEMA: 'public',
|
||||
});
|
||||
await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:db:push:game', '--accept-data-loss'], {
|
||||
...process.env,
|
||||
POSTGRES_SCHEMA: 'che',
|
||||
});
|
||||
await truncateSchema('public');
|
||||
await truncateSchema('che');
|
||||
|
||||
const redis = createRedisConnector(resolveRedisConfigFromEnv());
|
||||
await redis.connect();
|
||||
try {
|
||||
await redis.client.flushDb();
|
||||
} finally {
|
||||
await redis.disconnect();
|
||||
}
|
||||
};
|
||||
|
||||
const createGatewayClient = (baseUrl: string, pathName: string, token: { value?: string }) =>
|
||||
createTRPCProxyClient<GatewayAppRouter>({
|
||||
links: [
|
||||
httpBatchLink({
|
||||
url: `${baseUrl}${pathName}`,
|
||||
headers: () => (token.value ? { 'x-session-token': token.value } : {}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const createGameClient = (baseUrl: string, pathName: string, token: { value?: string }) =>
|
||||
createTRPCProxyClient<GameAppRouter>({
|
||||
links: [
|
||||
httpBatchLink({
|
||||
url: `${baseUrl}${pathName}`,
|
||||
headers: () => (token.value ? { authorization: `Bearer ${token.value}` } : {}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
describe('actual tournament lifecycle', () => {
|
||||
let gatewayServer: Awaited<ReturnType<typeof createGatewayApiServer>> | null = null;
|
||||
let gameServer: Awaited<ReturnType<typeof createGameApiServer>> | null = null;
|
||||
let turnDaemon: Awaited<ReturnType<typeof createTurnDaemonRuntime>> | null = null;
|
||||
let turnDaemonLoop: Promise<void> | null = null;
|
||||
let gameConnector: Awaited<ReturnType<typeof createGamePostgresConnector>> | null = null;
|
||||
let redisConnector: Awaited<ReturnType<typeof createRedisConnector>> | null = null;
|
||||
let store: TournamentStore | null = null;
|
||||
let transport: DatabaseTurnDaemonTransport | null = null;
|
||||
|
||||
const clients = new Map<string, ReturnType<typeof createGameClient>>();
|
||||
const generalIds = new Map<string, number>();
|
||||
|
||||
beforeAll(async () => {
|
||||
await loadEnv();
|
||||
process.env.SCENARIO = '908';
|
||||
process.chdir(workspaceRoot);
|
||||
await resetServices();
|
||||
|
||||
gatewayServer = await createGatewayApiServer();
|
||||
await gatewayServer.app.listen({ host: gatewayServer.config.host, port: gatewayServer.config.port });
|
||||
gameServer = await createGameApiServer();
|
||||
await gameServer.app.listen({ host: gameServer.config.host, port: gameServer.config.port });
|
||||
|
||||
const gatewayUrl = `http://localhost:${gatewayServer.config.port}`;
|
||||
const gameUrl = `http://localhost:${gameServer.config.port}`;
|
||||
const adminSession = { value: undefined as string | undefined };
|
||||
const gatewayClient = createGatewayClient(gatewayUrl, gatewayServer.config.trpcPath, adminSession);
|
||||
|
||||
const bootstrap = await gatewayClient.auth.bootstrapLocal.mutate({
|
||||
token: process.env.GATEWAY_BOOTSTRAP_TOKEN ?? '',
|
||||
username: 'admin',
|
||||
password: 'admin-pass-123',
|
||||
displayName: '관리자',
|
||||
});
|
||||
adminSession.value = bootstrap.sessionToken;
|
||||
|
||||
const users = [
|
||||
['participant', '대회참가자'],
|
||||
['bettor-a', '베팅유저A'],
|
||||
['bettor-b', '베팅유저B'],
|
||||
['no-general', '무장수유저'],
|
||||
] as const;
|
||||
for (const [username, displayName] of users) {
|
||||
await gatewayClient.admin.users.createLocal.mutate({
|
||||
username,
|
||||
password: `${username}-pass`,
|
||||
displayName,
|
||||
});
|
||||
}
|
||||
|
||||
await gatewayClient.admin.profiles.upsert.mutate({
|
||||
profile: 'che',
|
||||
scenario: '908',
|
||||
apiPort: Number(process.env.GAME_API_PORT ?? 14000),
|
||||
status: 'RUNNING',
|
||||
});
|
||||
await gatewayClient.admin.profiles.installNow.mutate({
|
||||
profileName: 'che:908',
|
||||
install: {
|
||||
scenarioId: 908,
|
||||
turnTermMinutes: 1,
|
||||
sync: false,
|
||||
fiction: 0,
|
||||
extend: true,
|
||||
blockGeneralCreate: 0,
|
||||
npcMode: 0,
|
||||
showImgLevel: 0,
|
||||
tournamentTrig: true,
|
||||
joinMode: 'full',
|
||||
autorunUser: null,
|
||||
},
|
||||
});
|
||||
|
||||
for (const [username, displayName] of users) {
|
||||
const login = await gatewayClient.auth.login.mutate({
|
||||
username,
|
||||
password: `${username}-pass`,
|
||||
});
|
||||
const gatewayToken = await gatewayClient.auth.issueGameSession.mutate({
|
||||
sessionToken: login.sessionToken,
|
||||
profile: 'che:908',
|
||||
});
|
||||
const accessRef = { value: undefined as string | undefined };
|
||||
const client = createGameClient(gameUrl, gameServer.config.trpcPath, accessRef);
|
||||
const access = await client.auth.exchangeGatewayToken.mutate({ gatewayToken: gatewayToken.gameToken });
|
||||
accessRef.value = access.accessToken;
|
||||
clients.set(username, client);
|
||||
|
||||
if (username !== 'no-general') {
|
||||
const created = await client.join.createGeneral.mutate({
|
||||
name: displayName,
|
||||
leadership: 55,
|
||||
strength: 55,
|
||||
intel: 55,
|
||||
character: 'Random',
|
||||
});
|
||||
generalIds.set(username, created.generalId);
|
||||
}
|
||||
}
|
||||
|
||||
const gameDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'che' }).url;
|
||||
const gatewayDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url;
|
||||
gameConnector = createGamePostgresConnector({ url: gameDatabaseUrl });
|
||||
await gameConnector.connect();
|
||||
await gameConnector.prisma.general.updateMany({
|
||||
where: { id: { in: [...generalIds.values()] } },
|
||||
data: { gold: 10_000 },
|
||||
});
|
||||
const maxGeneralId = (await gameConnector.prisma.general.aggregate({ _max: { id: true } }))._max.id ?? 0;
|
||||
const npcTemplate = await gameConnector.prisma.general.findFirstOrThrow({
|
||||
where: { id: { in: [...generalIds.values()] } },
|
||||
});
|
||||
const {
|
||||
id: _templateId,
|
||||
userId: _templateUserId,
|
||||
name: _templateName,
|
||||
createdAt: _templateCreatedAt,
|
||||
updatedAt: _templateUpdatedAt,
|
||||
...npcTemplateData
|
||||
} = npcTemplate;
|
||||
const templateMeta =
|
||||
typeof npcTemplate.meta === 'object' && npcTemplate.meta !== null && !Array.isArray(npcTemplate.meta)
|
||||
? npcTemplate.meta
|
||||
: {};
|
||||
await gameConnector.prisma.general.createMany({
|
||||
data: Array.from({ length: 48 }, (_, index): GamePrisma.GeneralCreateManyInput => ({
|
||||
...npcTemplateData,
|
||||
id: maxGeneralId + index + 1,
|
||||
userId: null,
|
||||
name: `대회NPC${index + 1}`,
|
||||
npcState: 2,
|
||||
leadership: 40 + (index % 31),
|
||||
strength: 40 + ((index * 3) % 31),
|
||||
intel: 40 + ((index * 7) % 31),
|
||||
gold: 10_000,
|
||||
lastTurn: npcTemplate.lastTurn as GamePrisma.InputJsonValue,
|
||||
meta: { ...templateMeta, explevel: 20 },
|
||||
penalty: npcTemplate.penalty as GamePrisma.InputJsonValue,
|
||||
})),
|
||||
});
|
||||
|
||||
redisConnector = createRedisConnector(resolveRedisConfigFromEnv());
|
||||
await redisConnector.connect();
|
||||
store = new TournamentStore(redisConnector.client, buildTournamentKeys('che:908'));
|
||||
transport = new DatabaseTurnDaemonTransport(gameConnector.prisma, 30_000);
|
||||
|
||||
turnDaemon = await createTurnDaemonRuntime({
|
||||
profile: 'che',
|
||||
profileName: 'che:908',
|
||||
databaseUrl: gameDatabaseUrl,
|
||||
gatewayDatabaseUrl,
|
||||
redisUrl: resolveRedisConfigFromEnv().url,
|
||||
});
|
||||
|
||||
for (let attempt = 0; attempt < 36; attempt += 1) {
|
||||
const current = turnDaemon.world.getState().lastTurnTime;
|
||||
const next = new Date(current.getTime());
|
||||
next.setUTCMonth(next.getUTCMonth() + 1);
|
||||
await turnDaemon.world.advanceMonth(next);
|
||||
if ((await store.getState())?.stage === 1) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(await store.getState()).toMatchObject({ stage: 1, auto: true });
|
||||
|
||||
turnDaemonLoop = turnDaemon.lifecycle.start();
|
||||
const status = await transport.requestStatus(10_000);
|
||||
expect(status).not.toBeNull();
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (turnDaemon) {
|
||||
await turnDaemon.lifecycle.stop('tournament-lifecycle-test');
|
||||
await turnDaemon.close();
|
||||
await turnDaemonLoop;
|
||||
}
|
||||
await redisConnector?.disconnect();
|
||||
await gameConnector?.disconnect();
|
||||
await gameServer?.app.close();
|
||||
await gatewayServer?.app.close();
|
||||
}, 30_000);
|
||||
|
||||
it('runs auto-open, enrollment, betting, finals, rewards, and payout through the real daemon', async () => {
|
||||
if (!store || !transport || !gameConnector) {
|
||||
throw new Error('integration runtime is not ready');
|
||||
}
|
||||
const participant = clients.get('participant')!;
|
||||
const bettorA = clients.get('bettor-a')!;
|
||||
const bettorB = clients.get('bettor-b')!;
|
||||
const noGeneral = clients.get('no-general')!;
|
||||
|
||||
await expect(noGeneral.tournament.getState.query()).rejects.toThrow();
|
||||
await expect(participant.tournament.join.mutate()).resolves.toMatchObject({ ok: true });
|
||||
await expect(participant.tournament.join.mutate()).resolves.toMatchObject({ ok: true });
|
||||
|
||||
for (let steps = 0; steps < 2000; steps += 1) {
|
||||
const state = await store.getState();
|
||||
if (!state) {
|
||||
throw new Error('tournament state disappeared');
|
||||
}
|
||||
if (state.stage === 6) {
|
||||
break;
|
||||
}
|
||||
await store.setState({
|
||||
...state,
|
||||
nextAt: new Date(Date.now() - 1_000).toISOString(),
|
||||
});
|
||||
await processTournamentTick({
|
||||
store,
|
||||
prisma: gameConnector.prisma,
|
||||
daemonTransport: transport,
|
||||
});
|
||||
}
|
||||
|
||||
const bettingState = await store.getState();
|
||||
expect(bettingState).toMatchObject({ stage: 6, auto: true });
|
||||
const matches = await store.getMatches();
|
||||
const candidates = Array.from(
|
||||
new Set(
|
||||
matches.filter((match) => match.stage === 7).flatMap((match) => [match.attackerId, match.defenderId])
|
||||
)
|
||||
);
|
||||
expect(candidates).toHaveLength(16);
|
||||
|
||||
const idleDeadline = Date.now() + 60_000;
|
||||
while (Date.now() < idleDeadline) {
|
||||
const pending = await gameConnector.prisma.inputEvent.count({
|
||||
where: { status: { in: ['PENDING', 'PROCESSING'] } },
|
||||
});
|
||||
if (pending === 0) {
|
||||
break;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
expect(
|
||||
await gameConnector.prisma.inputEvent.count({
|
||||
where: { status: { in: ['PENDING', 'PROCESSING'] } },
|
||||
})
|
||||
).toBe(0);
|
||||
|
||||
for (const targetId of candidates) {
|
||||
await bettorA.tournament.placeBet.mutate({ targetId, amount: 10 });
|
||||
}
|
||||
await bettorB.tournament.placeBet.mutate({ targetId: candidates[0]!, amount: 100 });
|
||||
|
||||
const bets = await store.getBettingEntries();
|
||||
const bettorAId = generalIds.get('bettor-a')!;
|
||||
const bettorBId = generalIds.get('bettor-b')!;
|
||||
expect(bets.filter((entry) => entry.generalId === bettorAId)).toHaveLength(16);
|
||||
expect(bets.some((entry) => entry.generalId === bettorBId && entry.targetId === candidates[0])).toBe(true);
|
||||
expect(bets.every((entry) => entry.generalId !== generalIds.get('participant'))).toBe(true);
|
||||
|
||||
const bettorBeforeSettlement = await gameConnector.prisma.general.findUniqueOrThrow({
|
||||
where: { id: bettorAId },
|
||||
select: { gold: true, meta: true },
|
||||
});
|
||||
|
||||
await store.setState({
|
||||
...bettingState!,
|
||||
bettingCloseAt: new Date(Date.now() - 1_000).toISOString(),
|
||||
nextAt: new Date(Date.now() - 1_000).toISOString(),
|
||||
});
|
||||
|
||||
for (let steps = 0; steps < 100; steps += 1) {
|
||||
const state = await store.getState();
|
||||
if (!state) {
|
||||
throw new Error('tournament state disappeared');
|
||||
}
|
||||
if (state.stage === 0 && state.rewardSettled && state.bettingSettled) {
|
||||
break;
|
||||
}
|
||||
if (state.stage > 0) {
|
||||
await store.setState({
|
||||
...state,
|
||||
bettingCloseAt:
|
||||
state.stage === 6 ? new Date(Date.now() - 1_000).toISOString() : state.bettingCloseAt,
|
||||
nextAt: new Date(Date.now() - 1_000).toISOString(),
|
||||
});
|
||||
}
|
||||
await processTournamentTick({
|
||||
store,
|
||||
prisma: gameConnector.prisma,
|
||||
daemonTransport: transport,
|
||||
});
|
||||
}
|
||||
|
||||
const finalState = await store.getState();
|
||||
expect(finalState).toMatchObject({
|
||||
stage: 0,
|
||||
auto: false,
|
||||
rewardSettled: true,
|
||||
bettingSettled: true,
|
||||
});
|
||||
expect(finalState?.winnerId).toBeTypeOf('number');
|
||||
|
||||
const bettorAfterSettlement = await gameConnector.prisma.general.findUniqueOrThrow({
|
||||
where: { id: bettorAId },
|
||||
select: { gold: true, meta: true },
|
||||
});
|
||||
expect(bettorAfterSettlement.gold).toBeGreaterThan(bettorBeforeSettlement.gold);
|
||||
expect(bettorAfterSettlement.meta).toMatchObject({
|
||||
betgold: 160,
|
||||
betwin: 1,
|
||||
});
|
||||
expect(Number((bettorAfterSettlement.meta as Record<string, unknown>).betwingold)).toBeGreaterThan(0);
|
||||
|
||||
const settlementEvents = await gameConnector.prisma.inputEvent.findMany({
|
||||
where: { eventType: { in: ['tournamentReward', 'tournamentBettingPayout'] } },
|
||||
select: { eventType: true, status: true, result: true },
|
||||
});
|
||||
expect(settlementEvents).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ eventType: 'tournamentReward', status: 'SUCCEEDED' }),
|
||||
expect.objectContaining({ eventType: 'tournamentBettingPayout', status: 'SUCCEEDED' }),
|
||||
])
|
||||
);
|
||||
expect(settlementEvents.every((event) => (event.result as { ok?: boolean } | null)?.ok === true)).toBe(true);
|
||||
}, 120_000);
|
||||
});
|
||||
@@ -931,6 +931,184 @@ integration('general command pre-required turn boundary matrix', () => {
|
||||
);
|
||||
});
|
||||
|
||||
const readGeneralCooldown = (
|
||||
snapshot: { world: Record<string, unknown> },
|
||||
generalId: number,
|
||||
actionName: string
|
||||
): number | null => {
|
||||
const cooldowns = Array.isArray(snapshot.world.generalCooldowns) ? snapshot.world.generalCooldowns : [];
|
||||
const matched = cooldowns.find(
|
||||
(entry) =>
|
||||
typeof entry === 'object' &&
|
||||
entry !== null &&
|
||||
(entry as Record<string, unknown>).generalId === generalId &&
|
||||
(entry as Record<string, unknown>).actionName === actionName
|
||||
);
|
||||
const value =
|
||||
typeof matched === 'object' && matched !== null ? (matched as Record<string, unknown>).nextAvailableTurn : null;
|
||||
return typeof value === 'number' ? value : null;
|
||||
};
|
||||
|
||||
integration('general command post-required cooldown boundary matrix', () => {
|
||||
it('stores the same 60-turn cooldown after domestic trait reset completion', async () => {
|
||||
const request = buildRequest('che_내정특기초기화', undefined, {
|
||||
specialDomestic: 'che_인덕',
|
||||
lastTurn: { command: '내정 특기 초기화', term: 1 },
|
||||
});
|
||||
request.observe!.generalCooldowns = [{ generalId: 1, actionName: '내정 특기 초기화' }];
|
||||
const reference = runReferenceTurnCommandTraceRequest(
|
||||
workspaceRoot!,
|
||||
request as unknown as Record<string, unknown>
|
||||
);
|
||||
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||
const expectedNextAvailableTurn = 190 * 12 + 1 - 1 + 60 - 1;
|
||||
|
||||
expect(reference.execution.outcome).toMatchObject({ completed: true });
|
||||
expect(core.execution.outcome).toMatchObject({
|
||||
requestedAction: 'che_내정특기초기화',
|
||||
actionKey: 'che_내정특기초기화',
|
||||
usedFallback: false,
|
||||
});
|
||||
expect(readGeneralCooldown(reference.after, 1, '내정 특기 초기화')).toBe(expectedNextAvailableTurn);
|
||||
expect(readGeneralCooldown(core.after, 1, '내정 특기 초기화')).toBe(expectedNextAvailableTurn);
|
||||
expect(core.rng).toEqual(reference.rng);
|
||||
expect(
|
||||
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||
})
|
||||
).toEqual([]);
|
||||
}, 120_000);
|
||||
|
||||
it('blocks domestic trait reset one turn before the cooldown boundary', async () => {
|
||||
const currentYearMonth = 190 * 12 + 1 - 1;
|
||||
const request = buildRequest('che_내정특기초기화', undefined, {
|
||||
specialDomestic: 'che_인덕',
|
||||
lastTurn: { command: '내정 특기 초기화', term: 1 },
|
||||
});
|
||||
request.setup!.generalCooldowns = [
|
||||
{
|
||||
generalId: 1,
|
||||
actionName: '내정 특기 초기화',
|
||||
nextAvailableTurn: currentYearMonth + 1,
|
||||
},
|
||||
];
|
||||
request.observe!.generalCooldowns = [{ generalId: 1, actionName: '내정 특기 초기화' }];
|
||||
const reference = runReferenceTurnCommandTraceRequest(
|
||||
workspaceRoot!,
|
||||
request as unknown as Record<string, unknown>
|
||||
);
|
||||
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||
|
||||
expect(readGeneralCooldown(reference.before, 1, '내정 특기 초기화')).toBe(currentYearMonth + 1);
|
||||
expect(readGeneralCooldown(core.before, 1, '내정 특기 초기화')).toBe(currentYearMonth + 1);
|
||||
expect(reference.execution.outcome).toMatchObject({ completed: false });
|
||||
expect(core.execution.outcome).toMatchObject({
|
||||
requestedAction: 'che_내정특기초기화',
|
||||
actionKey: '휴식',
|
||||
usedFallback: true,
|
||||
blockedReason: '1턴 더 기다려야 합니다',
|
||||
});
|
||||
expect(reference.after.logs.some((entry) => String(entry.text).includes('1턴 더 기다려야 합니다'))).toBe(true);
|
||||
expect(core.after.logs.some((entry) => String(entry.text).includes('1턴 더 기다려야 합니다'))).toBe(true);
|
||||
expect(readGeneralCooldown(reference.after, 1, '내정 특기 초기화')).toBe(currentYearMonth + 1);
|
||||
expect(readGeneralCooldown(core.after, 1, '내정 특기 초기화')).toBe(currentYearMonth + 1);
|
||||
expect(core.rng).toEqual(reference.rng);
|
||||
expect(
|
||||
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||
})
|
||||
).toEqual([]);
|
||||
}, 120_000);
|
||||
|
||||
it('allows war trait reset exactly at the cooldown boundary', async () => {
|
||||
const currentYearMonth = 190 * 12 + 1 - 1;
|
||||
const request = buildRequest('che_전투특기초기화', undefined, {
|
||||
specialWar: 'che_귀병',
|
||||
lastTurn: { command: '전투 특기 초기화', term: 1 },
|
||||
});
|
||||
request.setup!.generalCooldowns = [
|
||||
{
|
||||
generalId: 1,
|
||||
actionName: '전투 특기 초기화',
|
||||
nextAvailableTurn: currentYearMonth,
|
||||
},
|
||||
];
|
||||
request.observe!.generalCooldowns = [{ generalId: 1, actionName: '전투 특기 초기화' }];
|
||||
const reference = runReferenceTurnCommandTraceRequest(
|
||||
workspaceRoot!,
|
||||
request as unknown as Record<string, unknown>
|
||||
);
|
||||
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||
const expectedNextAvailableTurn = currentYearMonth + 60 - 1;
|
||||
|
||||
expect(reference.execution.outcome).toMatchObject({ completed: true });
|
||||
expect(core.execution.outcome).toMatchObject({
|
||||
requestedAction: 'che_전투특기초기화',
|
||||
actionKey: 'che_전투특기초기화',
|
||||
usedFallback: false,
|
||||
});
|
||||
expect(readGeneralCooldown(reference.after, 1, '전투 특기 초기화')).toBe(expectedNextAvailableTurn);
|
||||
expect(readGeneralCooldown(core.after, 1, '전투 특기 초기화')).toBe(expectedNextAvailableTurn);
|
||||
expect(core.rng).toEqual(reference.rng);
|
||||
expect(
|
||||
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||
})
|
||||
).toEqual([]);
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
const missingTargetCases: Array<{ name: string; action: string; args: Record<string, unknown> }> = [
|
||||
{
|
||||
name: 'gift to a missing general',
|
||||
action: 'che_증여',
|
||||
args: { isGold: true, amount: 100, destGeneralID: 999 },
|
||||
},
|
||||
{
|
||||
name: 'spy on a missing city',
|
||||
action: 'che_첩보',
|
||||
args: { destCityID: 999 },
|
||||
},
|
||||
{
|
||||
name: 'move to a missing city',
|
||||
action: 'che_이동',
|
||||
args: { destCityID: 999 },
|
||||
},
|
||||
{
|
||||
name: 'employ a missing general',
|
||||
action: 'che_등용',
|
||||
args: { destGeneralID: 999 },
|
||||
},
|
||||
];
|
||||
|
||||
integration('general command missing-target fallback matrix', () => {
|
||||
it.each(missingTargetCases)(
|
||||
'$name rejects the missing target and falls back without command RNG',
|
||||
async ({ action, args }) => {
|
||||
const request = buildRequest(action, args);
|
||||
const reference = runReferenceTurnCommandTraceRequest(
|
||||
workspaceRoot!,
|
||||
request as unknown as Record<string, unknown>
|
||||
);
|
||||
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||
|
||||
expect(reference.execution.outcome).toMatchObject({ completed: false });
|
||||
expect(core.execution.outcome).toMatchObject({
|
||||
requestedAction: action,
|
||||
actionKey: '휴식',
|
||||
usedFallback: true,
|
||||
});
|
||||
expect(core.rng).toEqual(reference.rng);
|
||||
expect(
|
||||
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||
})
|
||||
).toEqual([]);
|
||||
},
|
||||
120_000
|
||||
);
|
||||
});
|
||||
|
||||
type GeneralConstraintCase = {
|
||||
name: string;
|
||||
action: string;
|
||||
|
||||
Reference in New Issue
Block a user