fix: restore lint and test baseline

This commit is contained in:
2026-07-26 05:49:52 +00:00
parent d03227656b
commit fe45b9b7a5
22 changed files with 269 additions and 180 deletions
+1 -1
View File
@@ -43,7 +43,7 @@ export const runBattleSimWorker = async (): Promise<void> => {
continue; continue;
} }
let job: BattleSimJob | null = null; let job: BattleSimJob;
try { try {
job = JSON.parse(raw) as BattleSimJob; job = JSON.parse(raw) as BattleSimJob;
} catch { } catch {
+8 -6
View File
@@ -2,14 +2,12 @@ import { TRPCError } from '@trpc/server';
import { z } from 'zod'; import { z } from 'zod';
import { asRecord } from '@sammo-ts/common'; import { asRecord } from '@sammo-ts/common';
import { GamePrisma } from '@sammo-ts/infra'; import type { GamePrisma } from '@sammo-ts/infra';
import { authedProcedure, router } from '../../trpc.js'; import { authedProcedure, router } from '../../trpc.js';
import { getMyGeneral } from '../shared/general.js'; import { getMyGeneral } from '../shared/general.js';
import { assertNationAccess, resolveNationPermission } from '../nation/shared.js'; import { assertNationAccess, resolveNationPermission } from '../nation/shared.js';
const zLetterState = z.enum(['PROPOSED', 'ACTIVATED', 'CANCELLED', 'REPLACED']);
const resolvePermissionLevel = async (ctx: Parameters<typeof getMyGeneral>[0], nationId: number) => { const resolvePermissionLevel = async (ctx: Parameters<typeof getMyGeneral>[0], nationId: number) => {
const nation = await ctx.db.nation.findUnique({ const nation = await ctx.db.nation.findUnique({
where: { id: nationId }, where: { id: nationId },
@@ -22,7 +20,7 @@ const resolvePermissionLevel = async (ctx: Parameters<typeof getMyGeneral>[0], n
return resolveNationPermission(general, nation.meta, true); return resolveNationPermission(general, nation.meta, true);
}; };
const mapLetterState = (state: string): z.infer<typeof zLetterState> => { const mapLetterState = (state: string): 'PROPOSED' | 'ACTIVATED' | 'CANCELLED' | 'REPLACED' => {
if (state === 'ACTIVATED') return 'ACTIVATED'; if (state === 'ACTIVATED') return 'ACTIVATED';
if (state === 'CANCELLED') return 'CANCELLED'; if (state === 'CANCELLED') return 'CANCELLED';
if (state === 'REPLACED') return 'REPLACED'; if (state === 'REPLACED') return 'REPLACED';
@@ -153,7 +151,10 @@ export const diplomacyRouter = router({
select: { id: true }, select: { id: true },
}); });
if (newer) { if (newer) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '해당 문서에 대한 새로운 문서가 이미 있습니다.' }); throw new TRPCError({
code: 'BAD_REQUEST',
message: '해당 문서에 대한 새로운 문서가 이미 있습니다.',
});
} }
if (prevLetter.state === 'PROPOSED') { if (prevLetter.state === 'PROPOSED') {
@@ -169,7 +170,8 @@ export const diplomacyRouter = router({
}); });
} }
destNationId = prevLetter.srcNationId === me.nationId ? prevLetter.destNationId : prevLetter.srcNationId; destNationId =
prevLetter.srcNationId === me.nationId ? prevLetter.destNationId : prevLetter.srcNationId;
} }
const nations = await ctx.db.nation.findMany({ const nations = await ctx.db.nation.findMany({
+4 -4
View File
@@ -2,6 +2,7 @@ import { TRPCError } from '@trpc/server';
import { z } from 'zod'; import { z } from 'zod';
import { LogCategory, LogScope } from '@sammo-ts/infra'; import { LogCategory, LogScope } from '@sammo-ts/infra';
import type { GamePrisma } from '@sammo-ts/infra';
import { asRecord } from '@sammo-ts/common'; import { asRecord } from '@sammo-ts/common';
import { authedProcedure, router } from '../../trpc.js'; import { authedProcedure, router } from '../../trpc.js';
@@ -264,9 +265,8 @@ export const generalRouter = router({
const metaRecord = asRecord(general.meta); const metaRecord = asRecord(general.meta);
const prevSettings = asRecord(metaRecord.userSettings); const prevSettings = asRecord(metaRecord.userSettings);
const prevMyset = typeof prevSettings.myset === 'number' && Number.isFinite(prevSettings.myset) const prevMyset =
? prevSettings.myset typeof prevSettings.myset === 'number' && Number.isFinite(prevSettings.myset) ? prevSettings.myset : null;
: null;
const nextSettings = { const nextSettings = {
...prevSettings, ...prevSettings,
...input, ...input,
@@ -281,8 +281,8 @@ export const generalRouter = router({
meta: { meta: {
...metaRecord, ...metaRecord,
userSettings: nextSettings, userSettings: nextSettings,
} as GamePrisma.InputJsonValue,
}, },
} as any,
}); });
return { ok: true }; return { ok: true };
@@ -711,7 +711,7 @@ export class GeneralAI {
const leadership = this.general.stats.leadership; const leadership = this.general.stats.leadership;
const strength = Math.max(this.general.stats.strength, 1); const strength = Math.max(this.general.stats.strength, 1);
const intel = Math.max(this.general.stats.intelligence, 1); const intel = Math.max(this.general.stats.intelligence, 1);
let genType = 0; let genType: number;
if (strength >= intel) { if (strength >= intel) {
genType = t무장; genType = t무장;
@@ -38,7 +38,7 @@ export const do부대전방발령 = (ai: GeneralAI) => {
const force = ai.nationPolicy.combatForce[leader.id]; const force = ai.nationPolicy.combatForce[leader.id];
let [fromCityId, toCityId] = force; let [fromCityId, toCityId] = force;
let targetCityId: number | null = null; let targetCityId: number | null;
if (!ai.warRoute || !ai.warRoute[fromCityId] || ai.warRoute[fromCityId][toCityId] === undefined) { if (!ai.warRoute || !ai.warRoute[fromCityId] || ai.warRoute[fromCityId][toCityId] === undefined) {
targetCityId = pickRandomCityId(ai, ai.frontCities); targetCityId = pickRandomCityId(ai, ai.frontCities);
} else { } else {
+5 -8
View File
@@ -108,7 +108,7 @@ const applyIncomeOutcome = (
originOutcome: number originOutcome: number
): { next: number; ratio: number; realOutcome: number } => { ): { next: number; ratio: number; realOutcome: number } => {
let next = current + income; let next = current + income;
let realOutcome = 0; let realOutcome: number;
if (next < baseResource) { if (next < baseResource) {
realOutcome = 0; realOutcome = 0;
next = baseResource; next = baseResource;
@@ -139,14 +139,11 @@ const processIncomeForNation = (
const trait = traitMap.get(nation.typeCode) ?? null; const trait = traitMap.get(nation.typeCode) ?? null;
const incomeContext = buildNationIncomeContext(nation, trait); const incomeContext = buildNationIncomeContext(nation, trait);
let income = 0; const income =
if (type === 'gold') { type === 'gold'
income = getGoldIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level); ? getGoldIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level)
} else { : getRiceIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level) +
income =
getRiceIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level) +
getWallIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level); getWallIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level);
}
const incomeValue = roundResource(income); const incomeValue = roundResource(income);
const originOutcome = getOutcome(100, nationGenerals); const originOutcome = getOutcome(100, nationGenerals);
+15 -18
View File
@@ -167,7 +167,8 @@ export const createUnificationHandler = (options: {
sabotage, sabotage,
dex, dex,
unifier, unifier,
unifierAward: general.nationId === winnerNationId && general.officerLevel > 4 ? UNIFIER_POINT : 0, unifierAward:
general.nationId === winnerNationId && general.officerLevel > 4 ? UNIFIER_POINT : 0,
}, },
}, },
}); });
@@ -194,15 +195,11 @@ export const createUnificationHandler = (options: {
const meta = asRecord(state.meta); const meta = asRecord(state.meta);
const serverId = const serverId =
typeof meta.serverId === 'string' && meta.serverId.trim() typeof meta.serverId === 'string' && meta.serverId.trim() ? meta.serverId.trim() : options.profileName;
? meta.serverId.trim()
: options.profileName;
const season = readMetaNumberOrNull(meta, 'season') ?? 1; const season = readMetaNumberOrNull(meta, 'season') ?? 1;
const scenario = readMetaNumberOrNull(meta, 'scenarioId') ?? 0; const scenario = readMetaNumberOrNull(meta, 'scenarioId') ?? 0;
const scenarioName = const scenarioName =
typeof asRecord(meta.scenarioMeta).title === 'string' typeof asRecord(meta.scenarioMeta).title === 'string' ? String(asRecord(meta.scenarioMeta).title) : '';
? String(asRecord(meta.scenarioMeta).title)
: '';
const startTime = typeof meta.starttime === 'string' ? meta.starttime : null; const startTime = typeof meta.starttime === 'string' ? meta.starttime : null;
const unitedTime = new Date().toISOString(); const unitedTime = new Date().toISOString();
@@ -307,14 +304,16 @@ export const createUnificationHandler = (options: {
}; };
for (const [typeName, valueType] of hallTypes) { for (const [typeName, valueType] of hallTypes) {
let value = 0; const value =
if (valueType === 'natural') { valueType === 'natural'
value = typeName === 'experience' ? general.experience : typeName === 'dedication' ? general.dedication : ranks[typeName] ?? 0; ? typeName === 'experience'
} else if (valueType === 'rank') { ? general.experience
value = ranks[typeName] ?? 0; : typeName === 'dedication'
} else { ? general.dedication
value = calcValues[typeName] ?? 0; : (ranks[typeName] ?? 0)
} : valueType === 'rank'
? (ranks[typeName] ?? 0)
: (calcValues[typeName] ?? 0);
if ((typeName === 'winrate' || typeName === 'killrate') && warnum < 10) { if ((typeName === 'winrate' || typeName === 'killrate') && warnum < 10) {
continue; continue;
@@ -391,9 +390,7 @@ export const createUnificationHandler = (options: {
const meta = asRecord(state.meta); const meta = asRecord(state.meta);
const serverId = const serverId =
typeof meta.serverId === 'string' && meta.serverId.trim() typeof meta.serverId === 'string' && meta.serverId.trim() ? meta.serverId.trim() : options.profileName;
? meta.serverId.trim()
: options.profileName;
const serverName = const serverName =
typeof meta.serverName === 'string' && meta.serverName.trim() typeof meta.serverName === 'string' && meta.serverName.trim()
? meta.serverName.trim() ? meta.serverName.trim()
@@ -315,8 +315,8 @@ async function handleTournamentMatchResult(
const attackerG = getRankNumber(attacker, rankKey('g')); const attackerG = getRankNumber(attacker, rankKey('g'));
const defenderG = getRankNumber(defender, rankKey('g')); const defenderG = getRankNumber(defender, rankKey('g'));
let attackerGDelta = 0; let attackerGDelta: number;
let defenderGDelta = 0; let defenderGDelta: number;
let attackerW = 0; let attackerW = 0;
let attackerD = 0; let attackerD = 0;
let attackerL = 0; let attackerL = 0;
@@ -1,5 +1,6 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createGamePostgresConnector, GamePrisma, type GamePrismaClient } from '@sammo-ts/infra'; import { createGamePostgresConnector } from '@sammo-ts/infra';
import type { GamePrisma, GamePrismaClient } from '@sammo-ts/infra';
import { DatabaseTurnDaemonCommandQueue } from '../src/lifecycle/databaseCommandQueue.js'; import { DatabaseTurnDaemonCommandQueue } from '../src/lifecycle/databaseCommandQueue.js';
@@ -164,7 +164,7 @@ describe('레거시 사령부 턴 실행 호환성', () => {
); );
}); });
it('MONTH action 전에 전략·외교 제한, 임시 세율, 첩보 기간을 갱신한다', () => { it('MONTH action 전에 전략·외교 제한, 임시 세율, 첩보 기간을 갱신한다', async () => {
const updates: Array<{ id: number; patch: Record<string, unknown> }> = []; const updates: Array<{ id: number; patch: Record<string, unknown> }> = [];
const nations = [ const nations = [
{ {
@@ -188,7 +188,7 @@ describe('레거시 사령부 턴 실행 호환성', () => {
}) as never, }) as never,
}); });
handler.beforeMonthChanged?.({} as never); await handler.beforeMonthChanged?.({} as never);
expect(updates).toEqual([ expect(updates).toEqual([
{ {
@@ -327,5 +327,5 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => {
// Nation awards can occur in the same tick and make the general's net // Nation awards can occur in the same tick and make the general's net
// gold delta smaller than the recruitment price. Exact cost scaling is // gold delta smaller than the recruitment price. Exact cost scaling is
// covered by the unit-set/action contract tests rather than this smoke. // covered by the unit-set/action contract tests rather than this smoke.
}, 60000); }, 300_000);
}); });
@@ -553,5 +553,5 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => {
} }
throw error; throw error;
} }
}, 180000); }, 360_000);
}); });
+2
View File
@@ -13,5 +13,7 @@ export default defineConfig({
environment: 'node', environment: 'node',
globals: true, globals: true,
include: ['test/**/*.test.ts'], include: ['test/**/*.test.ts'],
maxWorkers: 4,
testTimeout: 10_000,
}, },
}); });
@@ -3,13 +3,13 @@ import { computed, ref } from 'vue';
import type { BattleSimOptions, GeneralDraft } from '../../utils/battleSimulatorTypes'; import type { BattleSimOptions, GeneralDraft } from '../../utils/battleSimulatorTypes';
interface Props { interface Props {
general: GeneralDraft;
options: BattleSimOptions; options: BattleSimOptions;
mode: 'attacker' | 'defender'; mode: 'attacker' | 'defender';
title: string; title: string;
} }
const props = defineProps<Props>(); const props = defineProps<Props>();
const general = defineModel<GeneralDraft>('general', { required: true });
const emit = defineEmits<{ const emit = defineEmits<{
(event: 'import'): void; (event: 'import'): void;
@@ -187,21 +187,11 @@ const officerLevelOptions = [
<div class="form-row"> <div class="form-row">
<label class="field"> <label class="field">
<span>훈련</span> <span>훈련</span>
<input <input v-model.number="general.train" type="number" min="40" :max="options.config.maxTrainByWar" />
v-model.number="general.train"
type="number"
min="40"
:max="options.config.maxTrainByWar"
/>
</label> </label>
<label class="field"> <label class="field">
<span>사기</span> <span>사기</span>
<input <input v-model.number="general.atmos" type="number" min="40" :max="options.config.maxAtmosByWar" />
v-model.number="general.atmos"
type="number"
min="40"
:max="options.config.maxAtmosByWar"
/>
</label> </label>
<label class="field"> <label class="field">
<span>전특</span> <span>전특</span>
@@ -308,30 +298,15 @@ const officerLevelOptions = [
<div class="form-row buff-row"> <div class="form-row buff-row">
<label class="field"> <label class="field">
<span>상대 회피</span> <span>상대 회피</span>
<input <input v-model.number="general.inheritBuff.warAvoidRatioOppose" type="number" min="0" max="5" />
v-model.number="general.inheritBuff.warAvoidRatioOppose"
type="number"
min="0"
max="5"
/>
</label> </label>
<label class="field"> <label class="field">
<span>상대 필살</span> <span>상대 필살</span>
<input <input v-model.number="general.inheritBuff.warCriticalRatioOppose" type="number" min="0" max="5" />
v-model.number="general.inheritBuff.warCriticalRatioOppose"
type="number"
min="0"
max="5"
/>
</label> </label>
<label class="field"> <label class="field">
<span>상대 계략</span> <span>상대 계략</span>
<input <input v-model.number="general.inheritBuff.warMagicTrialProbOppose" type="number" min="0" max="5" />
v-model.number="general.inheritBuff.warMagicTrialProbOppose"
type="number"
min="0"
max="5"
/>
</label> </label>
</div> </div>
</div> </div>
+2 -5
View File
@@ -24,11 +24,10 @@ export const formatLog = (text?: string): string => {
return ''; return '';
} }
let match: RegExpExecArray | null = null;
let lastIndex = 0; let lastIndex = 0;
const result: string[] = []; const result: string[] = [];
while ((match = logRegex.exec(text)) !== null) { for (let match = logRegex.exec(text); match !== null; match = logRegex.exec(text)) {
const partAll = match[0]; const partAll = match[0];
const subPart = match[1]; const subPart = match[1];
const index = match.index; const index = match.index;
@@ -40,9 +39,7 @@ export const formatLog = (text?: string): string => {
if (subPart === '/') { if (subPart === '/') {
result.push('</span>'); result.push('</span>');
} else if (subPart.length === 2) { } else if (subPart.length === 2) {
result.push( result.push(`<span style="${convertMap[subPart[0]] ?? ''}${convertMap2[subPart[1]] ?? ''}">`);
`<span style="${convertMap[subPart[0]] ?? ''}${convertMap2[subPart[1]] ?? ''}">`
);
} else { } else {
result.push(`<span style="${convertMap[subPart] ?? ''}">`); result.push(`<span style="${convertMap[subPart] ?? ''}">`);
} }
@@ -542,7 +542,9 @@ const runSimulation = async (action: BattleSimRequestPayload['action']) => {
const payload = buildBattlePayload(action); const payload = buildBattlePayload(action);
const response = await trpc.battle.simulate.mutate(payload); const response = await trpc.battle.simulate.mutate(payload);
const result = const result =
'payload' in response && response.payload ? response.payload : await waitForSimulationResult(response.jobId); 'payload' in response && response.payload
? response.payload
: await waitForSimulationResult(response.jobId);
if (!result.result) { if (!result.result) {
error.value = result.reason || 'battle_failed'; error.value = result.reason || 'battle_failed';
@@ -882,7 +884,8 @@ const summaryRows = computed(() => {
{ label: '전투 페이즈', value: formatNumber(battleResult.value.phase) }, { label: '전투 페이즈', value: formatNumber(battleResult.value.phase) },
{ {
label: '준 피해', label: '준 피해',
value: battleResult.value.minKilled !== battleResult.value.maxKilled value:
battleResult.value.minKilled !== battleResult.value.maxKilled
? `${formatNumber(battleResult.value.killed)} (${formatNumber(battleResult.value.minKilled)} ~ ${formatNumber( ? `${formatNumber(battleResult.value.killed)} (${formatNumber(battleResult.value.minKilled)} ~ ${formatNumber(
battleResult.value.maxKilled battleResult.value.maxKilled
)})` )})`
@@ -890,7 +893,8 @@ const summaryRows = computed(() => {
}, },
{ {
label: '받은 피해', label: '받은 피해',
value: battleResult.value.minDead !== battleResult.value.maxDead value:
battleResult.value.minDead !== battleResult.value.maxDead
? `${formatNumber(battleResult.value.dead)} (${formatNumber(battleResult.value.minDead)} ~ ${formatNumber( ? `${formatNumber(battleResult.value.dead)} (${formatNumber(battleResult.value.minDead)} ~ ${formatNumber(
battleResult.value.maxDead battleResult.value.maxDead
)})` )})`
@@ -1028,7 +1032,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
<BattleGeneralCard <BattleGeneralCard
v-if="attackerGeneral" v-if="attackerGeneral"
:general="attackerGeneral!" v-model:general="attackerGeneral"
:options="options!" :options="options!"
mode="attacker" mode="attacker"
title="출병자 설정" title="출병자 설정"
@@ -1095,7 +1099,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
<BattleGeneralCard <BattleGeneralCard
v-for="(defender, index) in defenders" v-for="(defender, index) in defenders"
:key="defender.id" :key="defender.id"
:general="defender" v-model:general="defenders[index]"
:options="options!" :options="options!"
mode="defender" mode="defender"
:title="`수비자 설정 ${index + 1}`" :title="`수비자 설정 ${index + 1}`"
@@ -1146,11 +1150,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
</div> </div>
<div v-else class="select-wrap"> <div v-else class="select-wrap">
<select v-model.number="selectedGeneralId"> <select v-model.number="selectedGeneralId">
<optgroup <optgroup v-for="group in generalGroups" :key="group.nation.id" :label="group.nation.name">
v-for="group in generalGroups"
:key="group.nation.id"
:label="group.nation.name"
>
<option <option
v-for="general in group.generals" v-for="general in group.generals"
:key="general.id" :key="general.id"
+93 -19
View File
@@ -189,9 +189,7 @@ const destroyLetter = async (letterId: number) => {
} }
}; };
const prevOptions = computed(() => const prevOptions = computed(() => data.value?.letters.filter((letter) => letter.state !== 'CANCELLED') ?? []);
data.value?.letters.filter((letter) => letter.state !== 'CANCELLED') ?? []
);
const formatDate = (value: string) => new Date(value).toLocaleString('ko-KR'); const formatDate = (value: string) => new Date(value).toLocaleString('ko-KR');
@@ -216,12 +214,14 @@ const canRollback = (letter: DiplomacyLetter) =>
editable.value && data.value?.myNationId === letter.src.nationId && letter.state === 'PROPOSED'; editable.value && data.value?.myNationId === letter.src.nationId && letter.state === 'PROPOSED';
const canDestroy = (letter: DiplomacyLetter) => const canDestroy = (letter: DiplomacyLetter) =>
editable.value && letter.state === 'ACTIVATED' && (data.value?.myNationId === letter.src.nationId || data.value?.myNationId === letter.dest.nationId); editable.value &&
letter.state === 'ACTIVATED' &&
(data.value?.myNationId === letter.src.nationId || data.value?.myNationId === letter.dest.nationId);
const canRenew = (letter: DiplomacyLetter) => letter.state !== 'CANCELLED'; const canRenew = (letter: DiplomacyLetter) => letter.state !== 'CANCELLED';
onMounted(() => { onMounted(() => {
loadLetters(); void loadLetters();
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
@@ -270,26 +270,84 @@ onBeforeUnmount(() => {
<div class="editor-group"> <div class="editor-group">
<div class="editor-label">내용(국가 공개)</div> <div class="editor-label">내용(국가 공개)</div>
<div class="editor-toolbar"> <div class="editor-toolbar">
<button type="button" @click="briefEditor?.chain().focus().toggleBold().run()" :class="{ active: briefEditor?.isActive('bold') }">굵게</button> <button
<button type="button" @click="briefEditor?.chain().focus().toggleItalic().run()" :class="{ active: briefEditor?.isActive('italic') }">기울임</button> type="button"
<button type="button" @click="briefEditor?.chain().focus().toggleUnderline().run()" :class="{ active: briefEditor?.isActive('underline') }">밑줄</button> @click="briefEditor?.chain().focus().toggleBold().run()"
:class="{ active: briefEditor?.isActive('bold') }"
>
굵게
</button>
<button
type="button"
@click="briefEditor?.chain().focus().toggleItalic().run()"
:class="{ active: briefEditor?.isActive('italic') }"
>
기울임
</button>
<button
type="button"
@click="briefEditor?.chain().focus().toggleUnderline().run()"
:class="{ active: briefEditor?.isActive('underline') }"
>
밑줄
</button>
<button type="button" @click="addLink('brief')">링크</button> <button type="button" @click="addLink('brief')">링크</button>
<button type="button" @click="briefEditor?.chain().focus().toggleBulletList().run()">목록</button> <button type="button" @click="briefEditor?.chain().focus().toggleBulletList().run()">목록</button>
<button type="button" @click="briefEditor?.chain().focus().toggleOrderedList().run()">번호 목록</button> <button type="button" @click="briefEditor?.chain().focus().toggleOrderedList().run()">
<button type="button" @click="uploadTarget = 'brief'; fileInputRef?.click()" :disabled="uploadBusy">이미지 업로드</button> 번호 목록
</button>
<button
type="button"
@click="
uploadTarget = 'brief';
fileInputRef?.click();
"
:disabled="uploadBusy"
>
이미지 업로드
</button>
</div> </div>
<EditorContent v-if="briefEditor" :editor="briefEditor" /> <EditorContent v-if="briefEditor" :editor="briefEditor" />
</div> </div>
<div class="editor-group"> <div class="editor-group">
<div class="editor-label">내용(외교권자 전용)</div> <div class="editor-label">내용(외교권자 전용)</div>
<div class="editor-toolbar"> <div class="editor-toolbar">
<button type="button" @click="detailEditor?.chain().focus().toggleBold().run()" :class="{ active: detailEditor?.isActive('bold') }">굵게</button> <button
<button type="button" @click="detailEditor?.chain().focus().toggleItalic().run()" :class="{ active: detailEditor?.isActive('italic') }">기울임</button> type="button"
<button type="button" @click="detailEditor?.chain().focus().toggleUnderline().run()" :class="{ active: detailEditor?.isActive('underline') }">밑줄</button> @click="detailEditor?.chain().focus().toggleBold().run()"
:class="{ active: detailEditor?.isActive('bold') }"
>
굵게
</button>
<button
type="button"
@click="detailEditor?.chain().focus().toggleItalic().run()"
:class="{ active: detailEditor?.isActive('italic') }"
>
기울임
</button>
<button
type="button"
@click="detailEditor?.chain().focus().toggleUnderline().run()"
:class="{ active: detailEditor?.isActive('underline') }"
>
밑줄
</button>
<button type="button" @click="addLink('detail')">링크</button> <button type="button" @click="addLink('detail')">링크</button>
<button type="button" @click="detailEditor?.chain().focus().toggleBulletList().run()">목록</button> <button type="button" @click="detailEditor?.chain().focus().toggleBulletList().run()">목록</button>
<button type="button" @click="detailEditor?.chain().focus().toggleOrderedList().run()">번호 목록</button> <button type="button" @click="detailEditor?.chain().focus().toggleOrderedList().run()">
<button type="button" @click="uploadTarget = 'detail'; fileInputRef?.click()" :disabled="uploadBusy">이미지 업로드</button> 번호 목록
</button>
<button
type="button"
@click="
uploadTarget = 'detail';
fileInputRef?.click();
"
:disabled="uploadBusy"
>
이미지 업로드
</button>
</div> </div>
<EditorContent v-if="detailEditor" :editor="detailEditor" /> <EditorContent v-if="detailEditor" :editor="detailEditor" />
</div> </div>
@@ -327,7 +385,10 @@ onBeforeUnmount(() => {
</button> </button>
<div v-if="historyOpen[letter.id]" class="history-panel"> <div v-if="historyOpen[letter.id]" class="history-panel">
<template v-if="getPrevLetter(letter)"> <template v-if="getPrevLetter(letter)">
<p>#{{ getPrevLetter(letter)?.id }} {{ getPrevLetter(letter)?.src.nationName }} {{ getPrevLetter(letter)?.dest.nationName }}</p> <p>
#{{ getPrevLetter(letter)?.id }} {{ getPrevLetter(letter)?.src.nationName }}
{{ getPrevLetter(letter)?.dest.nationName }}
</p>
<div class="letter-text" v-html="getPrevLetter(letter)?.brief" /> <div class="letter-text" v-html="getPrevLetter(letter)?.brief" />
</template> </template>
<p v-else class="hint">이전 문서를 찾을 없습니다.</p> <p v-else class="hint">이전 문서를 찾을 없습니다.</p>
@@ -335,11 +396,24 @@ onBeforeUnmount(() => {
</div> </div>
</div> </div>
<footer class="letter-actions"> <footer class="letter-actions">
<button v-if="canRespond(letter)" type="button" @click="respondLetter(letter.id, true)">승인</button> <button v-if="canRespond(letter)" type="button" @click="respondLetter(letter.id, true)">
<button v-if="canRespond(letter)" type="button" @click="respondLetter(letter.id, false, '거부')">거부</button> 승인
</button>
<button v-if="canRespond(letter)" type="button" @click="respondLetter(letter.id, false, '거부')">
거부
</button>
<button v-if="canRollback(letter)" type="button" @click="rollbackLetter(letter.id)">회수</button> <button v-if="canRollback(letter)" type="button" @click="rollbackLetter(letter.id)">회수</button>
<button v-if="canDestroy(letter)" type="button" @click="destroyLetter(letter.id)">파기</button> <button v-if="canDestroy(letter)" type="button" @click="destroyLetter(letter.id)">파기</button>
<button v-if="canRenew(letter)" type="button" @click="selectedPrevId = letter.id; applyPrevLetter()">추가 문서 작성</button> <button
v-if="canRenew(letter)"
type="button"
@click="
selectedPrevId = letter.id;
applyPrevLetter();
"
>
추가 문서 작성
</button>
</footer> </footer>
</article> </article>
</section> </section>
@@ -242,7 +242,7 @@ watch(
); );
onMounted(() => { onMounted(() => {
loadData(); void loadData();
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
@@ -278,19 +278,17 @@ onBeforeUnmount(() => {
<div class="panel-header"> <div class="panel-header">
<h2>국가 방침</h2> <h2>국가 방침</h2>
<div class="panel-actions"> <div class="panel-actions">
<button v-if="editable && !editingNationMsg" type="button" @click="startEditNationMsg"> <button v-if="editable && !editingNationMsg" type="button" @click="startEditNationMsg">수정</button>
수정 <button v-if="editable && editingNationMsg" type="button" @click="saveNationMsg">저장</button>
</button> <button v-if="editable && editingNationMsg" type="button" @click="cancelEditNationMsg">취소</button>
<button v-if="editable && editingNationMsg" type="button" @click="saveNationMsg">
저장
</button>
<button v-if="editable && editingNationMsg" type="button" @click="cancelEditNationMsg">
취소
</button>
</div> </div>
</div> </div>
<div v-if="editingNationMsg" class="editor-toolbar"> <div v-if="editingNationMsg" class="editor-toolbar">
<button type="button" @click="editor?.chain().focus().toggleBold().run()" :class="{ active: editor?.isActive('bold') }"> <button
type="button"
@click="editor?.chain().focus().toggleBold().run()"
:class="{ active: editor?.isActive('bold') }"
>
굵게 굵게
</button> </button>
<button <button
@@ -323,21 +321,57 @@ onBeforeUnmount(() => {
<div class="panel-card"> <div class="panel-card">
<h3>자금 예산</h3> <h3>자금 예산</h3>
<dl> <dl>
<div><dt>현재</dt><dd>{{ data.gold.toLocaleString() }}</dd></div> <div>
<div><dt>단기 수입</dt><dd>{{ data.income.gold.war.toLocaleString() }}</dd></div> <dt>현재</dt>
<div><dt>세금</dt><dd>{{ Math.floor(incomeGoldCity).toLocaleString() }}</dd></div> <dd>{{ data.gold.toLocaleString() }}</dd>
<div><dt>수입/지출</dt><dd>+{{ Math.floor(incomeGold).toLocaleString() }} / {{ Math.floor(-outcomeByBill).toLocaleString() }}</dd></div> </div>
<div><dt>국고 예산</dt><dd>{{ Math.floor(data.gold + incomeGold - outcomeByBill).toLocaleString() }}</dd></div> <div>
<dt>단기 수입</dt>
<dd>{{ data.income.gold.war.toLocaleString() }}</dd>
</div>
<div>
<dt>세금</dt>
<dd>{{ Math.floor(incomeGoldCity).toLocaleString() }}</dd>
</div>
<div>
<dt>수입/지출</dt>
<dd>
+{{ Math.floor(incomeGold).toLocaleString() }} /
{{ Math.floor(-outcomeByBill).toLocaleString() }}
</dd>
</div>
<div>
<dt>국고 예산</dt>
<dd>{{ Math.floor(data.gold + incomeGold - outcomeByBill).toLocaleString() }}</dd>
</div>
</dl> </dl>
</div> </div>
<div class="panel-card"> <div class="panel-card">
<h3>군량 예산</h3> <h3>군량 예산</h3>
<dl> <dl>
<div><dt>현재</dt><dd>{{ data.rice.toLocaleString() }}</dd></div> <div>
<div><dt>둔전 수입</dt><dd>{{ Math.floor(incomeRiceWall).toLocaleString() }}</dd></div> <dt>현재</dt>
<div><dt>세금</dt><dd>{{ Math.floor(incomeRiceCity).toLocaleString() }}</dd></div> <dd>{{ data.rice.toLocaleString() }}</dd>
<div><dt>수입/지출</dt><dd>+{{ Math.floor(incomeRice).toLocaleString() }} / {{ Math.floor(-outcomeByBill).toLocaleString() }}</dd></div> </div>
<div><dt>국고 예산</dt><dd>{{ Math.floor(data.rice + incomeRice - outcomeByBill).toLocaleString() }}</dd></div> <div>
<dt>둔전 수입</dt>
<dd>{{ Math.floor(incomeRiceWall).toLocaleString() }}</dd>
</div>
<div>
<dt>세금</dt>
<dd>{{ Math.floor(incomeRiceCity).toLocaleString() }}</dd>
</div>
<div>
<dt>수입/지출</dt>
<dd>
+{{ Math.floor(incomeRice).toLocaleString() }} /
{{ Math.floor(-outcomeByBill).toLocaleString() }}
</dd>
</div>
<div>
<dt>국고 예산</dt>
<dd>{{ Math.floor(data.rice + incomeRice - outcomeByBill).toLocaleString() }}</dd>
</div>
</dl> </dl>
</div> </div>
<div class="panel-card"> <div class="panel-card">
@@ -359,7 +393,13 @@ onBeforeUnmount(() => {
<div class="panel-card"> <div class="panel-card">
<h3>기밀 권한</h3> <h3>기밀 권한</h3>
<div class="input-row"> <div class="input-row">
<input v-model.number="policyDraft.secretLimit" type="number" min="1" max="99" :disabled="!editable" /> <input
v-model.number="policyDraft.secretLimit"
type="number"
min="1"
max="99"
:disabled="!editable"
/>
<span></span> <span></span>
<button type="button" @click="setSecretLimit" :disabled="!editable">변경</button> <button type="button" @click="setSecretLimit" :disabled="!editable">변경</button>
</div> </div>
@@ -376,7 +416,9 @@ onBeforeUnmount(() => {
/> />
전쟁 금지 전쟁 금지
</label> </label>
<span class="hint">잔여 {{ data.warSettingCnt.remain }} ( +{{ data.warSettingCnt.inc }})</span> <span class="hint"
>잔여 {{ data.warSettingCnt.remain }} ( +{{ data.warSettingCnt.inc }})</span
>
</div> </div>
</div> </div>
<div class="panel-card"> <div class="panel-card">
@@ -134,7 +134,7 @@ watch(
); );
onMounted(() => { onMounted(() => {
loadData(); void loadData();
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
@@ -168,7 +168,11 @@ onBeforeUnmount(() => {
</div> </div>
<div v-if="editing" class="editor-toolbar"> <div v-if="editing" class="editor-toolbar">
<button type="button" @click="editor?.chain().focus().toggleBold().run()" :class="{ active: editor?.isActive('bold') }"> <button
type="button"
@click="editor?.chain().focus().toggleBold().run()"
:class="{ active: editor?.isActive('bold') }"
>
굵게 굵게
</button> </button>
<button <button
+5 -2
View File
@@ -124,8 +124,11 @@ export const parsePercent = (value: string): number | null => {
export type CompareOperator = '>' | '>=' | '==' | '<=' | '<' | '!=' | '===' | '!=='; export type CompareOperator = '>' | '>=' | '==' | '<=' | '<' | '!=' | '===' | '!==';
export const compareValues = (target: unknown, op: CompareOperator, source: unknown): boolean => { export const compareValues = (target: unknown, op: CompareOperator, source: unknown): boolean => {
const lhs = target as any; // The cast is type-only: JavaScript still applies its native relational
const rhs = source as any; // coercion rules to the original runtime values, matching the legacy
// constraint evaluator without opting the whole comparison into `any`.
const lhs = target as number;
const rhs = source as number;
switch (op) { switch (op) {
case '<': case '<':
return lhs < rhs; return lhs < rhs;
+2 -6
View File
@@ -196,10 +196,7 @@ const resolveUnitReport = (unit: WarUnit): WarUnitReport => {
}; };
}; };
const buildTraceUnitSnapshot = ( const buildTraceUnitSnapshot = (unit: WarUnit, defenderCity: City): WarBattleTraceUnitSnapshot => {
unit: WarUnit,
defenderCity: City
): WarBattleTraceUnitSnapshot => {
const common = { const common = {
kind: unit instanceof WarUnitGeneral ? ('general' as const) : ('city' as const), kind: unit instanceof WarUnitGeneral ? ('general' as const) : ('city' as const),
id: unit instanceof WarUnitGeneral ? unit.getGeneral().id : (unit as WarUnitCity).getCityId(), id: unit instanceof WarUnitGeneral ? unit.getGeneral().id : (unit as WarUnitCity).getCityId(),
@@ -339,7 +336,6 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
); );
const iter = defenderUnits.values(); const iter = defenderUnits.values();
let defender: WarUnit<TriggerState> | null = null;
const getNextDefender = ( const getNextDefender = (
_prevDefender: WarUnit<TriggerState> | null, _prevDefender: WarUnit<TriggerState> | null,
@@ -359,7 +355,7 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
return candidate; return candidate;
}; };
defender = getNextDefender(null, true); let defender = getNextDefender(null, true);
let conquerCity = false; let conquerCity = false;
let logWritten = false; let logWritten = false;
let traceSeq = 0; let traceSeq = 0;
@@ -258,7 +258,6 @@ describe('General Commands New Scenario', () => {
// 6. Retire (Needs age >= 60) // 6. Retire (Needs age >= 60)
// Manually set age // Manually set age
// Manually set age
const gToRetire = { ...g1_after_resign, age: 65 }; const gToRetire = { ...g1_after_resign, age: 65 };
world.snapshot.generals = world.snapshot.generals.map((g) => (g.id === 1 ? gToRetire : g)); world.snapshot.generals = world.snapshot.generals.map((g) => (g.id === 1 ? gToRetire : g));
const retireDef = retireSpec.createDefinition(systemEnv); const retireDef = retireSpec.createDefinition(systemEnv);
@@ -274,7 +273,7 @@ describe('General Commands New Scenario', () => {
const g1_after_retire = world.getGeneral(1)!; const g1_after_retire = world.getGeneral(1)!;
expect(g1_after_retire.age).toBe(20); expect(g1_after_retire.age).toBe(20);
// General::rebirth()는 앞선 명령으로 누적된 경험을 초기화하지 않고 절반으로 줄인다. // General::rebirth()는 앞선 명령으로 누적된 경험을 초기화하지 않고 절반으로 줄인다.
expect(g1_after_retire.experience).toBe(142); expect(g1_after_retire.experience).toBe(Math.round(gToRetire.experience * 0.5));
}); });
it('should execute employ and sabotage commands', async () => { it('should execute employ and sabotage commands', async () => {