Merge remote-tracking branch 'origin/main' into feature/recruitment-command-parity-20260813
# Conflicts: # app/game-api/src/router/turns/index.ts # app/game-api/src/turns/commandInput.ts # app/game-frontend/e2e/commandArguments.spec.ts # app/game-frontend/src/components/command/ReservedCommandEditor.vue # app/game-frontend/src/components/command/types.ts # app/game-frontend/src/views/ChiefCenterView.vue
This commit is contained in:
@@ -137,7 +137,24 @@ export const tournamentRouter = router({
|
||||
store.getMatches(),
|
||||
store.getBettingEntries(),
|
||||
]);
|
||||
return { state, participants, matches, betCount: bets.length };
|
||||
const participantIds = [...new Set(participants.map((participant) => participant.id))];
|
||||
const iconRows =
|
||||
participantIds.length === 0
|
||||
? []
|
||||
: await ctx.db.general.findMany({
|
||||
where: { id: { in: participantIds } },
|
||||
select: { id: true, picture: true, imageServer: true },
|
||||
});
|
||||
const iconsByGeneralId = new Map(iconRows.map((general) => [general.id, general]));
|
||||
const publicParticipants = participants.map((participant) => {
|
||||
const icon = iconsByGeneralId.get(participant.id);
|
||||
return {
|
||||
...participant,
|
||||
picture: icon?.picture ?? null,
|
||||
imageServer: icon?.imageServer ?? 0,
|
||||
};
|
||||
});
|
||||
return { state, participants: publicParticipants, matches, betCount: bets.length };
|
||||
}),
|
||||
getRankings: authedProcedure.query(async ({ ctx }) => {
|
||||
await getMyGeneral(ctx);
|
||||
@@ -177,7 +194,16 @@ export const tournamentRouter = router({
|
||||
}
|
||||
const generals = await ctx.db.general.findMany({
|
||||
where: { id: { in: [...rankMap.keys()] } },
|
||||
select: { id: true, name: true, npcState: true, leadership: true, strength: true, intel: true },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
npcState: true,
|
||||
picture: true,
|
||||
imageServer: true,
|
||||
leadership: true,
|
||||
strength: true,
|
||||
intel: true,
|
||||
},
|
||||
});
|
||||
|
||||
return tournamentRankTypes.map((prefix) => {
|
||||
@@ -201,6 +227,8 @@ export const tournamentRouter = router({
|
||||
generalId: general.id,
|
||||
name: general.name,
|
||||
npcState: general.npcState,
|
||||
picture: general.picture,
|
||||
imageServer: general.imageServer,
|
||||
stat,
|
||||
games: win + draw + lose,
|
||||
win,
|
||||
|
||||
@@ -102,6 +102,24 @@ const resolveMapName = (worldState: WorldStateRow, fallback: string): string =>
|
||||
return typeof mapName === 'string' && mapName.trim().length > 0 ? mapName : fallback;
|
||||
};
|
||||
|
||||
const plainLegacyInfo = (value: string): string =>
|
||||
value
|
||||
.replace(/<br\s*\/?>/giu, ' · ')
|
||||
.replace(/<[^>]+>/gu, '')
|
||||
.replace(/\s+/gu, ' ')
|
||||
.trim();
|
||||
|
||||
const readGeneralMetaNumber = (meta: unknown, key: string): number | null => {
|
||||
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return null;
|
||||
const value = (meta as Record<string, unknown>)[key];
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const assertReservedTurnPermission = async (
|
||||
worldState: WorldStateRow,
|
||||
general: GeneralRow,
|
||||
@@ -183,7 +201,19 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
||||
};
|
||||
for (const item of moduleBundle.itemModules) {
|
||||
if (item.buyable) {
|
||||
items[item.slot].push({ value: item.key, label: item.name });
|
||||
const cost = item.cost ?? 0;
|
||||
const currentSecurity = city?.security ?? 0;
|
||||
const availability =
|
||||
currentSecurity < item.reqSecu
|
||||
? `현재 구입 불가: 치안 ${item.reqSecu.toLocaleString()} 필요`
|
||||
: general.gold < cost
|
||||
? `현재 구입 불가: 자금 ${cost.toLocaleString()} 필요`
|
||||
: '현재 구입 가능';
|
||||
items[item.slot].push({
|
||||
value: item.key,
|
||||
label: item.name,
|
||||
description: `${availability} · 가격 ${cost.toLocaleString()} · ${plainLegacyInfo(item.info)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
const inputOptions: TurnCommandInputOptions = {
|
||||
@@ -205,11 +235,19 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
||||
crewTypes: (environment.unitSet.crewTypes ?? [])
|
||||
.filter((entry) => !entry.requirements.some((requirement) => requirement.type === 'Impossible'))
|
||||
.map((entry) => ({ value: entry.id, label: entry.name })),
|
||||
armTypes: Object.entries(environment.unitSet.armTypes ?? {}).map(([value, label]) => ({
|
||||
value: Number(value),
|
||||
label,
|
||||
armTypes: Object.entries(environment.unitSet.armTypes ?? {}).map(([value, label]) => {
|
||||
const dexterity = readGeneralMetaNumber(general.meta, `dex${value}`);
|
||||
return {
|
||||
value: Number(value),
|
||||
label,
|
||||
...(dexterity === null ? {} : { description: `현재 숙련 ${dexterity.toLocaleString()}` }),
|
||||
};
|
||||
}),
|
||||
nationTypes: traits.nationTypes.map((entry) => ({
|
||||
value: entry.key,
|
||||
label: entry.name,
|
||||
description: plainLegacyInfo(entry.info),
|
||||
})),
|
||||
nationTypes: traits.nationTypes.map((entry) => ({ value: entry.key, label: entry.name })),
|
||||
colors: TURN_COMMAND_NATION_COLORS.map((color, index) => ({
|
||||
value: index,
|
||||
label: `색상 ${index + 1}`,
|
||||
@@ -226,6 +264,12 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
||||
unitSet: environment.unitSet,
|
||||
generalActionModules: moduleBundle.general,
|
||||
}),
|
||||
context: {
|
||||
actorGold: general.gold,
|
||||
actorRice: general.rice,
|
||||
...(city ? { citySecurity: city.security } : {}),
|
||||
...(nation ? { nationGold: nation.gold, nationRice: nation.rice, nationLevel: nation.level } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
return buildTurnCommandTable({
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface TurnCommandOption {
|
||||
value: TurnCommandOptionValue;
|
||||
label: string;
|
||||
color?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface TurnCommandRecruitmentCrewType {
|
||||
@@ -50,14 +51,7 @@ export interface TurnCommandRecruitmentInfo {
|
||||
}
|
||||
|
||||
export type TurnCommandOptionSource =
|
||||
| 'cities'
|
||||
| 'nations'
|
||||
| 'generals'
|
||||
| 'crewTypes'
|
||||
| 'armTypes'
|
||||
| 'nationTypes'
|
||||
| 'colors'
|
||||
| 'items';
|
||||
'cities' | 'nations' | 'generals' | 'crewTypes' | 'armTypes' | 'nationTypes' | 'colors' | 'items';
|
||||
|
||||
export interface TurnCommandInputField {
|
||||
key: string;
|
||||
@@ -83,14 +77,50 @@ export interface TurnCommandInputOptions {
|
||||
colors: TurnCommandOption[];
|
||||
items: Record<string, TurnCommandOption[]>;
|
||||
recruitment: TurnCommandRecruitmentInfo | null;
|
||||
context?: {
|
||||
actorGold: number;
|
||||
actorRice: number;
|
||||
citySecurity?: number;
|
||||
nationGold?: number;
|
||||
nationRice?: number;
|
||||
nationLevel?: number;
|
||||
};
|
||||
}
|
||||
|
||||
// 레거시 및 명령 실행 모듈의 인덱스 순서와 동일해야 한다.
|
||||
export const TURN_COMMAND_NATION_COLORS = [
|
||||
'#FF0000', '#800000', '#A0522D', '#FF6347', '#FFA500', '#FFDAB9', '#FFD700', '#FFFF00',
|
||||
'#7CFC00', '#00FF00', '#808000', '#008000', '#2E8B57', '#008080', '#20B2AA', '#6495ED',
|
||||
'#7FFFD4', '#AFEEEE', '#87CEEB', '#00FFFF', '#00BFFF', '#0000FF', '#000080', '#483D8B',
|
||||
'#7B68EE', '#BA55D3', '#800080', '#FF00FF', '#FFC0CB', '#F5F5DC', '#E0FFFF', '#FFFFFF',
|
||||
'#FF0000',
|
||||
'#800000',
|
||||
'#A0522D',
|
||||
'#FF6347',
|
||||
'#FFA500',
|
||||
'#FFDAB9',
|
||||
'#FFD700',
|
||||
'#FFFF00',
|
||||
'#7CFC00',
|
||||
'#00FF00',
|
||||
'#808000',
|
||||
'#008000',
|
||||
'#2E8B57',
|
||||
'#008080',
|
||||
'#20B2AA',
|
||||
'#6495ED',
|
||||
'#7FFFD4',
|
||||
'#AFEEEE',
|
||||
'#87CEEB',
|
||||
'#00FFFF',
|
||||
'#00BFFF',
|
||||
'#0000FF',
|
||||
'#000080',
|
||||
'#483D8B',
|
||||
'#7B68EE',
|
||||
'#BA55D3',
|
||||
'#800080',
|
||||
'#FF00FF',
|
||||
'#FFC0CB',
|
||||
'#F5F5DC',
|
||||
'#E0FFFF',
|
||||
'#FFFFFF',
|
||||
'#A9A9A9',
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -84,6 +84,8 @@ const buildGeneral = (id: number, userId: string, gold = 2_000): GeneralRow =>
|
||||
id,
|
||||
userId,
|
||||
name: `장수${id}`,
|
||||
picture: `${id}.jpg`,
|
||||
imageServer: id % 2,
|
||||
leadership: 70 + id,
|
||||
strength: 60 + id,
|
||||
intel: 50 + id,
|
||||
@@ -296,6 +298,10 @@ describe('tournament router permissions and mutations', () => {
|
||||
const sections = await ownerCaller.tournament.getRankings();
|
||||
expect(sections).toHaveLength(4);
|
||||
expect(sections[0]?.entries.map((entry) => entry.generalId)).toEqual([second.id, first.id]);
|
||||
expect(sections[0]?.entries[0]).toMatchObject({
|
||||
picture: '2.jpg',
|
||||
imageServer: 0,
|
||||
});
|
||||
|
||||
const generalLessCaller = appRouter.createCaller(
|
||||
buildContext({ redis, transport, generals: [first, second], userId: 'user-3', rankRows })
|
||||
@@ -303,6 +309,33 @@ describe('tournament router permissions and mutations', () => {
|
||||
await expect(generalLessCaller.tournament.getRankings()).rejects.toMatchObject({ code: 'NOT_FOUND' });
|
||||
});
|
||||
|
||||
it('joins current dedicated icon metadata to the public tournament snapshot', async () => {
|
||||
const redis = new MemoryRedis();
|
||||
const transport = new TournamentTransport();
|
||||
const owner = buildGeneral(11, 'user-1');
|
||||
const rival = buildGeneral(12, 'user-2');
|
||||
await setTournamentFixture(redis, {
|
||||
stage: 7,
|
||||
phase: 0,
|
||||
type: 0,
|
||||
auto: true,
|
||||
openYear: 193,
|
||||
openMonth: 1,
|
||||
termSeconds: 60,
|
||||
nextAt: '2026-07-26T01:00:00.000Z',
|
||||
});
|
||||
const caller = appRouter.createCaller(
|
||||
buildContext({ redis, transport, generals: [owner, rival], userId: 'user-1' })
|
||||
);
|
||||
|
||||
const snapshot = await caller.tournament.getSnapshot();
|
||||
|
||||
expect(snapshot.participants).toEqual([
|
||||
expect.objectContaining({ id: 11, picture: '11.jpg', imageServer: 1 }),
|
||||
expect.objectContaining({ id: 12, picture: '12.jpg', imageServer: 0 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('refunds gold when the tournament bet rank update fails', async () => {
|
||||
const redis = new MemoryRedis();
|
||||
const transport = new TournamentTransport();
|
||||
|
||||
Reference in New Issue
Block a user