merge: complete GUI parity follow-up
This commit is contained in:
@@ -230,6 +230,12 @@ export const generalRouter = router({
|
||||
injury: true,
|
||||
experience: true,
|
||||
dedication: true,
|
||||
age: true,
|
||||
turnTime: true,
|
||||
crewTypeId: true,
|
||||
personalCode: true,
|
||||
specialCode: true,
|
||||
special2Code: true,
|
||||
weaponCode: true,
|
||||
horseCode: true,
|
||||
bookCode: true,
|
||||
@@ -309,6 +315,19 @@ export const generalRouter = router({
|
||||
injury: general.injury,
|
||||
experience: general.experience,
|
||||
dedication: general.dedication,
|
||||
age: general.age,
|
||||
turnTime: general.turnTime.toISOString(),
|
||||
crewTypeId: general.crewTypeId,
|
||||
traits: {
|
||||
personal: general.personalCode,
|
||||
specialWar: general.specialCode,
|
||||
specialDomestic: general.special2Code,
|
||||
},
|
||||
progression: {
|
||||
experienceLevel: readNumber(metaRecord.explevel, 0),
|
||||
dedicationLevel: readNumber(metaRecord.dedlevel, 0),
|
||||
dex: [1, 2, 3, 4, 5].map((index) => readNumber(metaRecord[`dex${index}`], 0)),
|
||||
},
|
||||
items: {
|
||||
horse: normalizeItemCode(general.horseCode),
|
||||
weapon: normalizeItemCode(general.weaponCode),
|
||||
@@ -468,12 +487,12 @@ export const generalRouter = router({
|
||||
ctx.db.logEntry.findMany({
|
||||
where: {
|
||||
scope: LogScope.SYSTEM,
|
||||
category: LogCategory.SUMMARY,
|
||||
category: { in: [LogCategory.SUMMARY, LogCategory.ACTION] },
|
||||
id: { gte: input.lastGeneralRecordId },
|
||||
},
|
||||
orderBy: { id: 'desc' },
|
||||
take,
|
||||
select: { id: true, text: true },
|
||||
select: { id: true, text: true, createdAt: true },
|
||||
}),
|
||||
ctx.db.logEntry.findMany({
|
||||
where: {
|
||||
@@ -484,7 +503,7 @@ export const generalRouter = router({
|
||||
},
|
||||
orderBy: { id: 'desc' },
|
||||
take,
|
||||
select: { id: true, text: true },
|
||||
select: { id: true, text: true, createdAt: true },
|
||||
}),
|
||||
ctx.db.logEntry.findMany({
|
||||
where: {
|
||||
@@ -494,7 +513,7 @@ export const generalRouter = router({
|
||||
},
|
||||
orderBy: { id: 'desc' },
|
||||
take,
|
||||
select: { id: true, text: true },
|
||||
select: { id: true, text: true, createdAt: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
@@ -186,9 +186,14 @@ const zRevealMode = z.enum(['after_vote', 'after_end']);
|
||||
export const voteRouter = router({
|
||||
getVoteList: authedProcedure.query(async ({ ctx }) => {
|
||||
const worldState = await ctx.db.worldState.findFirst();
|
||||
const worldMeta = asRecord(worldState?.meta ?? {});
|
||||
const config = asRecord(worldState?.config ?? {});
|
||||
const constValues = asRecord(config.const);
|
||||
const develCost = resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0);
|
||||
const develCost = resolveNumber(
|
||||
worldMeta,
|
||||
['develcost', 'develCost'],
|
||||
resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0)
|
||||
);
|
||||
const voteReward = develCost * 5;
|
||||
|
||||
const rows = await ctx.db.$queryRaw<VoteListRow[]>(GamePrisma.sql`
|
||||
@@ -404,12 +409,16 @@ export const voteRouter = router({
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state is not initialized.' });
|
||||
}
|
||||
|
||||
const worldMeta = asRecord(worldState.meta);
|
||||
const config = asRecord(worldState.config);
|
||||
const constValues = asRecord(config.const);
|
||||
const develCost = resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0);
|
||||
const develCost = resolveNumber(
|
||||
worldMeta,
|
||||
['develcost', 'develCost'],
|
||||
resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0)
|
||||
);
|
||||
const voteReward = develCost * 5;
|
||||
|
||||
const worldMeta = asRecord(worldState.meta);
|
||||
const scenarioMeta = asRecord(worldMeta.scenarioMeta);
|
||||
const startYear = readMetaNumber(scenarioMeta, 'startYear', worldState.currentYear);
|
||||
const initYear = readMetaNumber(worldMeta, 'initYear', startYear);
|
||||
|
||||
@@ -241,10 +241,10 @@ describe('in-game my information ownership', () => {
|
||||
expect(fixture.db.logEntry.findMany).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
where: { scope: 'SYSTEM', category: 'SUMMARY', id: { gte: 0 } },
|
||||
where: { scope: 'SYSTEM', category: { in: ['SUMMARY', 'ACTION'] }, id: { gte: 0 } },
|
||||
orderBy: { id: 'desc' },
|
||||
take: 16,
|
||||
select: { id: true, text: true },
|
||||
select: { id: true, text: true, createdAt: true },
|
||||
})
|
||||
);
|
||||
expect(fixture.db.logEntry.findMany).toHaveBeenNthCalledWith(
|
||||
@@ -253,7 +253,7 @@ describe('in-game my information ownership', () => {
|
||||
where: { scope: 'GENERAL', category: 'ACTION', generalId: 7, id: { gte: 0 } },
|
||||
orderBy: { id: 'desc' },
|
||||
take: 16,
|
||||
select: { id: true, text: true },
|
||||
select: { id: true, text: true, createdAt: true },
|
||||
})
|
||||
);
|
||||
expect(fixture.db.logEntry.findMany).toHaveBeenNthCalledWith(
|
||||
@@ -262,7 +262,7 @@ describe('in-game my information ownership', () => {
|
||||
where: { scope: 'SYSTEM', category: 'HISTORY', id: { gte: 0 } },
|
||||
orderBy: { id: 'desc' },
|
||||
take: 16,
|
||||
select: { id: true, text: true },
|
||||
select: { id: true, text: true, createdAt: true },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -24,13 +24,13 @@ const auth: GameSessionTokenPayload = {
|
||||
type LogQuery = {
|
||||
where: {
|
||||
scope: LogScope;
|
||||
category: LogCategory;
|
||||
category: LogCategory | { in: LogCategory[] };
|
||||
generalId?: number;
|
||||
id: { gte: number };
|
||||
};
|
||||
orderBy: { id: 'desc' };
|
||||
take: number;
|
||||
select: { id: true; text: true };
|
||||
select: { id: true; text: true; createdAt: true };
|
||||
};
|
||||
|
||||
const buildContext = (findMany: (query: LogQuery) => Promise<Array<{ id: number; text: string }>>) =>
|
||||
@@ -56,7 +56,7 @@ describe('general.getRecentRecords', () => {
|
||||
{ id: 20, text: '개인 cursor' },
|
||||
];
|
||||
}
|
||||
if (query.where.category === LogCategory.SUMMARY) {
|
||||
if (typeof query.where.category === 'object' && query.where.category.in.includes(LogCategory.SUMMARY)) {
|
||||
return [
|
||||
{ id: 32, text: '장수 최신' },
|
||||
{ id: 20, text: '장수 cursor' },
|
||||
@@ -89,7 +89,7 @@ describe('general.getRecentRecords', () => {
|
||||
},
|
||||
orderBy: { id: 'desc' },
|
||||
take: 16,
|
||||
select: { id: true, text: true },
|
||||
select: { id: true, text: true, createdAt: true },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -92,6 +92,7 @@ const buildContext = (options: {
|
||||
voteRows?: Array<{ selection: number[]; cnt: number }>;
|
||||
pollRow?: typeof poll;
|
||||
configConst?: Record<string, unknown>;
|
||||
metaDevelCost?: number;
|
||||
auctionTargets?: string[];
|
||||
}) => {
|
||||
const auth = options.auth === undefined ? buildAuth() : options.auth;
|
||||
@@ -136,6 +137,7 @@ const buildContext = (options: {
|
||||
tickSeconds: 3600,
|
||||
config: { const: { develCost: 18, allItems: {}, ...(options.configConst ?? {}) } },
|
||||
meta: {
|
||||
...(options.metaDevelCost === undefined ? {} : { develcost: options.metaDevelCost }),
|
||||
hiddenSeed: 'seed',
|
||||
scenarioId: 200,
|
||||
initYear: 180,
|
||||
@@ -216,6 +218,16 @@ describe('vote router actor and permission boundaries', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the current world develcost for the legacy five-times survey reward', async () => {
|
||||
const fixture = buildContext({ metaDevelCost: 30, configConst: { develCost: 0 } });
|
||||
|
||||
await expect(appRouter.createCaller(fixture.context).vote.getVoteList()).resolves.toMatchObject({
|
||||
voteReward: 150,
|
||||
});
|
||||
await appRouter.createCaller(fixture.context).vote.submitVote({ voteId: 1, selection: [0] });
|
||||
expect(fixture.requestCommand).toHaveBeenCalledWith(expect.objectContaining({ goldReward: 150 }));
|
||||
});
|
||||
|
||||
it('includes active unique auctions in the API-side reward expectation', async () => {
|
||||
const fixture = buildContext({
|
||||
configConst: {
|
||||
|
||||
@@ -50,6 +50,16 @@ const d징병 = 2;
|
||||
const d직전 = 3;
|
||||
const d전쟁 = 4;
|
||||
|
||||
export const selectNpcMessageForTurn = (
|
||||
message: unknown,
|
||||
rng: Pick<RandUtil, 'nextBool'>,
|
||||
frequencyPerDay: number,
|
||||
turnTermMinutes: number
|
||||
): string | null => {
|
||||
if (!message) return null;
|
||||
return rng.nextBool((frequencyPerDay * turnTermMinutes) / (60 * 24)) ? String(message) : null;
|
||||
};
|
||||
|
||||
export const resolveLegacyAiStats = (
|
||||
general: Pick<TurnGeneral, 'injury' | 'officerLevel' | 'stats'>,
|
||||
nation: Nation | null | undefined,
|
||||
@@ -97,6 +107,7 @@ export class GeneralAI {
|
||||
public readonly env: ConstraintEnv;
|
||||
public readonly startYear: number;
|
||||
public readonly turnTermMinutes: number;
|
||||
private pendingNpcMessage: string | null = null;
|
||||
|
||||
public readonly aiConst: {
|
||||
baseGold: number;
|
||||
@@ -213,9 +224,16 @@ export class GeneralAI {
|
||||
return (...args: unknown[]) => {
|
||||
const result = Reflect.apply(value, receiver, args);
|
||||
if (
|
||||
['nextFloat1', 'nextRangeInt', 'nextInt', 'nextBit', 'nextBool', 'choice', 'choiceUsingWeight', 'choiceUsingWeightPair'].includes(
|
||||
String(property)
|
||||
)
|
||||
[
|
||||
'nextFloat1',
|
||||
'nextRangeInt',
|
||||
'nextInt',
|
||||
'nextBit',
|
||||
'nextBool',
|
||||
'choice',
|
||||
'choiceUsingWeight',
|
||||
'choiceUsingWeightPair',
|
||||
].includes(String(property))
|
||||
) {
|
||||
process.stdout.write(
|
||||
`AI_RNG_TRACE ${JSON.stringify({
|
||||
@@ -330,11 +348,7 @@ export class GeneralAI {
|
||||
// Ref refreshes the cached AI state after these selected nation
|
||||
// commands, before choosing the general command with the same
|
||||
// RNG. The refresh includes another mixed-general type draw.
|
||||
if (
|
||||
['유저장긴급포상', 'NPC긴급포상', '선전포고', '천도'].includes(
|
||||
actionName
|
||||
)
|
||||
) {
|
||||
if (['유저장긴급포상', 'NPC긴급포상', '선전포고', '천도'].includes(actionName)) {
|
||||
this.reqUpdateInstance = true;
|
||||
}
|
||||
return result;
|
||||
@@ -377,6 +391,12 @@ export class GeneralAI {
|
||||
return { set, unset };
|
||||
}
|
||||
|
||||
consumeNpcMessage(): string | null {
|
||||
const message = this.pendingNpcMessage;
|
||||
this.pendingNpcMessage = null;
|
||||
return message;
|
||||
}
|
||||
|
||||
chooseGeneralTurn(reservedTurn: ReservedTurnEntry): AiCommandCandidate | null {
|
||||
this.updateInstance();
|
||||
if (!this.worldRef) {
|
||||
@@ -384,10 +404,12 @@ export class GeneralAI {
|
||||
}
|
||||
|
||||
const generalMeta = asRecord(this.general.meta);
|
||||
const npcMessage = generalMeta.npcmsg ?? generalMeta.text;
|
||||
if (npcMessage && this.rng.nextBool((this.aiConst.npcMessageFreqByDay * this.turnTermMinutes) / (60 * 24))) {
|
||||
// 메시지 영속화는 turn handler가 담당한다. 여기서는 레거시와 같은 RNG 소비를 보존한다.
|
||||
}
|
||||
this.pendingNpcMessage = selectNpcMessageForTurn(
|
||||
generalMeta.npcmsg ?? generalMeta.text,
|
||||
this.rng,
|
||||
this.aiConst.npcMessageFreqByDay,
|
||||
this.turnTermMinutes
|
||||
);
|
||||
|
||||
if (this.general.npcState >= 2) {
|
||||
this.general.meta = { ...this.general.meta, defence_train: 80 };
|
||||
|
||||
@@ -1048,7 +1048,10 @@ export const createReservedTurnHandler = async (options: {
|
||||
}
|
||||
const actionContext = specificContext ?? baseContext;
|
||||
if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(currentGeneral.id))) {
|
||||
const tracedContext = actionContext as ActionContextBase & { destCity?: City; destGeneral?: TurnGeneral };
|
||||
const tracedContext = actionContext as ActionContextBase & {
|
||||
destCity?: City;
|
||||
destGeneral?: TurnGeneral;
|
||||
};
|
||||
process.stdout.write(
|
||||
`AI_ACTION_INPUT_TRACE ${JSON.stringify({ generalId: currentGeneral.id, kind, actionKey, actionArgs, destCityId: tracedContext.destCity?.id, destGeneralId: tracedContext.destGeneral?.id })}\n`
|
||||
);
|
||||
@@ -1731,6 +1734,26 @@ export const createReservedTurnHandler = async (options: {
|
||||
nationFallback,
|
||||
});
|
||||
const candidate = ai.chooseGeneralTurn(generalCommand);
|
||||
const npcMessage = ai.consumeNpcMessage();
|
||||
if (npcMessage) {
|
||||
const messageTarget = {
|
||||
generalId: currentGeneral.id,
|
||||
generalName: currentGeneral.name,
|
||||
nationId: currentGeneral.nationId,
|
||||
nationName: currentNation?.name ?? '재야',
|
||||
color: currentNation?.color ?? '#000000',
|
||||
icon: currentGeneral.picture ?? '',
|
||||
};
|
||||
messages.push({
|
||||
msgType: 'public',
|
||||
src: messageTarget,
|
||||
dest: messageTarget,
|
||||
text: npcMessage,
|
||||
time: new Date(context.world.lastTurnTime),
|
||||
validUntil: new Date('9999-12-31T00:00:00.000Z'),
|
||||
option: {},
|
||||
});
|
||||
}
|
||||
if (candidate) {
|
||||
generalAutorunMode =
|
||||
candidate.action !== generalCommand.action ||
|
||||
|
||||
@@ -117,7 +117,7 @@ describe('NPC 일반 내정 턴', () => {
|
||||
specialWar: null,
|
||||
},
|
||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
meta: { killturn: 999 },
|
||||
meta: { killturn: 999, text: '기부는 저처럼 돈 많은 사람들이 많이 하면 됩니다' },
|
||||
officerLevel: 4,
|
||||
experience: 0,
|
||||
dedication: 0,
|
||||
@@ -222,7 +222,7 @@ describe('NPC 일반 내정 턴', () => {
|
||||
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
|
||||
iconPath: '',
|
||||
map: {},
|
||||
const: {},
|
||||
const: { npcMessageFreqByDay: 144 },
|
||||
environment: { mapName: 'npc_domestic_map', unitSet: 'default' },
|
||||
},
|
||||
scenarioMeta: {
|
||||
@@ -291,5 +291,12 @@ describe('NPC 일반 내정 턴', () => {
|
||||
security: 1063,
|
||||
});
|
||||
expect(world.getGeneralById(1)!.turnTime.getTime()).toBe(addMinutes(mockDate, 10).getTime());
|
||||
expect(world.peekDirtyState().messages).toContainEqual(
|
||||
expect.objectContaining({
|
||||
msgType: 'public',
|
||||
text: '기부는 저처럼 돈 많은 사람들이 많이 하면 됩니다',
|
||||
src: expect.objectContaining({ generalId: 1, generalName: 'NPC_무장', nationId: 1 }),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { selectNpcMessageForTurn } from '../src/turn/ai/generalAi/core.js';
|
||||
|
||||
describe('legacy NPC public chatter', () => {
|
||||
it('uses the per-turn legacy probability and returns the scenario text', () => {
|
||||
const nextBool = vi.fn(() => true);
|
||||
|
||||
expect(selectNpcMessageForTurn('기부는 저처럼 돈 많은 사람들이 많이 하면 됩니다', { nextBool }, 2, 10)).toBe(
|
||||
'기부는 저처럼 돈 많은 사람들이 많이 하면 됩니다'
|
||||
);
|
||||
expect(nextBool).toHaveBeenCalledWith(2 / 144);
|
||||
});
|
||||
|
||||
it('does not consume RNG when a scenario NPC has no message', () => {
|
||||
const nextBool = vi.fn(() => true);
|
||||
expect(selectNpcMessageForTurn(null, { nextBool }, 2, 10)).toBeNull();
|
||||
expect(nextBool).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -29,37 +29,52 @@ const props = defineProps<{
|
||||
</div>
|
||||
<div v-else-if="!props.city" class="empty">도시 정보를 불러오지 못했습니다.</div>
|
||||
<div v-else class="city-body">
|
||||
<div class="title">{{ props.city.name }} (Lv {{ props.city.level }})</div>
|
||||
<div class="title">
|
||||
{{ props.city.name }} (Lv {{ props.city.level }}) · 국가 {{ props.city.nationId || '무주' }}
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div>인구 {{ props.city.population }}</div>
|
||||
<div>농업 {{ props.city.agriculture }}</div>
|
||||
<div>상업 {{ props.city.commerce }}</div>
|
||||
<div>치안 {{ props.city.security }}</div>
|
||||
<div>방어 {{ props.city.defence }}</div>
|
||||
<div>성벽 {{ props.city.wall }}</div>
|
||||
<div>보급 {{ props.city.supplyState }}</div>
|
||||
<div>전방 {{ props.city.frontState }}</div>
|
||||
<span>인구</span><strong>{{ props.city.population.toLocaleString() }}</strong> <span>농업</span
|
||||
><strong>{{ props.city.agriculture.toLocaleString() }}</strong> <span>상업</span
|
||||
><strong>{{ props.city.commerce.toLocaleString() }}</strong> <span>치안</span
|
||||
><strong>{{ props.city.security.toLocaleString() }}</strong> <span>수비</span
|
||||
><strong>{{ props.city.defence.toLocaleString() }}</strong> <span>성벽</span
|
||||
><strong>{{ props.city.wall.toLocaleString() }}</strong> <span>보급</span
|
||||
><strong>{{ props.city.supplyState }}</strong> <span>전방</span
|
||||
><strong>{{ props.city.frontState }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.city-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.title {
|
||||
min-height: 24px;
|
||||
padding: 2px 6px;
|
||||
border-bottom: 1px solid #666;
|
||||
background: #173d27;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(90px, 1fr));
|
||||
gap: 6px;
|
||||
font-size: 0.85rem;
|
||||
grid-template-columns: repeat(8, minmax(0, 1fr));
|
||||
font-size: 12px;
|
||||
}
|
||||
.grid > * {
|
||||
min-height: 23px;
|
||||
box-sizing: border-box;
|
||||
border-right: 1px solid #666;
|
||||
border-bottom: 1px solid #666;
|
||||
padding: 2px 5px;
|
||||
}
|
||||
.grid > span {
|
||||
background: rgb(20 75 42 / 70%);
|
||||
text-align: center;
|
||||
}
|
||||
.grid > strong {
|
||||
text-align: right;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.empty {
|
||||
|
||||
@@ -150,8 +150,7 @@ const clearNationTurn = (index: number) => {
|
||||
emit('set-nation-turn', { index, action: '휴식', args: {} });
|
||||
};
|
||||
|
||||
const canNationReserve = () =>
|
||||
Boolean(props.general && props.general.nationId > 0 && props.general.officerLevel >= 5);
|
||||
const canNationReserve = () => Boolean(props.general && props.general.nationId > 0 && props.general.officerLevel >= 5);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -160,7 +159,8 @@ const canNationReserve = () =>
|
||||
<div class="label">선택 도시</div>
|
||||
<div class="value">
|
||||
<span v-if="props.selectedCity">
|
||||
{{ props.selectedCity.name }} · {{ props.selectedCity.nationName }} · {{ props.selectedCity.regionName }}
|
||||
{{ props.selectedCity.name }} · {{ props.selectedCity.nationName }} ·
|
||||
{{ props.selectedCity.regionName }}
|
||||
</span>
|
||||
<span v-else>선택된 도시 없음</span>
|
||||
</div>
|
||||
@@ -260,24 +260,32 @@ const canNationReserve = () =>
|
||||
}
|
||||
|
||||
.command-selection {
|
||||
border: 1px solid rgba(201, 164, 90, 0.35);
|
||||
padding: 6px 8px;
|
||||
font-size: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
display: grid;
|
||||
grid-template-columns: 64px minmax(0, 1fr);
|
||||
min-height: 24px;
|
||||
border: 1px solid #666;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.command-selection .label {
|
||||
color: rgba(232, 221, 196, 0.6);
|
||||
padding: 2px 5px;
|
||||
background: #173d27;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
}
|
||||
.command-selection .value {
|
||||
overflow: hidden;
|
||||
padding: 2px 5px;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.command-selected {
|
||||
border: 1px solid rgba(201, 164, 90, 0.3);
|
||||
padding: 8px;
|
||||
border: 1px solid #666;
|
||||
padding: 3px 5px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font-size: 0.75rem;
|
||||
grid-template-columns: 64px minmax(0, 1fr);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.command-selected .label {
|
||||
|
||||
@@ -98,18 +98,8 @@ watch(selectedCategory, (value) => {
|
||||
}
|
||||
});
|
||||
|
||||
const statusLabel = (command: CommandAvailability) => {
|
||||
if (command.status === 'available') {
|
||||
return '가능';
|
||||
}
|
||||
if (command.status === 'needsInput') {
|
||||
return '입력 필요';
|
||||
}
|
||||
if (command.status === 'blocked') {
|
||||
return '불가';
|
||||
}
|
||||
return '확인 필요';
|
||||
};
|
||||
const commandTitle = (command: CommandAvailability) =>
|
||||
command.reason || (command.reqArg ? '대상을 선택하는 명령입니다.' : command.possible ? '실행 가능' : '실행 불가');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -140,10 +130,10 @@ const statusLabel = (command: CommandAvailability) => {
|
||||
command.status === 'blocked' ? 'blocked' : '',
|
||||
]"
|
||||
:disabled="!command.possible"
|
||||
:title="commandTitle(command)"
|
||||
@click="emit('select', command.key)"
|
||||
>
|
||||
<span class="command-name">{{ command.name }}</span>
|
||||
<span class="command-status">{{ statusLabel(command) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -154,49 +144,63 @@ const statusLabel = (command: CommandAvailability) => {
|
||||
.command-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
gap: 0;
|
||||
border-top: 1px solid #666;
|
||||
border-left: 1px solid #666;
|
||||
}
|
||||
|
||||
.category-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(90px, 1fr));
|
||||
gap: 6px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.category-btn {
|
||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
||||
padding: 6px 8px;
|
||||
font-size: 0.75rem;
|
||||
min-height: 24px;
|
||||
border: 0;
|
||||
border-right: 1px solid #666;
|
||||
border-bottom: 1px solid #666;
|
||||
padding: 2px 4px;
|
||||
background: #173d27;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.category-btn.active {
|
||||
background: rgba(201, 164, 90, 0.2);
|
||||
background: #28633f;
|
||||
color: #ffe38a;
|
||||
}
|
||||
|
||||
.command-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
||||
gap: 6px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.command-item {
|
||||
border: 1px solid rgba(201, 164, 90, 0.3);
|
||||
padding: 6px;
|
||||
min-height: 24px;
|
||||
border: 0;
|
||||
border-right: 1px solid #666;
|
||||
border-bottom: 1px solid #666;
|
||||
padding: 2px 5px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
text-align: left;
|
||||
font-size: 0.75rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #302016 var(--sammo-texture-walnut);
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.command-item.ok {
|
||||
border-color: rgba(201, 164, 90, 0.6);
|
||||
color: #d9f7df;
|
||||
}
|
||||
|
||||
.command-item.blocked {
|
||||
opacity: 0.5;
|
||||
color: #888;
|
||||
opacity: 0.72;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -204,11 +208,6 @@ const statusLabel = (command: CommandAvailability) => {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.command-status {
|
||||
font-size: 0.7rem;
|
||||
color: rgba(232, 221, 196, 0.6);
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: rgba(232, 221, 196, 0.6);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,11 @@ interface GeneralInfo {
|
||||
injury: number;
|
||||
experience: number;
|
||||
dedication: number;
|
||||
age?: number;
|
||||
turnTime?: string;
|
||||
crewTypeId?: number;
|
||||
traits?: { personal: string; specialWar: string; specialDomestic: string };
|
||||
progression?: { experienceLevel: number; dedicationLevel: number; dex: number[] };
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -36,61 +41,71 @@ const props = defineProps<{
|
||||
</div>
|
||||
<div v-else-if="!props.general" class="empty">장수 정보를 불러오지 못했습니다.</div>
|
||||
<div v-else class="general-body">
|
||||
<div class="general-header">
|
||||
<span class="name">{{ props.general.name }}</span>
|
||||
<span class="meta">ID {{ props.general.id }} · 관직 {{ props.general.officerLevel }}</span>
|
||||
<div class="general-title">
|
||||
{{ props.general.name }} · 관직 {{ props.general.officerLevel }} · {{ props.general.age ?? '-' }}세
|
||||
</div>
|
||||
<div class="stats">
|
||||
<div>통솔 {{ props.general.stats.leadership }}</div>
|
||||
<div>무력 {{ props.general.stats.strength }}</div>
|
||||
<div>지력 {{ props.general.stats.intelligence }}</div>
|
||||
</div>
|
||||
<div class="resources">
|
||||
<div>금 {{ props.general.gold }}</div>
|
||||
<div>쌀 {{ props.general.rice }}</div>
|
||||
<div>병 {{ props.general.crew }}</div>
|
||||
</div>
|
||||
<div class="status">
|
||||
<div>훈련 {{ props.general.train }}</div>
|
||||
<div>사기 {{ props.general.atmos }}</div>
|
||||
<div>부상 {{ props.general.injury }}</div>
|
||||
<div>경험 {{ props.general.experience }}</div>
|
||||
<div>공헌 {{ props.general.dedication }}</div>
|
||||
<div class="legacy-grid">
|
||||
<span>통솔</span><strong>{{ props.general.stats.leadership }}</strong> <span>무력</span
|
||||
><strong>{{ props.general.stats.strength }}</strong> <span>지력</span
|
||||
><strong>{{ props.general.stats.intelligence }}</strong> <span>자금</span
|
||||
><strong>{{ props.general.gold.toLocaleString() }}</strong> <span>군량</span
|
||||
><strong>{{ props.general.rice.toLocaleString() }}</strong> <span>병력</span
|
||||
><strong>{{ props.general.crew.toLocaleString() }}</strong> <span>훈련</span
|
||||
><strong>{{ props.general.train }}</strong> <span>사기</span><strong>{{ props.general.atmos }}</strong>
|
||||
<span>부상</span><strong>{{ props.general.injury }}</strong> <span>명망</span
|
||||
><strong
|
||||
>Lv {{ props.general.progression?.experienceLevel ?? 0 }} ({{ props.general.experience }})</strong
|
||||
>
|
||||
<span>계급</span
|
||||
><strong
|
||||
>Lv {{ props.general.progression?.dedicationLevel ?? 0 }} ({{ props.general.dedication }})</strong
|
||||
>
|
||||
<span>병종</span><strong>{{ props.general.crewTypeId || '-' }}</strong> <span>성격</span
|
||||
><strong>{{ props.general.traits?.personal ?? '-' }}</strong> <span>전투특기</span
|
||||
><strong>{{ props.general.traits?.specialWar ?? '-' }}</strong> <span>내정특기</span
|
||||
><strong>{{ props.general.traits?.specialDomestic ?? '-' }}</strong> <span>다음 턴</span
|
||||
><strong>{{ props.general.turnTime?.slice(11, 16) ?? '-' }}</strong>
|
||||
</div>
|
||||
<div class="dex">숙련도 {{ props.general.progression?.dex?.join(' / ') ?? '0 / 0 / 0 / 0 / 0' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.general-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
.general-title {
|
||||
min-height: 24px;
|
||||
padding: 2px 6px;
|
||||
border-bottom: 1px solid #777;
|
||||
background: #173d27;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.general-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.general-header .name {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.general-header .meta {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(232, 221, 196, 0.7);
|
||||
}
|
||||
|
||||
.stats,
|
||||
.resources,
|
||||
.status {
|
||||
.legacy-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(90px, 1fr));
|
||||
gap: 6px;
|
||||
font-size: 0.85rem;
|
||||
grid-template-columns: repeat(8, minmax(0, 1fr));
|
||||
font-size: 12px;
|
||||
}
|
||||
.legacy-grid > * {
|
||||
min-height: 22px;
|
||||
box-sizing: border-box;
|
||||
border-right: 1px solid #666;
|
||||
border-bottom: 1px solid #666;
|
||||
padding: 2px 4px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.legacy-grid > span {
|
||||
background: rgb(20 75 42 / 70%);
|
||||
text-align: center;
|
||||
}
|
||||
.legacy-grid > strong {
|
||||
text-align: right;
|
||||
font-weight: 400;
|
||||
}
|
||||
.dex {
|
||||
padding: 3px 6px;
|
||||
font-size: 12px;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.empty {
|
||||
|
||||
@@ -36,8 +36,9 @@ defineProps<{
|
||||
|
||||
<style scoped>
|
||||
.front-status {
|
||||
width: calc(100% + 48px);
|
||||
margin-left: -24px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
background-color: #302016;
|
||||
background-image: var(--sammo-texture-walnut);
|
||||
color: #fff;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type NationNavigationAccess,
|
||||
} from './mainNavigation';
|
||||
import { useMenuPopup } from './useMenuPopup';
|
||||
import { legacyNationTextColor } from '../../utils/legacyNationColor';
|
||||
|
||||
const props = defineProps<{
|
||||
access: NationNavigationAccess;
|
||||
@@ -23,6 +24,7 @@ const isActive = (link: MainNavigationLinkItem) => link.highlightStage === props
|
||||
:ref="setRoot"
|
||||
class="main-nation-menu"
|
||||
:style="{ '--nation-menu-color': nationColor || '#000000' }"
|
||||
:class="{ 'dark-label': legacyNationTextColor(nationColor) === '#000000' }"
|
||||
aria-label="국가 메뉴"
|
||||
>
|
||||
<template v-for="entry in nationNavigation" :key="entry.id">
|
||||
@@ -83,6 +85,10 @@ const isActive = (link: MainNavigationLinkItem) => link.highlightStage === props
|
||||
background-color: var(--nation-menu-color);
|
||||
background-image: none;
|
||||
}
|
||||
.main-nation-menu.dark-label :deep(.main-menu-link),
|
||||
.main-nation-menu.dark-label .main-menu-button {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.nation-menu-split {
|
||||
position: relative;
|
||||
|
||||
@@ -280,18 +280,11 @@ const selectCity = (cityId: number) => {
|
||||
<div class="map-viewer">
|
||||
<div class="map-top">
|
||||
<div class="map-title">{{ mapSummary }}</div>
|
||||
<div class="map-controls">
|
||||
<button class="map-toggle" :class="{ active: showCityName }" @click="mapStore.toggleCityName">
|
||||
도시명 표기 {{ showCityName ? '끄기' : '켜기' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="props.loading">
|
||||
<SkeletonLines :lines="4" />
|
||||
</div>
|
||||
<div v-else-if="!props.mapData || !props.mapLayout" class="map-empty">
|
||||
지도 데이터를 불러오지 못했습니다.
|
||||
</div>
|
||||
<div v-else-if="!props.mapData || !props.mapLayout" class="map-empty">지도 데이터를 불러오지 못했습니다.</div>
|
||||
<div v-else ref="mapBody" class="map-body">
|
||||
<div
|
||||
ref="mapArea"
|
||||
@@ -324,14 +317,11 @@ const selectCity = (cityId: number) => {
|
||||
{{ hoveredCity.nationName }} · {{ hoveredCity.regionName }} · {{ hoveredCity.levelName }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="map-meta">
|
||||
<span>도시 {{ props.mapData.cityList.length }}</span>
|
||||
<span>세력 {{ props.mapData.nationList.length }}</span>
|
||||
<span>테마 {{ props.mapLayout.mapName }}</span>
|
||||
</div>
|
||||
<div class="map-footnote">
|
||||
좌표/도시명은 시나리오 맵 레이아웃을 기준으로 표시됩니다.
|
||||
<div class="map-controls">
|
||||
<button class="map-toggle" :class="{ active: showCityName }" @click="mapStore.toggleCityName">
|
||||
도시명 표기 {{ showCityName ? '끄기' : '켜기' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -423,19 +413,6 @@ const selectCity = (cityId: number) => {
|
||||
color: rgba(232, 221, 196, 0.6);
|
||||
}
|
||||
|
||||
.map-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
font-size: 0.75rem;
|
||||
color: rgba(232, 221, 196, 0.6);
|
||||
}
|
||||
|
||||
.map-footnote {
|
||||
font-size: 0.65rem;
|
||||
color: rgba(232, 221, 196, 0.5);
|
||||
}
|
||||
|
||||
.map-empty {
|
||||
color: rgba(232, 221, 196, 0.6);
|
||||
}
|
||||
|
||||
@@ -377,7 +377,8 @@ const forwardResponse = (messageId: number, response: boolean) => {
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
min-height: 22px;
|
||||
min-height: 0;
|
||||
padding: 2px 7px;
|
||||
}
|
||||
|
||||
.MessageList {
|
||||
@@ -426,7 +427,7 @@ const forwardResponse = (messageId: number, response: boolean) => {
|
||||
}
|
||||
|
||||
.MessageList {
|
||||
height: 650px;
|
||||
max-height: 650px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import SkeletonLines from '../ui/SkeletonLines.vue';
|
||||
import { legacyNationTextColor } from '../../utils/legacyNationColor';
|
||||
|
||||
interface NationInfo {
|
||||
id: number;
|
||||
@@ -26,46 +27,50 @@ const props = defineProps<{
|
||||
</div>
|
||||
<div v-else-if="!props.nation" class="empty">국가 정보를 불러오지 못했습니다.</div>
|
||||
<div v-else class="nation-body">
|
||||
<div class="title">
|
||||
<span class="color" :style="{ backgroundColor: props.nation.color }" />
|
||||
<div
|
||||
class="title"
|
||||
:style="{ backgroundColor: props.nation.color, color: legacyNationTextColor(props.nation.color) }"
|
||||
>
|
||||
{{ props.nation.name }} (Lv {{ props.nation.level }})
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div>국고 {{ props.nation.gold }}</div>
|
||||
<div>국량 {{ props.nation.rice }}</div>
|
||||
<div>기술 {{ props.nation.tech }}</div>
|
||||
<div>체제 {{ props.nation.typeCode }}</div>
|
||||
<div>수도 {{ props.nation.capitalCityId ?? '-' }}</div>
|
||||
<span>국고</span><strong>{{ props.nation.gold.toLocaleString() }}</strong> <span>국량</span
|
||||
><strong>{{ props.nation.rice.toLocaleString() }}</strong> <span>기술</span
|
||||
><strong>{{ props.nation.tech.toLocaleString() }}</strong> <span>체제</span
|
||||
><strong>{{ props.nation.typeCode }}</strong> <span>수도</span
|
||||
><strong>{{ props.nation.capitalCityId ?? '-' }}</strong> <span>국가 등급</span
|
||||
><strong>{{ props.nation.level }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.nation-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 24px;
|
||||
padding: 2px 6px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.color {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 1px solid rgba(232, 221, 196, 0.6);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(90px, 1fr));
|
||||
gap: 6px;
|
||||
font-size: 0.85rem;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
font-size: 12px;
|
||||
}
|
||||
.grid > * {
|
||||
min-height: 23px;
|
||||
box-sizing: border-box;
|
||||
border-right: 1px solid #666;
|
||||
border-bottom: 1px solid #666;
|
||||
padding: 2px 5px;
|
||||
}
|
||||
.grid > span {
|
||||
background: rgb(20 75 42 / 70%);
|
||||
text-align: center;
|
||||
}
|
||||
.grid > strong {
|
||||
text-align: right;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.empty {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, watch } from 'vue';
|
||||
import { EditorContent, useEditor } from '@tiptap/vue-3';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Underline from '@tiptap/extension-underline';
|
||||
import Link from '@tiptap/extension-link';
|
||||
|
||||
const props = withDefaults(defineProps<{ modelValue: string; maxLength?: number }>(), { maxLength: 16384 });
|
||||
const emit = defineEmits<{ (event: 'update:modelValue', value: string): void }>();
|
||||
|
||||
const editor = useEditor({
|
||||
content: props.modelValue,
|
||||
extensions: [StarterKit, Underline, Link.configure({ openOnClick: false })],
|
||||
editorProps: {
|
||||
attributes: { class: 'legacy-html-editor__content', 'aria-label': 'HTML 편집기' },
|
||||
},
|
||||
onUpdate: ({ editor: instance }) => {
|
||||
const html = instance.getHTML();
|
||||
if (html.length <= props.maxLength) emit('update:modelValue', html);
|
||||
},
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
if (editor.value && editor.value.getHTML() !== value) {
|
||||
editor.value.commands.setContent(value || '', { emitUpdate: false });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const setLink = () => {
|
||||
const previous = editor.value?.getAttributes('link').href as string | undefined;
|
||||
const href = window.prompt('링크 주소', previous ?? 'https://');
|
||||
if (href === null || !editor.value) return;
|
||||
if (!href.trim()) editor.value.chain().focus().unsetLink().run();
|
||||
else editor.value.chain().focus().extendMarkRange('link').setLink({ href: href.trim() }).run();
|
||||
};
|
||||
|
||||
onBeforeUnmount(() => editor.value?.destroy());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="legacy-html-editor">
|
||||
<div class="legacy-html-editor__toolbar" role="toolbar" aria-label="서식">
|
||||
<button
|
||||
type="button"
|
||||
:class="{ active: editor?.isActive('bold') }"
|
||||
@click="editor?.chain().focus().toggleBold().run()"
|
||||
>
|
||||
<b>굵게</b>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="{ active: editor?.isActive('italic') }"
|
||||
@click="editor?.chain().focus().toggleItalic().run()"
|
||||
>
|
||||
<i>기울임</i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="{ active: editor?.isActive('underline') }"
|
||||
@click="editor?.chain().focus().toggleUnderline().run()"
|
||||
>
|
||||
<u>밑줄</u>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="{ active: editor?.isActive('bulletList') }"
|
||||
@click="editor?.chain().focus().toggleBulletList().run()"
|
||||
>
|
||||
목록
|
||||
</button>
|
||||
<button type="button" :class="{ active: editor?.isActive('link') }" @click="setLink">링크</button>
|
||||
<button type="button" @click="editor?.chain().focus().unsetAllMarks().clearNodes().run()">
|
||||
서식 지우기
|
||||
</button>
|
||||
</div>
|
||||
<EditorContent :editor="editor" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.legacy-html-editor {
|
||||
border: 1px solid #777;
|
||||
background: #fff;
|
||||
color: #111;
|
||||
}
|
||||
.legacy-html-editor__toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
border-bottom: 1px solid #aaa;
|
||||
padding: 3px;
|
||||
background: #ddd;
|
||||
}
|
||||
.legacy-html-editor__toolbar button {
|
||||
border: 1px solid #777;
|
||||
border-radius: 2px;
|
||||
padding: 2px 7px;
|
||||
background: #f5f5f5;
|
||||
color: #111;
|
||||
cursor: pointer;
|
||||
}
|
||||
.legacy-html-editor__toolbar button.active {
|
||||
background: #b9d4f0;
|
||||
}
|
||||
:deep(.legacy-html-editor__content) {
|
||||
min-height: 110px;
|
||||
padding: 6px;
|
||||
outline: none;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
:deep(.legacy-html-editor__content p) {
|
||||
margin: 0 0 0.4em;
|
||||
}
|
||||
</style>
|
||||
@@ -457,7 +457,7 @@ onMounted(() => {
|
||||
.log-block {
|
||||
border: 1px solid #666;
|
||||
padding: 0;
|
||||
background: #111;
|
||||
background: #000;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@@ -469,7 +469,7 @@ onMounted(() => {
|
||||
justify-content: center;
|
||||
border-bottom: 1px solid #666;
|
||||
color: orange;
|
||||
background: #252525;
|
||||
background: #000;
|
||||
font-size: 1.3em;
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -479,6 +479,11 @@ onMounted(() => {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.log-line :deep(.hidden_but_copyable) {
|
||||
color: transparent !important;
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 2px 8px;
|
||||
color: #999;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import MapViewer from '../components/main/MapViewer.vue';
|
||||
import { trpc } from '../utils/trpc';
|
||||
import { legacyNationTextColor } from '../utils/legacyNationColor';
|
||||
|
||||
type Result = Awaited<ReturnType<typeof trpc.world.getGlobalInfo.query>>;
|
||||
type Layout = Awaited<ReturnType<typeof trpc.world.getMapLayout.query>>;
|
||||
@@ -14,17 +15,9 @@ const goBack = () => router.push('/');
|
||||
const state = (value: number) => ({ 0: '★', 1: '▲', 2: '', 7: '@' })[value] ?? 'ㆍ';
|
||||
const stateClass = (value: number) => `state-${value}`;
|
||||
const nationMap = computed(() => new Map(data.value?.nations.map((nation) => [nation.id, nation]) ?? []));
|
||||
const isBrightColor = (color: string): boolean => {
|
||||
const normalized = color.trim().replace(/^#/u, '');
|
||||
if (!/^[0-9a-f]{6}$/iu.test(normalized)) return false;
|
||||
const red = Number.parseInt(normalized.slice(0, 2), 16);
|
||||
const green = Number.parseInt(normalized.slice(2, 4), 16);
|
||||
const blue = Number.parseInt(normalized.slice(4, 6), 16);
|
||||
return red * 0.299 + green * 0.587 + blue * 0.114 > 170;
|
||||
};
|
||||
const nationNameStyle = (color: string) => ({
|
||||
backgroundColor: color,
|
||||
color: isBrightColor(color) ? '#000' : '#fff',
|
||||
color: legacyNationTextColor(color),
|
||||
});
|
||||
onMounted(async () => {
|
||||
try {
|
||||
@@ -55,7 +48,7 @@ onMounted(async () => {
|
||||
v-for="nation in data.nations"
|
||||
:key="nation.id"
|
||||
class="vertical"
|
||||
:style="{ backgroundColor: nation.color }"
|
||||
:style="nationNameStyle(nation.color)"
|
||||
>
|
||||
{{ nation.name }}
|
||||
</th>
|
||||
@@ -63,7 +56,7 @@ onMounted(async () => {
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="me in data.nations" :key="me.id">
|
||||
<th :style="{ backgroundColor: me.color }">{{ me.name }}</th>
|
||||
<th :style="nationNameStyle(me.color)">{{ me.name }}</th>
|
||||
<td
|
||||
v-for="you in data.nations"
|
||||
:key="you.id"
|
||||
@@ -93,7 +86,7 @@ onMounted(async () => {
|
||||
<strong>{{ conflict.cityName }}</strong>
|
||||
<div>
|
||||
<div v-for="(percent, id) in conflict.nations" :key="id" class="conflict-row">
|
||||
<span :style="{ backgroundColor: nationMap.get(Number(id))?.color }">{{
|
||||
<span :style="nationNameStyle(nationMap.get(Number(id))?.color ?? '#000000')">{{
|
||||
nationMap.get(Number(id))?.name
|
||||
}}</span
|
||||
><em>{{ percent.toFixed(1) }}%</em
|
||||
|
||||
@@ -199,6 +199,9 @@ const specialNameMap = computed(() => {
|
||||
}
|
||||
return map;
|
||||
});
|
||||
const selectedSpecialWarInfo = computed(
|
||||
() => status.value?.availableSpecialWar.find((entry) => entry.key === nextSpecialKey.value)?.info ?? ''
|
||||
);
|
||||
|
||||
const buffCost = (key: BuffKey, target: number): number => {
|
||||
const points = status.value?.inheritConst.inheritBuffPoints ?? [0, 0, 0, 0, 0, 0];
|
||||
@@ -493,7 +496,11 @@ onMounted(() => {
|
||||
</select>
|
||||
</div>
|
||||
<small
|
||||
>{{ specialNameMap.get(nextSpecialKey) }} 특기를 다음에 얻도록 지정합니다.<br /><b
|
||||
><span v-if="selectedSpecialWarInfo" class="special-description">{{
|
||||
selectedSpecialWarInfo
|
||||
}}</span
|
||||
><br v-if="selectedSpecialWarInfo" />{{ specialNameMap.get(nextSpecialKey) }} 특기를 다음에
|
||||
얻도록 지정합니다.<br /><b
|
||||
>필요 포인트: {{ status.inheritConst.inheritSpecificSpecialPoint }}</b
|
||||
></small
|
||||
>
|
||||
|
||||
@@ -65,6 +65,18 @@ const nationAccess = computed(() => ({
|
||||
}));
|
||||
const nationColor = computed(() => nation.value?.color ?? '#000000');
|
||||
const voteActive = computed(() => Boolean(frontStatus.value?.latestVote));
|
||||
const formatRecord = (entry: { text: string; createdAt?: string | Date }, appendTime = false): string => {
|
||||
if (!appendTime || /\d{2}:\d{2}\s*$/u.test(entry.text)) return formatLog(entry.text);
|
||||
const parsed = entry.createdAt ? new Date(entry.createdAt) : null;
|
||||
if (!parsed || Number.isNaN(parsed.getTime())) return formatLog(entry.text);
|
||||
const time = new Intl.DateTimeFormat('ko-KR', {
|
||||
timeZone: 'Asia/Seoul',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(parsed);
|
||||
return formatLog(`${entry.text} ${time}`);
|
||||
};
|
||||
|
||||
let surveyNoticeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
watch(surveyNotice, (notice) => {
|
||||
@@ -151,7 +163,9 @@ watch(
|
||||
>
|
||||
실시간 동기화: {{ realtimeLabel }}
|
||||
</button>
|
||||
<button class="game-shell__action game-shell__action--navigation" type="button" @click="loadMainData">갱 신</button>
|
||||
<button class="game-shell__action game-shell__action--navigation" type="button" @click="loadMainData">
|
||||
갱 신
|
||||
</button>
|
||||
<button class="game-shell__action" type="button" @click="moveLobby">로비로</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -241,7 +255,7 @@ watch(
|
||||
v-for="entry in globalRecords"
|
||||
:key="entry.id"
|
||||
class="record-line"
|
||||
v-html="formatLog(entry.text)"
|
||||
v-html="formatRecord(entry)"
|
||||
/>
|
||||
<div v-if="globalRecords.length === 0" class="record-empty">기록이 없습니다.</div>
|
||||
</div>
|
||||
@@ -255,7 +269,7 @@ watch(
|
||||
v-for="entry in generalRecords"
|
||||
:key="entry.id"
|
||||
class="record-line"
|
||||
v-html="formatLog(entry.text)"
|
||||
v-html="formatRecord(entry, true)"
|
||||
/>
|
||||
<div v-if="generalRecords.length === 0" class="record-empty">기록이 없습니다.</div>
|
||||
</div>
|
||||
@@ -269,7 +283,7 @@ watch(
|
||||
v-for="entry in worldHistory"
|
||||
:key="entry.id"
|
||||
class="record-line"
|
||||
v-html="formatLog(entry.text)"
|
||||
v-html="formatRecord(entry)"
|
||||
/>
|
||||
<div v-if="worldHistory.length === 0" class="record-empty">기록이 없습니다.</div>
|
||||
</div>
|
||||
@@ -350,7 +364,7 @@ watch(
|
||||
v-for="entry in globalRecords"
|
||||
:key="entry.id"
|
||||
class="record-line"
|
||||
v-html="formatLog(entry.text)"
|
||||
v-html="formatRecord(entry)"
|
||||
/>
|
||||
<div v-if="globalRecords.length === 0" class="record-empty">기록이 없습니다.</div>
|
||||
</div>
|
||||
@@ -364,7 +378,7 @@ watch(
|
||||
v-for="entry in generalRecords"
|
||||
:key="entry.id"
|
||||
class="record-line"
|
||||
v-html="formatLog(entry.text)"
|
||||
v-html="formatRecord(entry, true)"
|
||||
/>
|
||||
<div v-if="generalRecords.length === 0" class="record-empty">기록이 없습니다.</div>
|
||||
</div>
|
||||
@@ -378,7 +392,7 @@ watch(
|
||||
v-for="entry in worldHistory"
|
||||
:key="entry.id"
|
||||
class="record-line"
|
||||
v-html="formatLog(entry.text)"
|
||||
v-html="formatRecord(entry)"
|
||||
/>
|
||||
<div v-if="worldHistory.length === 0" class="record-empty">기록이 없습니다.</div>
|
||||
</div>
|
||||
@@ -624,7 +638,6 @@ button {
|
||||
|
||||
.desktop-message-panel {
|
||||
grid-column: 1 / -1;
|
||||
height: 1377.5px;
|
||||
}
|
||||
|
||||
.common-menu-middle {
|
||||
@@ -654,6 +667,11 @@ button {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.record-line :deep(.hidden_but_copyable) {
|
||||
color: transparent !important;
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.record-empty {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
@@ -386,20 +386,49 @@ onMounted(() => {
|
||||
<dt>경험/공헌</dt>
|
||||
<dd>{{ data.general.experience }} / {{ data.general.dedication }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>성격/특기</dt>
|
||||
<dd>
|
||||
{{ data.general.traits?.personal ?? '-' }} /
|
||||
{{ data.general.traits?.specialWar ?? '-' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>나이/다음턴</dt>
|
||||
<dd>{{ data.general.age ?? '-' }}세 / {{ data.general.turnTime?.slice(11, 16) ?? '-' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<div v-if="data" class="legacy-general-details">
|
||||
<div>
|
||||
명망 <strong>약간 ({{ data.general.experience }})</strong> · 계급
|
||||
<strong>약간 ({{ data.general.dedication }})</strong>
|
||||
명망
|
||||
<strong
|
||||
>Lv {{ data.general.progression?.experienceLevel ?? 0 }} ({{
|
||||
data.general.experience
|
||||
}})</strong
|
||||
>
|
||||
· 계급
|
||||
<strong
|
||||
>Lv {{ data.general.progression?.dedicationLevel ?? 0 }} ({{
|
||||
data.general.dedication
|
||||
}})</strong
|
||||
>
|
||||
</div>
|
||||
<div>전투 0 · 계략 0 · 사관 7년</div>
|
||||
<div>승률 0% · 승리 0 · 패배 0</div>
|
||||
<div>살상률 0% · 사살 0 · 피살 0</div>
|
||||
<div class="dexterity-title">숙련도</div>
|
||||
<div>보병 0.0K · 궁병 0.0K · 기병 0.0K · 귀병 0.0K · 차병 0.0K</div>
|
||||
<div>
|
||||
병종 {{ data.general.crew ? '보병' : '-' }} · 부상 {{ data.general.injury }} · 부대 - · 벌점 -
|
||||
보병 {{ data.general.progression?.dex?.[0] ?? 0 }} · 궁병
|
||||
{{ data.general.progression?.dex?.[1] ?? 0 }} · 기병
|
||||
{{ data.general.progression?.dex?.[2] ?? 0 }} · 귀병
|
||||
{{ data.general.progression?.dex?.[3] ?? 0 }} · 차병
|
||||
{{ data.general.progression?.dex?.[4] ?? 0 }}
|
||||
</div>
|
||||
<div>
|
||||
병종 {{ data.general.crewTypeId || '-' }} · 내정특기
|
||||
{{ data.general.traits?.specialDomestic ?? '-' }} · 부상 {{ data.general.injury }} · 부대
|
||||
{{ data.general.troopId || '-' }} · 벌점 {{ penalties.length || '-' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { getNpcColor } from '../utils/npcColor';
|
||||
import { legacyNationTextColor } from '../utils/legacyNationColor';
|
||||
import { cityLevelMap, regionMap } from '../utils/nationFormat';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
@@ -149,7 +150,14 @@ onMounted(async () => {
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colspan="10" class="city-title" :style="{ backgroundColor: data?.nation.color }">
|
||||
<td
|
||||
colspan="10"
|
||||
class="city-title"
|
||||
:style="{
|
||||
backgroundColor: data?.nation.color,
|
||||
color: legacyNationTextColor(data?.nation.color ?? '#000000'),
|
||||
}"
|
||||
>
|
||||
【 {{ regionMap[city.region] }} | {{ cityLevelMap[city.level] }} 】
|
||||
<span :class="{ capital: city.id === data?.nation.capitalCityId }">{{
|
||||
city.id === data?.nation.capitalCityId ? `[${city.name}]` : city.name
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { formatLog } from '../utils/formatLog';
|
||||
import { legacyNationTextColor } from '../utils/legacyNationColor';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
type Result = Awaited<ReturnType<typeof trpc.nation.getNationInfo.query>>;
|
||||
@@ -35,7 +36,11 @@ onMounted(async () => {
|
||||
<table v-if="data" class="legacy-table info-table legacy-bg2">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colspan="8" class="nation-title" :style="{ backgroundColor: data.nation.color }">
|
||||
<td
|
||||
colspan="8"
|
||||
class="nation-title"
|
||||
:style="{ backgroundColor: data.nation.color, color: legacyNationTextColor(data.nation.color) }"
|
||||
>
|
||||
【{{ data.nation.name }}】
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useRouter } from 'vue-router';
|
||||
import { resolveGeneralIconBackgroundImage } from '../utils/generalIcon';
|
||||
import { trpc } from '../utils/trpc';
|
||||
import { cityLevelMap, formatOfficerLevelText, getNationChiefLevel, regionMap } from '../utils/nationFormat';
|
||||
import { legacyNationTextColor } from '../utils/legacyNationColor';
|
||||
|
||||
type PersonnelResponse = Awaited<ReturnType<typeof trpc.nation.getPersonnelInfo.query>>;
|
||||
type GeneralEntry = PersonnelResponse['generals'][number];
|
||||
@@ -213,7 +214,10 @@ onMounted(() => void loadPersonnel());
|
||||
<td
|
||||
class="nation-heading"
|
||||
colspan="6"
|
||||
:style="{ color: '#fff', backgroundColor: data.nation.color }"
|
||||
:style="{
|
||||
color: legacyNationTextColor(data.nation.color),
|
||||
backgroundColor: data.nation.color,
|
||||
}"
|
||||
>
|
||||
【 {{ data.nation.name }} 】
|
||||
</td>
|
||||
@@ -415,10 +419,22 @@ onMounted(() => void loadPersonnel());
|
||||
<td colspan="5" class="region-heading">【 {{ regionMap[city.region] ?? '-' }} 】</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="nation-city" :style="{ backgroundColor: data.nation.color }">
|
||||
<td
|
||||
class="nation-city"
|
||||
:style="{
|
||||
backgroundColor: data.nation.color,
|
||||
color: legacyNationTextColor(data.nation.color),
|
||||
}"
|
||||
>
|
||||
【{{ cityLevelMap[city.level] ?? '-' }}】
|
||||
</td>
|
||||
<td class="nation-city city-name" :style="{ backgroundColor: data.nation.color }">
|
||||
<td
|
||||
class="nation-city city-name"
|
||||
:style="{
|
||||
backgroundColor: data.nation.color,
|
||||
color: legacyNationTextColor(data.nation.color),
|
||||
}"
|
||||
>
|
||||
{{ city.name }}
|
||||
</td>
|
||||
<td
|
||||
|
||||
@@ -3,6 +3,8 @@ import { computed, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { trpc } from '../utils/trpc';
|
||||
import { resolveDiplomacyInfo } from '../utils/diplomacy';
|
||||
import { legacyNationTextColor } from '../utils/legacyNationColor';
|
||||
import LegacyHtmlEditor from '../components/ui/LegacyHtmlEditor.vue';
|
||||
|
||||
type StratFinanResponse = Awaited<ReturnType<typeof trpc.nation.getStratFinan.query>>;
|
||||
type NationEntry = StratFinanResponse['nationsList'][number];
|
||||
@@ -194,7 +196,9 @@ onMounted(() => void loadStratFinan());
|
||||
<div>종료 시점</div>
|
||||
</div>
|
||||
<div v-for="nation in nationsList" :key="nation.id" class="diplomacy-row">
|
||||
<div :style="{ backgroundColor: nation.color }">{{ nation.name }}</div>
|
||||
<div :style="{ backgroundColor: nation.color, color: legacyNationTextColor(nation.color) }">
|
||||
{{ nation.name }}
|
||||
</div>
|
||||
<div>{{ formatNumber(nation.power) }}</div>
|
||||
<div>{{ formatNumber(nation.generalCount) }}</div>
|
||||
<div>{{ formatNumber(nation.cityCount) }}</div>
|
||||
@@ -245,7 +249,7 @@ onMounted(() => void loadStratFinan());
|
||||
</span>
|
||||
</header>
|
||||
<div v-if="!editingNationMsg" class="message-preview" v-html="nationMsg || '내용 없음'" />
|
||||
<textarea v-else v-model="nationMsgDraft" aria-label="국가 방침" maxlength="16384" />
|
||||
<LegacyHtmlEditor v-else v-model="nationMsgDraft" :max-length="16384" />
|
||||
</section>
|
||||
<section id="scout-message-form" class="message-form">
|
||||
<header class="green-header">
|
||||
@@ -279,7 +283,7 @@ onMounted(() => void loadStratFinan());
|
||||
</header>
|
||||
<div class="scout-limit">870px x 200px를 넘어서는 내용은 표시되지 않습니다.</div>
|
||||
<div v-if="!editingScoutMsg" class="message-preview scout-preview" v-html="scoutMsg || '내용 없음'" />
|
||||
<textarea v-else v-model="scoutMsgDraft" class="scout-editor" aria-label="임관 권유" maxlength="1000" />
|
||||
<LegacyHtmlEditor v-else v-model="scoutMsgDraft" :max-length="1000" />
|
||||
</section>
|
||||
|
||||
<div class="finance-title">예산&정책</div>
|
||||
|
||||
@@ -33,6 +33,7 @@ const safeSpanClasses = new Set([
|
||||
'war_type_attack',
|
||||
'war_type_defense',
|
||||
'war_type_siege',
|
||||
'hidden_but_copyable',
|
||||
]);
|
||||
|
||||
const escapeText = (value: string): string =>
|
||||
|
||||
@@ -39,6 +39,9 @@ describe('formatLegacyLogHtml', () => {
|
||||
expect(formatLegacyLogHtml('<span class="unknown">미허용</span>')).toBe(
|
||||
'<span class="unknown">미허용</span>'
|
||||
);
|
||||
expect(formatLegacyLogHtml('<span class="hidden_but_copyable">(전투시드: fixed-seed)</span>')).toBe(
|
||||
'<span class="hidden_but_copyable">(전투시드: fixed-seed)</span>'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the fixed hex color form emitted by flag-change logs but rejects other inline CSS', () => {
|
||||
|
||||
@@ -381,7 +381,7 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
|
||||
const attackerNationName = (attackerUnit.getNationVar('name') as string | null) ?? 'UNKNOWN';
|
||||
const attackerName = attackerUnit.getName();
|
||||
const cityName = cityUnit.getName();
|
||||
const seedText = input.seed ? `(전투시드: ${input.seed})` : '';
|
||||
const seedText = input.seed ? `<span class="hidden_but_copyable">(전투시드: ${input.seed})</span>` : '';
|
||||
|
||||
const josaRo = JosaUtil.pick(cityName, '로');
|
||||
const josaYi = JosaUtil.pick(attackerName, '이');
|
||||
|
||||
Reference in New Issue
Block a user