Merge branch 'main' into feature/survey-unique-parity

This commit is contained in:
2026-07-26 04:18:00 +00:00
20 changed files with 1973 additions and 608 deletions
+20 -12
View File
@@ -1,5 +1,6 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { loadMapDefinitionByName } from './mapDefinition.js';
export interface MapLayoutCity {
id: number;
@@ -30,8 +31,7 @@ const LEGACY_CITY_CONST = path.resolve(process.cwd(), 'legacy/hwe/sammo/CityCons
const layoutCache = new Map<string, MapLayout>();
const stripComments = (value: string): string =>
value.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '');
const stripComments = (value: string): string => value.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '');
const extractPhpArray = (source: string, marker: string): string | null => {
const idx = source.indexOf(marker);
@@ -196,11 +196,7 @@ const parseCityConstFile = async (filePath: string): Promise<ParsedCityConst> =>
const resolveScenarioFile = async (scenario: string): Promise<string> => {
const normalized = scenario.replace(/\.json$/i, '');
const candidates = [
`${normalized}.json`,
`scenario_${normalized}.json`,
'default.json',
];
const candidates = [`${normalized}.json`, `scenario_${normalized}.json`, 'default.json'];
for (const candidate of candidates) {
const fullPath = path.join(LEGACY_SCENARIO_ROOT, candidate);
@@ -272,15 +268,15 @@ const normalizeInitCity = (
typeof levelLabel === 'number'
? levelLabel
: typeof levelLabel === 'string'
? levelMap.nameToId[levelLabel] ?? Number(levelLabel)
: 0;
? (levelMap.nameToId[levelLabel] ?? Number(levelLabel))
: 0;
const regionValue =
typeof regionLabel === 'number'
? regionLabel
: typeof regionLabel === 'string'
? regionMap.nameToId[regionLabel] ?? Number(regionLabel)
: 0;
? (regionMap.nameToId[regionLabel] ?? Number(regionLabel))
: 0;
const pathNames = Array.isArray(path) ? (path as string[]) : [];
const pathIds = pathNames
@@ -324,7 +320,19 @@ export const loadMapLayout = async (scenario: string): Promise<MapLayout> => {
const levelMap = buildLookupMap(levelMapRaw);
const initCity = map.initCity ?? base.initCity ?? [];
const cityList = normalizeInitCity(initCity, levelMap, regionMap);
let cityList = normalizeInitCity(initCity, levelMap, regionMap);
if (cityList.length === 0) {
const resourceMap = await loadMapDefinitionByName(mapName);
cityList = resourceMap.cities.map((city) => ({
id: city.id,
name: city.name,
level: city.level,
region: city.region,
x: city.position.x,
y: city.position.y,
path: [...city.connections],
}));
}
const layout: MapLayout = {
mapName,
@@ -0,0 +1,127 @@
import { TRPCError } from '@trpc/server';
import { asRecord } from '@sammo-ts/common';
import { LogCategory, LogScope } from '@sammo-ts/infra';
import { getGoldIncome, getOutcome, getRiceIncome, getWallIncome, getWarGoldIncome } from '@sammo-ts/logic';
import { authedProcedure } from '../../../trpc.js';
import { getMyGeneral } from '../../shared/general.js';
import {
assertNationAccess,
buildNationIncomeContext,
resolveNationBill,
resolveNationRate,
resolveOfficerCity,
toIncomeCity,
} from '../shared.js';
export const getNationInfo = authedProcedure.query(async ({ ctx }) => {
const me = await getMyGeneral(ctx);
assertNationAccess(me);
const [nation, cities, generals, history] = await Promise.all([
ctx.db.nation.findUnique({ where: { id: me.nationId } }),
ctx.db.city.findMany({ where: { nationId: me.nationId }, orderBy: { id: 'asc' } }),
ctx.db.general.findMany({ where: { nationId: me.nationId } }),
ctx.db.logEntry.findMany({
where: {
scope: LogScope.NATION,
category: LogCategory.HISTORY,
nationId: me.nationId,
},
select: { id: true, year: true, month: true, text: true },
orderBy: { id: 'asc' },
}),
]);
if (!nation) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
}
const officerCntByCity = new Map<number, number>();
for (const general of generals) {
const officerCity = resolveOfficerCity(asRecord(general.meta));
if (
general.officerLevel >= 2 &&
general.officerLevel <= 4 &&
officerCity > 0 &&
general.cityId === officerCity
) {
officerCntByCity.set(officerCity, (officerCntByCity.get(officerCity) ?? 0) + 1);
}
}
const incomeContext = await buildNationIncomeContext(nation);
const incomeCities = cities.map(toIncomeCity);
const rate = resolveNationRate(nation);
const bill = resolveNationBill(asRecord(nation.meta));
const goldCity = getGoldIncome(
incomeContext,
incomeCities,
officerCntByCity,
nation.capitalCityId ?? 0,
nation.level
);
const goldWar = getWarGoldIncome(incomeContext, incomeCities);
const riceCity = getRiceIncome(
incomeContext,
incomeCities,
officerCntByCity,
nation.capitalCityId ?? 0,
nation.level
);
const riceWall = getWallIncome(
incomeContext,
incomeCities,
officerCntByCity,
nation.capitalCityId ?? 0,
nation.level
);
const outcome = getOutcome(
bill,
generals.filter((general) => general.npcState !== 5)
);
const population = cities.reduce((sum, city) => sum + city.population, 0);
const populationMax = cities.reduce((sum, city) => sum + city.populationMax, 0);
const crewGenerals = generals.filter((general) => general.npcState !== 5);
const crew = crewGenerals.reduce((sum, general) => sum + general.crew, 0);
const crewMax = crewGenerals.reduce((sum, general) => sum + general.leadership * 100, 0);
const meta = asRecord(nation.meta);
return {
nation: {
id: nation.id,
name: nation.name,
color: nation.color,
level: nation.level,
power: typeof meta.power === 'number' ? meta.power : 0,
gold: nation.gold,
rice: nation.rice,
tech: Math.floor(nation.tech),
rate,
bill,
capitalCityId: nation.capitalCityId ?? 0,
generalCount: generals.length,
},
population: { current: population, max: populationMax },
crew: { current: crew, max: crewMax },
income: {
goldCity,
goldWar,
goldTotal: goldCity + goldWar,
riceCity,
riceWall,
riceTotal: riceCity + riceWall,
outcome,
},
budget: {
gold: nation.gold + goldCity + goldWar - outcome,
rice: nation.rice + riceCity + riceWall - outcome,
},
cities: cities.map((city) => ({
id: city.id,
name: city.name,
capital: city.id === nation.capitalCityId,
})),
history,
};
});
+2 -1
View File
@@ -6,6 +6,7 @@ import { getChiefCenter } from './endpoints/getChiefCenter.js';
import { getCityOverview } from './endpoints/getCityOverview.js';
import { getGeneralList } from './endpoints/getGeneralList.js';
import { getGeneralLog } from './endpoints/getGeneralLog.js';
import { getNationInfo } from './endpoints/getNationInfo.js';
import { getPersonnelInfo } from './endpoints/getPersonnelInfo.js';
import { getStratFinan } from './endpoints/getStratFinan.js';
import { kick } from './endpoints/kick.js';
@@ -18,6 +19,7 @@ import { setScoutMsg } from './endpoints/setScoutMsg.js';
import { setSecretLimit } from './endpoints/setSecretLimit.js';
export const nationRouter = router({
getNationInfo,
getGeneralList,
getCityOverview,
getPersonnelInfo,
@@ -36,4 +38,3 @@ export const nationRouter = router({
kick,
appoint,
});
+205 -6
View File
@@ -1,15 +1,37 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import {
type WorldStateRow,
zWorldStateConfig,
zWorldStateMeta,
} from '../../context.js';
import { type WorldStateRow, zWorldStateConfig, zWorldStateMeta } from '../../context.js';
import { procedure, router } from '../../trpc.js';
import { authedProcedure } from '../../trpc.js';
import { asRecord, isRecord } from '@sammo-ts/common';
import { loadWorldMap } from '../../maps/worldMap.js';
import { loadMapLayout } from '../../maps/mapLayout.js';
import { getOwnedGeneral } from '../shared/general.js';
import { getMyGeneral, getOwnedGeneral } from '../shared/general.js';
const isWorldAdmin = (roles: readonly string[]): boolean =>
roles.some((role) => role === 'superuser' || role === 'admin' || role === 'admin.superuser');
const numberRecord = (value: unknown): Record<number, number> => {
if (!isRecord(value)) return {};
return Object.fromEntries(
Object.entries(value)
.map(([key, item]) => [Number(key), typeof item === 'number' ? item : Number.NaN] as const)
.filter(([key, item]) => Number.isFinite(key) && Number.isFinite(item))
);
};
const officerCity = (meta: unknown): number => {
const value = asRecord(meta);
const raw = value.officerCity ?? value.officer_city;
return typeof raw === 'number' && Number.isFinite(raw) ? raw : 0;
};
const defenceTrain = (meta: unknown): number => {
const value = asRecord(meta);
const raw = value.defenceTrain ?? value.defence_train;
return typeof raw === 'number' && Number.isFinite(raw) ? raw : 0;
};
const toWorldStateSnapshot = (row: WorldStateRow) => ({
scenarioCode: row.scenarioCode,
@@ -22,6 +44,183 @@ const toWorldStateSnapshot = (row: WorldStateRow) => ({
});
export const worldRouter = router({
getGlobalInfo: authedProcedure.query(async ({ ctx }) => {
const me = await getMyGeneral(ctx);
const [nations, cities, diplomacy, map] = await Promise.all([
ctx.db.nation.findMany({ where: { level: { gt: 0 } } }),
ctx.db.city.findMany({ orderBy: { id: 'asc' } }),
ctx.db.diplomacy.findMany({ where: { isDead: false, isShowing: true } }),
loadWorldMap(ctx, { generalId: me.id, neutralView: false, showMe: true }),
]);
if (!map) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state is not initialized.' });
}
const nationRows = nations
.map((nation) => ({
id: nation.id,
name: nation.name,
color: nation.color,
capitalCityId: nation.capitalCityId ?? 0,
level: nation.level,
power: typeof asRecord(nation.meta).power === 'number' ? Number(asRecord(nation.meta).power) : 0,
cities: cities.filter((city) => city.nationId === nation.id).map((city) => city.name),
}))
.sort((left, right) => right.power - left.power || left.id - right.id);
const matrix: Record<number, Record<number, number>> = {};
for (const nation of nationRows) {
matrix[nation.id] = {};
for (const other of nationRows) matrix[nation.id]![other.id] = 2;
}
for (const relation of diplomacy) {
if (!matrix[relation.srcNationId]) continue;
const related = relation.srcNationId === me.nationId || relation.destNationId === me.nationId;
matrix[relation.srcNationId]![relation.destNationId] = related
? relation.stateCode
: [3, 4, 5, 6, 7].includes(relation.stateCode)
? 2
: relation.stateCode;
}
const conflict = cities.flatMap((city) => {
const raw = numberRecord(city.conflict);
const entries = Object.entries(raw);
if (entries.length < 2) return [];
const sum = entries.reduce((total, [, value]) => total + value, 0);
if (sum <= 0) return [];
return [
{
cityId: city.id,
cityName: city.name,
nations: Object.fromEntries(
entries.map(([id, value]) => [id, Math.round((value * 1000) / sum) / 10])
),
},
];
});
return { myNationId: me.nationId, nations: nationRows, diplomacy: matrix, conflict, map };
}),
getCurrentCity: authedProcedure
.input(z.object({ cityId: z.number().int().positive().optional() }).optional())
.query(async ({ ctx, input }) => {
const me = await getMyGeneral(ctx);
const admin = isWorldAdmin(ctx.auth?.user.roles ?? []);
const [cities, nation, nationGenerals, nations, world, layout] = await Promise.all([
ctx.db.city.findMany({ orderBy: { id: 'asc' } }),
me.nationId > 0 ? ctx.db.nation.findUnique({ where: { id: me.nationId } }) : null,
me.nationId > 0
? ctx.db.general.findMany({ where: { nationId: me.nationId }, select: { cityId: true } })
: [],
ctx.db.nation.findMany(),
ctx.db.worldState.findFirst(),
loadMapLayout(ctx.profile.scenario),
]);
const cityById = new Map(cities.map((city) => [city.id, city]));
const requested = input?.cityId && cityById.has(input.cityId) ? input.cityId : me.cityId;
const selected = cityById.get(requested);
if (!selected) throw new TRPCError({ code: 'NOT_FOUND', message: 'City not found' });
const spy = numberRecord(asRecord(nation?.meta).spyList ?? asRecord(nation?.meta).spy);
const selectable = new Set<number>([me.cityId]);
if (me.officerLevel > 0 && me.nationId > 0) {
cities.filter((city) => city.nationId === me.nationId).forEach((city) => selectable.add(city.id));
nationGenerals.forEach((general) => selectable.add(general.cityId));
Object.keys(spy).forEach((id) => selectable.add(Number(id)));
}
if (admin) cities.forEach((city) => selectable.add(city.id));
const full = admin || selectable.has(selected.id);
const ownCities = new Set(
cities.filter((city) => city.nationId === me.nationId && me.nationId > 0).map((city) => city.id)
);
const layoutCity = layout.cityList.find((city) => city.id === selected.id);
const detailed = full || Boolean(layoutCity?.path.some((id) => ownCities.has(id)));
const generals = detailed
? await ctx.db.general.findMany({ where: { cityId: selected.id }, orderBy: { turnTime: 'asc' } })
: [];
const generalIds = generals
.filter((general) => general.nationId === me.nationId && general.npcState <= 1)
.map((general) => general.id);
const turns = generalIds.length
? await ctx.db.generalTurn.findMany({
where: { generalId: { in: generalIds }, turnIdx: { lt: 5 } },
orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }],
})
: [];
const turnMap = new Map<number, string[]>();
for (const turn of turns) {
const list = turnMap.get(turn.generalId) ?? [];
list[turn.turnIdx] = turn.actionCode;
turnMap.set(turn.generalId, list);
}
const nationMap = new Map(nations.map((item) => [item.id, item]));
const officers = await ctx.db.general.findMany({
where: { officerLevel: { in: [2, 3, 4] } },
select: { name: true, officerLevel: true, meta: true },
});
const selectedOfficers = Object.fromEntries(
officers
.filter((item) => officerCity(item.meta) === selected.id)
.map((item) => [item.officerLevel, item.name])
);
const redact = <T>(value: T): T | null => (full ? value : null);
const mappedGenerals = generals.map((general) => {
const ours = admin || (me.nationId > 0 && general.nationId === me.nationId);
return {
id: general.id,
name: general.name,
npcState: general.npcState,
picture: general.picture,
imageServer: general.imageServer,
nationId: general.nationId,
nationName: nationMap.get(general.nationId)?.name ?? '재야',
leadership: general.leadership,
strength: general.strength,
intelligence: general.intel,
injury: general.injury,
officerLevel: general.officerLevel,
defenceTrain: ours ? defenceTrain(general.meta) : null,
crewTypeId: ours ? general.crewTypeId : null,
crew: ours || full ? general.crew : null,
train: ours ? general.train : null,
atmos: ours ? general.atmos : null,
turns: ours && general.npcState <= 1 ? (turnMap.get(general.id) ?? []) : [],
};
});
return {
me: { id: me.id, nationId: me.nationId, officerLevel: me.officerLevel, admin },
options: [...selectable]
.map((id) => cityById.get(id))
.filter((city): city is NonNullable<typeof city> => Boolean(city))
.map((city) => ({ id: city.id, name: city.name, nationId: city.nationId })),
visibility: { full, detailed },
city: {
id: selected.id,
name: selected.name,
nationId: selected.nationId,
level: selected.level,
region: selected.region,
population: redact(selected.population),
populationMax: selected.populationMax,
agriculture: redact(selected.agriculture),
agricultureMax: selected.agricultureMax,
commerce: redact(selected.commerce),
commerceMax: selected.commerceMax,
security: redact(selected.security),
securityMax: selected.securityMax,
trust: redact(selected.trust),
trade: selected.trade,
defence: full || selected.nationId === 0 ? selected.defence : null,
defenceMax: selected.defenceMax,
wall: full || selected.nationId === 0 ? selected.wall : null,
wallMax: selected.wallMax,
officers: {
4: selectedOfficers[4] ?? '-',
3: selectedOfficers[3] ?? '-',
2: selectedOfficers[2] ?? '-',
},
},
generals: mappedGenerals,
lastExecute:
typeof asRecord(world?.meta).turntime === 'string' ? String(asRecord(world?.meta).turntime) : '',
};
}),
getState: procedure.query(async ({ ctx }) => {
const state = await ctx.db.worldState.findFirst();
return state ? toWorldStateSnapshot(state) : null;
+185
View File
@@ -0,0 +1,185 @@
import { describe, expect, it, vi } from 'vitest';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { RedisConnector } from '@sammo-ts/infra';
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js';
import { appRouter } from '../src/router.js';
const now = new Date('2026-01-01T00:00:00Z');
const general = (overrides: Partial<GeneralRow> = {}): GeneralRow => ({
id: 1,
userId: 'user-1',
name: '아군',
nationId: 1,
cityId: 1,
troopId: 0,
npcState: 0,
affinity: null,
bornYear: 180,
deadYear: 300,
picture: null,
imageServer: 0,
leadership: 70,
strength: 60,
intel: 50,
injury: 0,
experience: 0,
dedication: 0,
officerLevel: 1,
gold: 1000,
rice: 1000,
crew: 500,
crewTypeId: 1,
train: 90,
atmos: 90,
weaponCode: 'None',
bookCode: 'None',
horseCode: 'None',
itemCode: 'None',
turnTime: now,
recentWarTime: null,
age: 20,
startAge: 20,
personalCode: 'None',
specialCode: 'None',
special2Code: 'None',
lastTurn: {},
meta: { defence_train: 80 },
penalty: {},
createdAt: now,
updatedAt: now,
...overrides,
});
const auth = (roles: string[] = []): GameSessionTokenPayload => ({
version: 1,
profile: 'che:default',
issuedAt: now.toISOString(),
expiresAt: new Date(now.getTime() + 86400000).toISOString(),
sessionId: 'session',
user: { id: 'user-1', username: 'tester', displayName: 'Tester', roles },
sanctions: {},
});
const city = (id: number, nationId: number) => ({
id,
name: `도시${id}`,
level: 6,
nationId,
supplyState: 1,
frontState: 0,
population: 1000,
populationMax: 2000,
agriculture: 10,
agricultureMax: 20,
commerce: 11,
commerceMax: 21,
security: 12,
securityMax: 22,
trust: 50,
trade: 100,
defence: 13,
defenceMax: 23,
wall: 14,
wallMax: 24,
region: 2,
conflict: {},
meta: {},
});
const context = (options: { me?: GeneralRow; roles?: string[]; nationMeta?: Record<string, unknown> } = {}) => {
const me = options.me ?? general();
const cities = [city(1, 1), city(2, 2), city(3, 2), city(80, 1)];
const foreign = general({ id: 2, userId: 'user-2', name: '적군', nationId: 2, cityId: 2, crew: 777 });
const db = {
general: {
findFirst: vi.fn(async () => me),
findMany: vi.fn(async (args: { where?: Record<string, unknown>; select?: Record<string, boolean> }) => {
if (args.where?.nationId === 1 && args.select?.cityId) return [{ cityId: me.cityId }];
if (args.where?.cityId === 2) return [foreign];
if (args.where?.cityId === 3) return [foreign];
if (args.where?.officerLevel) return [];
return [];
}),
},
nation: {
findUnique: vi.fn(async () => ({
id: 1,
name: '아국',
color: '#008000',
level: 1,
capitalCityId: 1,
meta: options.nationMeta ?? {},
})),
findMany: vi.fn(async () => [
{ id: 1, name: '아국', color: '#008000', level: 1, capitalCityId: 1, meta: { power: 100 } },
{ id: 2, name: '적국', color: '#800000', level: 1, capitalCityId: 2, meta: { power: 90 } },
]),
},
city: { findMany: vi.fn(async () => cities) },
worldState: { findFirst: vi.fn(async () => ({ meta: { turntime: '2026-01-01' } })) },
generalTurn: { findMany: vi.fn(async () => []) },
diplomacy: { findMany: vi.fn(async () => []) },
};
const redis = { get: vi.fn(async () => null), set: vi.fn(async () => null) } as unknown as RedisConnector['client'];
const accessTokenStore = new RedisAccessTokenStore(redis, 'che:default');
return {
db: db as unknown as DatabaseClient,
redis,
turnDaemon: {} as GameApiContext['turnDaemon'],
battleSim: {} as GameApiContext['battleSim'],
profile: { id: 'che', scenario: 'default', name: 'che:default' },
auth: auth(options.roles),
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
accessTokenStore,
flushStore: new InMemoryFlushStore(),
gameTokenSecret: 'secret',
} satisfies GameApiContext;
};
describe('in-game information permissions', () => {
it('does not expose nation-only pages to a wandering general', async () => {
const caller = appRouter.createCaller(context({ me: general({ nationId: 0, officerLevel: 0 }) }));
await expect(caller.nation.getNationInfo()).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
await expect(caller.nation.getCityOverview()).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
});
it('lets a wandering general select only the current city', async () => {
const caller = appRouter.createCaller(context({ me: general({ nationId: 0, officerLevel: 0 }) }));
const result = await caller.world.getCurrentCity({ cityId: 2 });
expect(result.city.id).toBe(2);
expect(result.options.map((entry) => entry.id)).toEqual([1]);
expect(result.visibility.full).toBe(false);
expect(result.city.population).toBeNull();
expect(result.generals).toEqual([]);
});
it('keeps adjacent foreign detail redacted and never reveals military fields', async () => {
const result = await appRouter
.createCaller(context({ me: general({ cityId: 80 }) }))
.world.getCurrentCity({ cityId: 2 });
expect(result.visibility).toEqual({ full: false, detailed: true });
expect(result.city.agriculture).toBeNull();
expect(result.city.defence).toBeNull();
expect(result.generals[0]).toMatchObject({ crew: null, train: null, atmos: null, crewTypeId: null });
});
it('allows a spied city in full but still redacts foreign-general private details', async () => {
const result = await appRouter
.createCaller(context({ nationMeta: { spy: { 2: 2 } } }))
.world.getCurrentCity({ cityId: 2 });
expect(result.visibility.full).toBe(true);
expect(result.city.population).toBe(1000);
expect(result.generals[0]).toMatchObject({ crew: 777, train: null, atmos: null, crewTypeId: null });
});
it('allows administrative roles to inspect all city and general fields', async () => {
const result = await appRouter.createCaller(context({ roles: ['admin'] })).world.getCurrentCity({ cityId: 3 });
expect(result.options).toHaveLength(4);
expect(result.visibility.full).toBe(true);
expect(result.generals[0]).toMatchObject({ crew: 777, train: 90, atmos: 90, crewTypeId: 1 });
});
});
+256
View File
@@ -0,0 +1,256 @@
import { expect, test, type Page, type Route } from '@playwright/test';
const response = (data: unknown) => ({ result: { data } });
const operationNames = (route: Route) =>
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
const city = {
id: 1,
name: '업',
level: 8,
region: 1,
population: 150000,
populationMax: 620500,
agriculture: 1000,
agricultureMax: 12500,
commerce: 1000,
commerceMax: 11300,
security: 1000,
securityMax: 10000,
trust: 80,
trade: 100,
defence: 5000,
defenceMax: 11700,
wall: 5000,
wallMax: 12200,
supplyState: 1,
frontState: 0,
incomes: { gold: 1000, rice: 900, wall: 800 },
officers: { 2: null, 3: null, 4: { id: 1, name: '태수', npcState: 0, officerLevel: 4, cityId: 1, cityName: '업' } },
};
const map = {
result: true,
version: 0,
startYear: 180,
year: 200,
month: 1,
cityList: [[1, 8, 0, 1, 1, 1]],
nationList: [[1, '아국', '#008000', 1]],
spyList: {},
shownByGeneralList: [],
myCity: 1,
myNation: 1,
};
const layout = {
mapName: 'che',
cityList: [{ id: 1, name: '업', level: 8, region: 1, x: 345, y: 130, path: [] }],
regionMap: { 1: '하북' },
levelMap: { 8: '특' },
};
const install = async (page: Page, mode: 'member' | 'wanderer' | 'admin' = 'member') => {
await page.addInitScript(() => {
localStorage.setItem('sammo-game-token', 'ga_info');
localStorage.setItem('sammo-game-profile', 'che:default');
});
await page.route('**/image/game/**', (route) =>
route.fulfill({ status: 200, contentType: 'image/jpeg', body: Buffer.from('') })
);
await page.route('**/che/api/trpc/**', async (route) => {
const results = operationNames(route).map((operation) => {
if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: '장수' } });
if (operation === 'join.getConfig') return response({});
if (operation === 'nation.getNationInfo')
return response({
nation: {
id: 1,
name: '아국',
color: '#008000',
level: 1,
power: 1234,
gold: 10000,
rice: 9000,
tech: 100,
rate: 20,
bill: 100,
capitalCityId: 1,
generalCount: 2,
},
population: { current: 150000, max: 620500 },
crew: { current: 500, max: 7000 },
income: {
goldCity: 1000,
goldWar: 200,
goldTotal: 1200,
riceCity: 900,
riceWall: 800,
riceTotal: 1700,
outcome: 300,
},
budget: { gold: 10900, rice: 10400 },
cities: [{ id: 1, name: '업', capital: true }],
history: [{ id: 1, year: 200, month: 1, text: '건국했습니다.' }],
});
if (operation === 'nation.getCityOverview')
return response({
me: { id: 1, officerLevel: 1 },
nation: {
id: 1,
name: '아국',
color: '#008000',
level: 1,
typeCode: 'che_중립',
capitalCityId: 1,
rate: 20,
},
chiefStatMin: 65,
cities: [city],
generals: [
{
id: 1,
name: '장수',
npcState: 0,
officerLevel: 1,
cityId: 1,
officerCity: 0,
stats: { leadership: 70, strength: 60, intelligence: 50 },
},
],
});
if (operation === 'world.getGlobalInfo')
return response({
myNationId: 1,
nations: [
{
id: 1,
name: '아국',
color: '#008000',
capitalCityId: 1,
level: 1,
power: 1234,
cities: ['업'],
},
{
id: 2,
name: '적국',
color: '#800000',
capitalCityId: 2,
level: 1,
power: 1000,
cities: ['허창'],
},
],
diplomacy: { 1: { 1: 2, 2: 0 }, 2: { 1: 0, 2: 2 } },
conflict: [],
map,
});
if (operation === 'world.getMapLayout') return response(layout);
if (operation === 'world.getCurrentCity')
return response({
me: {
id: 1,
nationId: mode === 'wanderer' ? 0 : 1,
officerLevel: mode === 'wanderer' ? 0 : 1,
admin: mode === 'admin',
},
options: [{ id: 1, name: '업', nationId: 1 }],
visibility: { full: mode !== 'wanderer', detailed: mode !== 'wanderer' },
city: {
id: 1,
name: '업',
nationId: 1,
level: 8,
region: 1,
population: mode === 'wanderer' ? null : 150000,
populationMax: 620500,
agriculture: mode === 'wanderer' ? null : 1000,
agricultureMax: 12500,
commerce: mode === 'wanderer' ? null : 1000,
commerceMax: 11300,
security: mode === 'wanderer' ? null : 1000,
securityMax: 10000,
trust: mode === 'wanderer' ? null : 80,
trade: 100,
defence: mode === 'wanderer' ? null : 5000,
defenceMax: 11700,
wall: mode === 'wanderer' ? null : 5000,
wallMax: 12200,
officers: { 2: '-', 3: '-', 4: '태수' },
},
generals:
mode === 'wanderer'
? []
: [
{
id: 1,
name: '장수',
npcState: 0,
picture: null,
imageServer: 0,
nationId: 1,
nationName: '아국',
leadership: 70,
strength: 60,
intelligence: 50,
injury: 0,
officerLevel: 1,
defenceTrain: 80,
crewTypeId: 1,
crew: 500,
train: 90,
atmos: 90,
turns: ['징병'],
},
],
lastExecute: '2026-07-26',
});
return { error: { message: `unhandled ${operation}`, data: { code: 'BAD_REQUEST' } } };
});
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) });
});
};
const go = async (page: Page, path: string) => {
await page.goto(path);
};
test('four legacy menu pages keep the 1000px desktop table contract', async ({ page }) => {
await install(page);
await page.setViewportSize({ width: 1200, height: 900 });
for (const [path, selector] of [
['nation/info', '.legacy-info-page'],
['nation/cities', '.nation-cities-page'],
['global-info', '.global-page'],
['current-city', '.city-page'],
] as const) {
await go(page, path);
await expect(page.locator(selector)).toBeVisible();
const box = await page.locator(selector).evaluate((el) => {
const r = el.getBoundingClientRect();
const s = getComputedStyle(el);
return { x: r.x, width: r.width, fontSize: s.fontSize, fontFamily: s.fontFamily };
});
expect(box.width).toBe(1000);
expect(box.x).toBe(100);
expect(box.fontSize).toBe('14px');
expect(box.fontFamily).toContain('Pretendard');
expect(
await page
.locator('table')
.first()
.evaluate((el) => getComputedStyle(el).borderCollapse)
).toBe('collapse');
}
});
test('current-city hides values and general rows for a wandering user', async ({ page }) => {
await install(page, 'wanderer');
await go(page, 'current-city');
await expect(page.locator('.stats')).toContainText('?/620,500');
await expect(page.locator('.generals')).toHaveCount(0);
});
test('current-city exposes own general details to a member and admin fixture', async ({ page }) => {
await install(page, 'admin');
await go(page, 'current-city');
await expect(page.locator('.generals')).toContainText('장수');
await expect(page.locator('.generals')).toContainText('90');
});
+1 -1
View File
@@ -6,7 +6,7 @@ const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../.
export default defineConfig({
testDir: '.',
testMatch: 'troop.spec.ts',
testMatch: ['troop.spec.ts', 'inGameInfo.spec.ts'],
fullyParallel: false,
workers: 1,
timeout: 30_000,
+21
View File
@@ -6,6 +6,9 @@ import JoinView from '../views/JoinView.vue';
import InheritView from '../views/InheritView.vue';
import AuctionView from '../views/AuctionView.vue';
import NationCitiesView from '../views/NationCitiesView.vue';
import NationInfoView from '../views/NationInfoView.vue';
import GlobalInfoView from '../views/GlobalInfoView.vue';
import CurrentCityView from '../views/CurrentCityView.vue';
import NationGeneralsView from '../views/NationGeneralsView.vue';
import NationPersonnelView from '../views/NationPersonnelView.vue';
import NationStratFinanView from '../views/NationStratFinanView.vue';
@@ -83,6 +86,12 @@ const routes = [
requiresGeneral: true,
},
},
{
path: '/nation/info',
name: 'nation-info',
component: NationInfoView,
meta: { requiresAuth: true, requiresGeneral: true },
},
{
path: '/nation/cities',
name: 'nation-cities',
@@ -92,6 +101,18 @@ const routes = [
requiresGeneral: true,
},
},
{
path: '/global-info',
name: 'global-info',
component: GlobalInfoView,
meta: { requiresAuth: true, requiresGeneral: true },
},
{
path: '/current-city',
name: 'current-city',
component: CurrentCityView,
meta: { requiresAuth: true, requiresGeneral: true },
},
{
path: '/nation/affairs',
name: 'nation-affairs',
@@ -0,0 +1,240 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { formatOfficerLevelText, cityLevelMap, regionMap } from '../utils/nationFormat';
import { trpc } from '../utils/trpc';
type Result = Awaited<ReturnType<typeof trpc.world.getCurrentCity.query>>;
const data = ref<Result | null>(null);
const error = ref('');
const selected = ref<number>();
const show = (value: number | null) => (value === null ? '?' : value.toLocaleString('ko-KR'));
const load = async (cityId?: number) => {
try {
data.value = await trpc.world.getCurrentCity.query(cityId ? { cityId } : undefined);
selected.value = data.value.city.id;
error.value = '';
} catch (cause) {
error.value = cause instanceof Error ? cause.message : '도시 정보를 불러오지 못했습니다.';
}
};
const city = computed(() => data.value?.city);
onMounted(() => void load());
</script>
<template>
<main class="city-page">
<table class="legacy-table legacy-bg0 center">
<tbody>
<tr>
<td> <br /><RouterLink to="/">돌아가기</RouterLink></td>
</tr>
</tbody>
</table>
<table class="legacy-table legacy-bg0 selector">
<tbody>
<tr>
<td>
도시선택 :
<select v-model.number="selected" @change="load(selected)">
<option v-for="option in data?.options ?? []" :key="option.id" :value="option.id">
{{ option.name.padEnd(4, '_') }}{{
option.nationId === data?.me.nationId
? '본국'
: option.nationId === 0
? '공백지'
: '타국'
}}
</option>
</select>
<p>명령 화면에서 도시를 클릭하셔도 됩니다.</p>
</td>
</tr>
</tbody>
</table>
<p v-if="error" class="error">{{ error }}</p>
<template v-if="data && city">
<table class="legacy-table legacy-bg2 stats">
<tbody>
<tr>
<td colspan="11" class="city-title">
{{ regionMap[city.region] }} | {{ cityLevelMap[city.level] }} {{ city.name }}
</td>
<td class="city-title">{{ data.lastExecute }}</td>
</tr>
<tr>
<th>주민</th>
<td>{{ show(city.population) }}/{{ show(city.populationMax) }}</td>
<th>농업</th>
<td>{{ show(city.agriculture) }}/{{ show(city.agricultureMax) }}</td>
<th>상업</th>
<td>{{ show(city.commerce) }}/{{ show(city.commerceMax) }}</td>
<th>치안</th>
<td>{{ show(city.security) }}/{{ show(city.securityMax) }}</td>
<th>수비</th>
<td>{{ show(city.defence) }}/{{ show(city.defenceMax) }}</td>
<th>성벽</th>
<td>{{ show(city.wall) }}/{{ show(city.wallMax) }}</td>
</tr>
<tr>
<th>민심</th>
<td>{{ show(city.trust) }}</td>
<th>시세</th>
<td>{{ city.trade ?? '-' }}%</td>
<th>인구</th>
<td>
{{
city.population === null
? '?'
: ((city.population / city.populationMax) * 100).toFixed(2)
}}%
</td>
<th>태수</th>
<td>{{ city.officers[4] }}</td>
<th>군사</th>
<td>{{ city.officers[3] }}</td>
<th>종사</th>
<td>{{ city.officers[2] }}</td>
</tr>
<tr>
<th>장수</th>
<td colspan="11">
{{
data.visibility.detailed
? data.generals.map((g) => g.name).join(', ') || '-'
: '알 수 없음'
}}
</td>
</tr>
</tbody>
</table>
<table v-if="data.visibility.detailed" class="legacy-table legacy-bg0 generals">
<thead>
<tr>
<th> </th>
<th> </th>
<th>통솔</th>
<th>무력</th>
<th>지력</th>
<th> </th>
<th></th>
<th> </th>
<th> </th>
<th>훈련</th>
<th>사기</th>
<th> </th>
</tr>
</thead>
<tbody>
<tr v-for="general in data.generals" :key="general.id">
<td>
<img
v-if="general.picture"
width="64"
height="64"
:src="`/image/general/${general.picture}`"
/>
</td>
<td>{{ general.name }}</td>
<td>{{ general.leadership }}</td>
<td>{{ general.strength }}</td>
<td>{{ general.intelligence }}</td>
<td>{{ formatOfficerLevelText(general.officerLevel) }}</td>
<td>{{ general.defenceTrain ?? '?' }}</td>
<td>{{ general.crewTypeId ?? '?' }}</td>
<td>{{ general.crew ?? '?' }}</td>
<td>{{ general.train ?? '?' }}</td>
<td>{{ general.atmos ?? '?' }}</td>
<td class="turns">
{{
general.turns.length
? general.turns.map((turn, index) => `${index + 1} : ${turn}`).join(' / ')
: general.npcState > 1
? 'NPC 장수'
: `${general.nationName}】 장수`
}}
</td>
</tr>
</tbody>
</table>
</template>
<table class="legacy-table legacy-bg0 center footer">
<tbody>
<tr>
<td><RouterLink to="/">돌아가기</RouterLink></td>
</tr>
</tbody>
</table>
</main>
</template>
<style scoped>
.city-page {
width: 1000px;
margin: 0 auto;
font-size: 14px;
}
.legacy-table {
width: 100%;
border-collapse: collapse;
}
.legacy-table td,
.legacy-table th {
border: 1px solid #777;
padding: 3px;
font-weight: 400;
}
.center {
text-align: center;
}
.selector {
text-align: center;
margin-top: 0;
}
.selector select {
display: inline-block;
min-width: 400px;
}
.stats {
margin-top: 14px;
}
.stats th,
.generals th {
background-image: url('/image/game/back_green.jpg');
text-align: center;
}
.stats td {
text-align: center;
}
.city-title {
text-align: center;
}
.generals {
margin-top: 14px;
}
.generals td {
text-align: center;
}
.generals td:last-child {
text-align: left;
padding-left: 1em;
}
.turns {
font-size: x-small;
}
.footer {
margin-top: 14px;
}
.error {
text-align: center;
color: #ff7373;
}
@media (max-width: 700px) {
.city-page {
width: 1000px;
transform-origin: top left;
}
.selector select {
min-width: 300px;
}
}
</style>
@@ -0,0 +1,243 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import MapViewer from '../components/main/MapViewer.vue';
import { trpc } from '../utils/trpc';
type Result = Awaited<ReturnType<typeof trpc.world.getGlobalInfo.query>>;
type Layout = Awaited<ReturnType<typeof trpc.world.getMapLayout.query>>;
const data = ref<Result | null>(null);
const layout = ref<Layout | null>(null);
const error = ref('');
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]) ?? []));
onMounted(async () => {
try {
[data.value, layout.value] = await Promise.all([
trpc.world.getGlobalInfo.query(),
trpc.world.getMapLayout.query(),
]);
} catch (cause) {
error.value = cause instanceof Error ? cause.message : '중원 정보를 불러오지 못했습니다.';
}
});
</script>
<template>
<main class="global-page legacy-bg0">
<table class="legacy-title">
<tbody>
<tr>
<td> <br /><RouterLink to="/">돌아가기</RouterLink></td>
</tr>
</tbody>
</table>
<p v-if="error" class="error">{{ error }}</p>
<section v-if="data" class="section">
<h2 class="blue">외교 현황</h2>
<div class="matrix-wrap">
<table class="matrix">
<thead>
<tr>
<th></th>
<th
v-for="nation in data.nations"
:key="nation.id"
class="vertical"
:style="{ backgroundColor: nation.color }"
>
{{ nation.name }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="me in data.nations" :key="me.id">
<th :style="{ backgroundColor: me.color }">{{ me.name }}</th>
<td
v-for="you in data.nations"
:key="you.id"
:class="[
stateClass(data.diplomacy[me.id]?.[you.id] ?? 2),
{ mine: me.id === data.myNationId || you.id === data.myNationId },
]"
>
{{ me.id === you.id ? '' : state(data.diplomacy[me.id]?.[you.id] ?? 2) }}
</td>
</tr>
</tbody>
<tfoot>
<tr>
<td :colspan="data.nations.length + 1">
불가침 : <b class="state-7">@</b>, 통상 : , 선포 : <b class="state-1"></b>, 교전 :
<b class="state-0"></b>
</td>
</tr>
</tfoot>
</table>
</div>
</section>
<section v-if="data?.conflict.length" class="section">
<h2 class="magenta">분쟁 현황</h2>
<div v-for="conflict in data.conflict" :key="conflict.cityId" class="conflict">
<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 }">{{
nationMap.get(Number(id))?.name
}}</span
><em>{{ percent.toFixed(1) }}%</em
><i :style="{ width: `${percent}%`, backgroundColor: nationMap.get(Number(id))?.color }" />
</div>
</div>
</div>
</section>
<section v-if="data && layout" class="section map-section">
<h2 class="green">중원 지도</h2>
<div class="map-grid">
<MapViewer :map-data="data.map" :map-layout="layout" :loading="false" />
<div class="nation-list">
<div v-for="nation in data.nations" :key="nation.id">
<b :style="{ color: nation.color }">{{ nation.name }}</b> {{ nation.power.toLocaleString()
}}<br /><small>{{ nation.cities.join(', ') }}</small>
</div>
</div>
</div>
</section>
<table class="legacy-title footer">
<tbody>
<tr>
<td><RouterLink to="/">돌아가기</RouterLink></td>
</tr>
</tbody>
</table>
</main>
</template>
<style scoped>
.global-page {
width: 1000px;
margin: 0 auto;
font-size: 14px;
}
.legacy-title {
width: 100%;
border-collapse: collapse;
text-align: center;
}
.legacy-title td {
border: 1px solid #777;
padding: 4px;
}
.section {
margin-top: 21px;
}
.section h2 {
font-size: 16.8px;
font-weight: 400;
text-align: center;
margin: 0;
border-block: 1px solid #777;
padding: 2px;
}
.blue {
background: #00f;
}
.magenta {
background: #f0f;
}
.green {
background: green;
}
.matrix-wrap {
overflow-x: auto;
}
.matrix {
margin: auto;
min-width: 400px;
border-collapse: collapse;
text-align: center;
}
.matrix th {
font-weight: 400;
}
.matrix .vertical {
writing-mode: vertical-rl;
text-align: end;
padding: 1ch 0;
min-width: 1ch;
max-width: 3ch;
}
.matrix tbody th {
padding: 2px 1ch;
text-align: right;
min-width: 10ch;
}
.matrix td {
border-left: 1px solid gray;
border-top: 1px solid gray;
padding: 0;
}
.matrix td.mine {
background: #600;
}
.state-0 {
color: red;
}
.state-1 {
color: #f0f;
}
.state-7 {
color: #32cd32;
}
.conflict {
display: grid;
grid-template-columns: 16ch 1fr;
}
.conflict > strong {
text-align: right;
padding-right: 1ch;
align-self: center;
}
.conflict-row {
display: grid;
grid-template-columns: 16ch 6ch 1fr;
align-items: center;
}
.conflict-row span {
padding-left: 1ch;
}
.conflict-row em {
text-align: right;
padding-right: 0.5ch;
font-style: normal;
}
.conflict-row i {
height: 1.2em;
}
.map-grid {
display: grid;
grid-template-columns: 700px 300px;
}
.nation-list > div {
padding: 6px;
border-bottom: 1px solid #666;
}
.footer {
margin-top: 20px;
}
.error {
text-align: center;
color: #ff7373;
}
@media (max-width: 700px) {
.global-page {
width: 500px;
}
.map-grid {
grid-template-columns: 500px;
}
.nation-list {
width: 500px;
}
}
</style>
+3
View File
@@ -91,7 +91,10 @@ watch(
<p class="page-subtitle">{{ statusLine }}</p>
</div>
<div class="header-actions">
<RouterLink class="ghost" to="/nation/info">세력 정보</RouterLink>
<RouterLink class="ghost" to="/nation/cities">세력 도시</RouterLink>
<RouterLink class="ghost" to="/global-info">중원 정보</RouterLink>
<RouterLink class="ghost" to="/current-city">현재 도시</RouterLink>
<RouterLink class="ghost" to="/nation/generals">세력 장수</RouterLink>
<RouterLink class="ghost" to="/nation/personnel">인사부</RouterLink>
<RouterLink class="ghost" to="/troop">부대 편성</RouterLink>
+155 -552
View File
@@ -1,582 +1,185 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue';
import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import { trpc } from '../utils/trpc';
import { computed, onMounted, ref } from 'vue';
import { cityLevelMap, regionMap } from '../utils/nationFormat';
import { trpc } from '../utils/trpc';
type CityOverviewResponse = Awaited<ReturnType<typeof trpc.nation.getCityOverview.query>>;
type CityEntry = CityOverviewResponse['cities'][number];
type GeneralEntry = CityOverviewResponse['generals'][number];
type CitySortKey = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
type OfficerLevel = 2 | 3 | 4;
const officerLabels: Record<OfficerLevel, string> = {
4: '태수',
3: '군사',
2: '종사',
};
const officerLevels: OfficerLevel[] = [4, 3, 2];
const sortOptions: Array<{ key: CitySortKey; label: string }> = [
{ key: 1, label: '기본' },
{ key: 2, label: '인구' },
{ key: 3, label: '인구율' },
{ key: 4, label: '민심' },
{ key: 5, label: '농업' },
{ key: 6, label: '상업' },
{ key: 7, label: '치안' },
{ key: 8, label: '수비' },
{ key: 9, label: '성벽' },
{ key: 10, label: '시세' },
{ key: 11, label: '지역' },
{ key: 12, label: '규모' },
];
const loading = ref(false);
const error = ref<string | null>(null);
const data = ref<CityOverviewResponse | null>(null);
const sortKey = ref<CitySortKey>(1);
const filterText = ref('');
const showAppointment = ref(false);
const appointmentDraft = reactive<Record<number, Record<number, number>>>({});
const resolveErrorMessage = (value: unknown): string => {
if (value instanceof Error) {
return value.message;
}
if (typeof value === 'string') {
return value;
}
return 'unknown_error';
};
const loadCities = async () => {
if (loading.value) {
return;
}
loading.value = true;
error.value = null;
type Result = Awaited<ReturnType<typeof trpc.nation.getCityOverview.query>>;
type City = Result['cities'][number];
type Sort = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
const data = ref<Result | null>(null);
const error = ref('');
const sort = ref<Sort>(10);
const options = ['기본', '인구', '인구율', '민심', '농업', '상업', '치안', '수비', '성벽', '시세', '지역', '규모'];
const generalNames = (cityId: number) =>
data.value?.generals
.filter((g) => g.cityId === cityId)
.map((g) => g.name)
.join(', ') || '-';
const cities = computed(() =>
[...(data.value?.cities ?? [])].sort((a, b) => {
const key = sort.value;
if (key === 1) return a.id - b.id;
if (key === 2) return b.population - a.population;
if (key === 3) return b.population / b.populationMax - a.population / a.populationMax;
if (key === 4) return b.trust - a.trust;
if (key === 5) return b.agriculture - a.agriculture;
if (key === 6) return b.commerce - a.commerce;
if (key === 7) return b.security - a.security;
if (key === 8) return b.defence - a.defence;
if (key === 9) return b.wall - a.wall;
if (key === 10) return (b.trade ?? -1) - (a.trade ?? -1);
if (key === 11) return a.region - b.region || b.level - a.level;
return b.level - a.level || a.region - b.region;
})
);
const officer = (city: City, level: 2 | 3 | 4) => city.officers[level]?.name ?? '-';
onMounted(async () => {
try {
data.value = await trpc.nation.getCityOverview.query();
} catch (err) {
error.value = resolveErrorMessage(err);
} finally {
loading.value = false;
} catch (cause) {
error.value = cause instanceof Error ? cause.message : '세력 도시를 불러오지 못했습니다.';
}
};
const cityNameMap = computed(() => {
const map = new Map<number, string>();
if (data.value) {
for (const city of data.value.cities) {
map.set(city.id, city.name);
}
}
return map;
});
const generalMap = computed(() => {
const map = new Map<number, GeneralEntry>();
for (const general of data.value?.generals ?? []) {
map.set(general.id, general);
}
return map;
});
const generalsByCity = computed(() => {
const map = new Map<number, GeneralEntry[]>();
for (const general of data.value?.generals ?? []) {
if (!map.has(general.cityId)) {
map.set(general.cityId, []);
}
map.get(general.cityId)?.push(general);
}
return map;
});
const canAppoint = computed(() => (data.value?.me.officerLevel ?? 0) >= 5);
const candidatesByLevel = computed(() => {
const minStat = data.value?.chiefStatMin ?? 0;
const base = (data.value?.generals ?? []).filter((general) => general.officerLevel !== 12);
return {
4: base.filter((general) => general.stats.strength >= minStat),
3: base.filter((general) => general.stats.intelligence >= minStat),
2: base,
} as Record<OfficerLevel, GeneralEntry[]>;
});
const formatCandidateLabel = (general: GeneralEntry): string => {
const cityName = cityNameMap.value.get(general.cityId);
const role = general.officerLevel >= 5 ? '수뇌' : general.officerLevel >= 2 ? '관직' : '일반';
return `${general.name}${cityName ? ` (${cityName})` : ''} · ${role}`;
};
const ensureDraft = (cityId: number, level: OfficerLevel, defaultValue: number) => {
if (!appointmentDraft[cityId]) {
appointmentDraft[cityId] = { 2: 0, 3: 0, 4: 0 };
}
if (appointmentDraft[cityId][level] === undefined) {
appointmentDraft[cityId][level] = defaultValue;
}
};
const resetDrafts = (cities: CityEntry[]) => {
for (const key of Object.keys(appointmentDraft)) {
delete appointmentDraft[Number(key)];
}
for (const city of cities) {
appointmentDraft[city.id] = {
2: city.officers[2]?.id ?? 0,
3: city.officers[3]?.id ?? 0,
4: city.officers[4]?.id ?? 0,
};
}
};
watch(
() => data.value,
(value) => {
if (value) {
resetDrafts(value.cities);
}
}
);
const sortedCities = computed(() => {
const list = data.value?.cities ?? [];
const keyword = filterText.value.trim();
const filtered = keyword
? list.filter((city) => city.name.includes(keyword))
: list;
return [...filtered].sort((lhs, rhs) => {
switch (sortKey.value) {
case 2:
return rhs.population - lhs.population;
case 3:
return rhs.population / rhs.populationMax - lhs.population / lhs.populationMax;
case 4:
return rhs.trust - lhs.trust;
case 5:
return rhs.agriculture - lhs.agriculture;
case 6:
return rhs.commerce - lhs.commerce;
case 7:
return rhs.security - lhs.security;
case 8:
return rhs.defence - lhs.defence;
case 9:
return rhs.wall - lhs.wall;
case 10:
return (rhs.trade ?? -1) - (lhs.trade ?? -1);
case 11: {
const regionCmp = lhs.region - rhs.region;
if (regionCmp !== 0) {
return regionCmp;
}
return rhs.level - lhs.level;
}
case 12: {
const levelCmp = rhs.level - lhs.level;
if (levelCmp !== 0) {
return levelCmp;
}
return lhs.region - rhs.region;
}
default:
return lhs.id - rhs.id;
}
});
});
const formatPercent = (value: number, max: number): string => {
if (!max) {
return '-';
}
return `${((value / max) * 100).toFixed(1)}%`;
};
const formatNumber = (value: number): string => new Intl.NumberFormat('ko-KR').format(value);
const resolveOfficerName = (officer: CityEntry['officers'][OfficerLevel]): string => {
if (!officer) {
return '-';
}
if (officer.cityName) {
return `${officer.name} (${officer.cityName})`;
}
return officer.name;
};
const appointOfficer = async (cityId: number, officerLevel: OfficerLevel) => {
if (!data.value) {
return;
}
const city = data.value.cities.find((entry) => entry.id === cityId);
if (!city) {
return;
}
const destGeneralId = appointmentDraft[cityId]?.[officerLevel] ?? 0;
const general = generalMap.value.get(destGeneralId);
const officerLabel = officerLabels[officerLevel];
const targetName = destGeneralId === 0 ? '공석' : general?.name ?? '알 수 없음';
const message =
destGeneralId === 0
? `${city.name} ${officerLabel} 자리를 비우시겠습니까?`
: `${targetName}을(를) ${city.name} ${officerLabel}로 임명하시겠습니까?`;
if (!window.confirm(message)) {
return;
}
try {
await trpc.nation.appoint.mutate({
destGeneralId,
destCityId: cityId,
officerLevel,
});
await loadCities();
} catch (err) {
error.value = resolveErrorMessage(err);
}
};
onMounted(() => {
void loadCities();
});
</script>
<template>
<main class="nation-page">
<header class="page-header">
<div>
<h1 class="page-title">세력 도시</h1>
<p class="page-subtitle"> 도시 현황과 관직 배치를 확인합니다.</p>
</div>
<div class="header-actions">
<RouterLink class="ghost" to="/">메인</RouterLink>
<RouterLink class="ghost" to="/nation/generals">세력 장수</RouterLink>
<RouterLink class="ghost" to="/nation/personnel">인사부</RouterLink>
<button class="ghost" @click="loadCities">새로고침</button>
</div>
</header>
<div v-if="error" class="error">{{ error }}</div>
<PanelCard title="세력 도시 목록" subtitle="도시별 개발 상태 및 관직 현황">
<template #actions>
<div class="toolbar-actions">
<select v-model.number="sortKey" class="select-input">
<option v-for="option in sortOptions" :key="option.key" :value="option.key">
{{ option.label }}
</option>
</select>
<input v-model="filterText" class="filter-input" placeholder="도시 검색" />
<button
v-if="canAppoint"
class="ghost"
:class="{ active: showAppointment }"
@click="showAppointment = !showAppointment"
>
관직 임명 모드
</button>
</div>
</template>
<div class="list-meta">
{{ sortedCities.length }} 도시 · 관직 최소 능력 {{ data?.chiefStatMin ?? '-' }}
</div>
<SkeletonLines v-if="loading" :lines="8" />
<section v-else class="city-grid">
<article v-for="city in sortedCities" :key="city.id" class="city-card">
<div class="city-header">
<div>
<div class="city-title">
{{ city.name }}
<span v-if="data?.nation.capitalCityId === city.id" class="capital-tag">수도</span>
</div>
<div class="city-meta">
{{ regionMap[city.region] ?? '미지' }} · {{ cityLevelMap[city.level] ?? '-' }} 규모
</div>
</div>
<div class="city-meta">시세 {{ city.trade === null ? '- ' : city.trade }}%</div>
</div>
<div class="city-stats">
<div>
주민 {{ city.population }}/{{ city.populationMax }}
<span class="muted">({{ formatPercent(city.population, city.populationMax) }})</span>
</div>
<div>농업 {{ city.agriculture }}/{{ city.agricultureMax }}</div>
<div>상업 {{ city.commerce }}/{{ city.commerceMax }}</div>
<div>치안 {{ city.security }}/{{ city.securityMax }}</div>
<div>수비 {{ city.defence }}/{{ city.defenceMax }}</div>
<div>성벽 {{ city.wall }}/{{ city.wallMax }}</div>
<div>민심 {{ city.trust }}</div>
</div>
<div class="city-incomes">
<div>자금 수입 {{ formatNumber(city.incomes.gold) }}</div>
<div>군량 수입 {{ formatNumber(city.incomes.rice) }}</div>
<div>둔전 수입 {{ formatNumber(city.incomes.wall) }}</div>
</div>
<div class="city-officers">
<div>
<span class="officer-label">태수</span>
{{ resolveOfficerName(city.officers[4]) }}
</div>
<div>
<span class="officer-label">군사</span>
{{ resolveOfficerName(city.officers[3]) }}
</div>
<div>
<span class="officer-label">종사</span>
{{ resolveOfficerName(city.officers[2]) }}
</div>
</div>
<div v-if="showAppointment && canAppoint" class="city-appoint">
<div v-for="level in officerLevels" :key="level" class="appoint-row">
<span class="officer-label">{{ officerLabels[level] }}</span>
<select
v-model.number="appointmentDraft[city.id][level]"
class="select-input"
@focus="ensureDraft(city.id, level, city.officers[level]?.id ?? 0)"
>
<option :value="0">공석</option>
<option
v-for="candidate in candidatesByLevel[level]"
:key="candidate.id"
:value="candidate.id"
>
{{ formatCandidateLabel(candidate) }}
</option>
</select>
<button class="ghost" @click="appointOfficer(city.id, level)">임명</button>
</div>
</div>
<div class="city-generals">
<div class="muted">장수</div>
<div class="general-tags">
<span
v-for="general in generalsByCity.get(city.id) ?? []"
:key="general.id"
class="general-tag"
>
<span v-if="general.npcState > 0" class="npc-tag">NPC</span>
{{ general.name }}
</span>
<span v-if="(generalsByCity.get(city.id) ?? []).length === 0" class="muted">-</span>
</div>
</div>
</article>
</section>
</PanelCard>
<main class="nation-cities-page">
<table class="legacy-table legacy-bg0 title">
<tbody>
<tr>
<td> <br /><RouterLink to="/">돌아가기</RouterLink></td>
</tr>
<tr>
<td>
정렬순서 :
<select v-model.number="sort">
<option v-for="(label, index) in options" :key="label" :value="index + 1">
{{ label }}
</option>
</select>
<button class="legacy-button">정렬하기</button>
</td>
</tr>
</tbody>
</table>
<p v-if="error" class="error">{{ error }}</p>
<table v-for="city in cities" :key="city.id" class="legacy-table city legacy-bg2">
<tbody>
<tr>
<td colspan="10" class="city-title" :style="{ backgroundColor: data?.nation.color }">
{{ regionMap[city.region] }} | {{ cityLevelMap[city.level] }}
<span :class="{ capital: city.id === data?.nation.capitalCityId }">{{
city.id === data?.nation.capitalCityId ? `[${city.name}]` : city.name
}}</span>
</td>
</tr>
<tr>
<th>주민</th>
<td>{{ city.population }}/{{ city.populationMax }}</td>
<th>인구율</th>
<td>{{ ((city.population / city.populationMax) * 100).toFixed(2) }}%</td>
<th>자금 수입</th>
<td>{{ city.incomes.gold.toLocaleString() }}</td>
<th>군량 수입</th>
<td>{{ city.incomes.rice.toLocaleString() }}</td>
<th>둔전 수입</th>
<td>{{ city.incomes.wall.toLocaleString() }}</td>
</tr>
<tr>
<th>농업</th>
<td>{{ city.agriculture }}/{{ city.agricultureMax }}</td>
<th>상업</th>
<td>{{ city.commerce }}/{{ city.commerceMax }}</td>
<th>치안</th>
<td>{{ city.security }}/{{ city.securityMax }}</td>
<th>수비</th>
<td>{{ city.defence }}/{{ city.defenceMax }}</td>
<th>성벽</th>
<td>{{ city.wall }}/{{ city.wallMax }}</td>
</tr>
<tr>
<th>민심</th>
<td>{{ city.trust.toFixed(1) }}</td>
<th>시세</th>
<td>{{ city.trade ?? '-' }}%</td>
<th>태수</th>
<td>{{ officer(city, 4) }}</td>
<th>군사</th>
<td>{{ officer(city, 3) }}</td>
<th>종사</th>
<td>{{ officer(city, 2) }}</td>
</tr>
<tr>
<th>장수</th>
<td colspan="9" class="general-list">{{ generalNames(city.id) }}</td>
</tr>
</tbody>
</table>
<table class="legacy-table legacy-bg0 title footer">
<tbody>
<tr>
<td><RouterLink to="/">돌아가기</RouterLink></td>
</tr>
</tbody>
</table>
</main>
</template>
<style scoped>
.nation-page {
min-height: 100vh;
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
.nation-cities-page {
width: 1000px;
margin: 0 auto;
font-size: 14px;
}
.page-header {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid rgba(201, 164, 90, 0.4);
padding-bottom: 12px;
.legacy-table {
width: 100%;
border-collapse: collapse;
}
.page-title {
font-size: 1.6rem;
font-weight: 600;
.legacy-table td,
.legacy-table th {
border: 1px solid #777;
padding: 3px;
font-weight: 400;
}
.page-subtitle {
font-size: 0.85rem;
color: rgba(232, 221, 196, 0.7);
.title {
text-align: center;
}
.header-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
.city {
margin-top: 14px;
}
.ghost {
border: 1px solid rgba(201, 164, 90, 0.4);
padding: 6px 12px;
font-size: 0.8rem;
cursor: pointer;
text-decoration: none;
color: inherit;
background: rgba(16, 16, 16, 0.6);
.city th {
width: 60px;
text-align: center;
background-image: url('/image/game/back_green.jpg');
}
.ghost.active {
background: rgba(201, 164, 90, 0.2);
.city td {
width: 140px;
text-align: center;
}
.error {
color: #f5b7b1;
font-size: 0.85rem;
}
.toolbar-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.select-input {
border: 1px solid rgba(201, 164, 90, 0.4);
background: rgba(16, 16, 16, 0.8);
color: rgba(232, 221, 196, 0.9);
padding: 6px 8px;
font-size: 0.75rem;
}
.filter-input {
border: 1px solid rgba(201, 164, 90, 0.4);
background: rgba(16, 16, 16, 0.8);
color: rgba(232, 221, 196, 0.9);
padding: 6px 8px;
font-size: 0.75rem;
}
.list-meta {
margin-bottom: 12px;
font-size: 0.75rem;
color: rgba(232, 221, 196, 0.6);
}
.city-grid {
display: grid;
gap: 12px;
}
.city-card {
border: 1px solid rgba(201, 164, 90, 0.3);
padding: 12px;
background: rgba(12, 12, 12, 0.75);
display: flex;
flex-direction: column;
gap: 10px;
}
.city-header {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
}
.city-title {
font-size: 1rem;
font-weight: 600;
text-align: left !important;
}
.city-meta {
font-size: 0.75rem;
color: rgba(232, 221, 196, 0.6);
.general-list {
text-align: left !important;
}
.capital-tag {
margin-left: 6px;
font-size: 0.65rem;
padding: 2px 6px;
border: 1px solid rgba(201, 164, 90, 0.4);
color: rgba(232, 221, 196, 0.8);
.capital {
color: #0ff;
}
.city-stats,
.city-incomes,
.city-officers {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 6px;
font-size: 0.8rem;
.footer {
margin-top: 14px;
}
.city-incomes {
padding-top: 6px;
border-top: 1px solid rgba(201, 164, 90, 0.2);
.legacy-button {
padding: 1px 6px;
font-weight: 400;
}
.city-officers {
border-top: 1px solid rgba(201, 164, 90, 0.2);
padding-top: 6px;
.error {
text-align: center;
color: #ff7373;
}
.officer-label {
font-weight: 600;
margin-right: 6px;
}
.city-appoint {
display: flex;
flex-direction: column;
gap: 6px;
border-top: 1px dashed rgba(201, 164, 90, 0.2);
padding-top: 8px;
}
.appoint-row {
display: grid;
grid-template-columns: 60px minmax(0, 1fr) auto;
gap: 6px;
align-items: center;
}
.city-generals {
display: flex;
flex-direction: column;
gap: 6px;
}
.general-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.general-tag {
padding: 3px 6px;
border: 1px solid rgba(201, 164, 90, 0.3);
font-size: 0.75rem;
display: inline-flex;
align-items: center;
gap: 4px;
}
.npc-tag {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.6rem;
padding: 1px 3px;
border: 1px solid rgba(201, 164, 90, 0.4);
color: rgba(232, 221, 196, 0.8);
}
.muted {
color: rgba(232, 221, 196, 0.6);
@media (max-width: 700px) {
.nation-cities-page {
width: 1000px;
transform-origin: top left;
}
}
</style>
@@ -0,0 +1,177 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { trpc } from '../utils/trpc';
type Result = Awaited<ReturnType<typeof trpc.nation.getNationInfo.query>>;
const data = ref<Result | null>(null);
const error = ref('');
const number = (value: number) => value.toLocaleString('ko-KR');
const diff = (value: number) => `${value > 0 ? '+' : ''}${number(value)}`;
const nationLevel = computed(
() => ['두목', '영주', '군벌', '주자사', '주목', '공', '왕', '황제'][data.value?.nation.level ?? 0] ?? '-'
);
onMounted(async () => {
try {
data.value = await trpc.nation.getNationInfo.query();
} catch (cause) {
error.value = cause instanceof Error ? cause.message : '세력 정보를 불러오지 못했습니다.';
}
});
</script>
<template>
<main class="legacy-info-page">
<table class="legacy-table title-table legacy-bg0">
<tbody>
<tr>
<td> <br /><RouterLink to="/">돌아가기</RouterLink></td>
</tr>
</tbody>
</table>
<p v-if="error" class="error">{{ error }}</p>
<table v-if="data" class="legacy-table info-table legacy-bg2">
<tbody>
<tr>
<td colspan="8" class="nation-title" :style="{ backgroundColor: data.nation.color }">
{{ data.nation.name }}
</td>
</tr>
<tr>
<th>총주민</th>
<td>{{ number(data.population.current) }}/{{ number(data.population.max) }}</td>
<th>총병사</th>
<td>{{ number(data.crew.current) }}/{{ number(data.crew.max) }}</td>
<th> </th>
<td colspan="3">{{ data.nation.power }}</td>
</tr>
<tr>
<th> </th>
<td>{{ number(data.nation.gold) }}</td>
<th> </th>
<td>{{ number(data.nation.rice) }}</td>
<th> </th>
<td colspan="3">{{ data.nation.rate }} %</td>
</tr>
<tr>
<th>세금/단기</th>
<td>+{{ number(data.income.goldCity) }} / +{{ number(data.income.goldWar) }}</td>
<th>세곡/둔전</th>
<td>+{{ number(data.income.riceCity) }} / +{{ number(data.income.riceWall) }}</td>
<th>지급률</th>
<td colspan="3">{{ data.nation.bill }} %</td>
</tr>
<tr>
<th>수입/지출</th>
<td>+{{ number(data.income.goldTotal) }} / -{{ number(data.income.outcome) }}</td>
<th>수입/지출</th>
<td>+{{ number(data.income.riceTotal) }} / -{{ number(data.income.outcome) }}</td>
<th> </th>
<td>{{ data.cities.length }}</td>
<th> </th>
<td>{{ data.nation.generalCount }}</td>
</tr>
<tr>
<th>국고 예산</th>
<td>{{ number(data.budget.gold) }} ({{ diff(data.income.goldTotal - data.income.outcome) }})</td>
<th>병량 예산</th>
<td>{{ number(data.budget.rice) }} ({{ diff(data.income.riceTotal - data.income.outcome) }})</td>
<th>기술력</th>
<td>{{ number(data.nation.tech) }}</td>
<th> </th>
<td>{{ nationLevel }}</td>
</tr>
<tr>
<th>속령일람 :</th>
<td colspan="7">
<template v-for="(city, index) in data.cities" :key="city.id"
><span v-if="city.capital" class="capital">{{ city.name }}</span
><span v-else>{{ city.name }}</span
><span v-if="index + 1 < data.cities.length">, </span></template
>
</td>
</tr>
<tr>
<th>국가열전</th>
<td colspan="7" class="history legacy-bg0">
<div v-for="entry in data.history" :key="entry.id">
{{ entry.year }} {{ entry.month }}: {{ entry.text }}
</div>
<span v-if="!data.history.length">-</span>
</td>
</tr>
</tbody>
</table>
<table class="legacy-table footer-table legacy-bg0">
<tbody>
<tr>
<td><RouterLink to="/">돌아가기</RouterLink></td>
</tr>
</tbody>
</table>
</main>
</template>
<style scoped>
.legacy-info-page {
width: 1000px;
margin: 0 auto;
font-size: 14px;
}
.legacy-table {
width: 100%;
border-collapse: collapse;
}
.legacy-table td,
.legacy-table th {
border: 1px solid #777;
padding: 4px;
font-weight: 400;
}
.title-table,
.footer-table {
text-align: center;
}
.title-table {
margin-bottom: 14px;
}
.footer-table {
margin-top: 14px;
}
.info-table th {
width: 98px;
text-align: center;
background-image: url('/image/game/back_green.jpg');
}
.info-table td {
text-align: center;
}
.info-table td:nth-child(2),
.info-table td:nth-child(4) {
width: 198px;
}
.nation-title {
text-align: center;
}
.capital {
color: #0ff;
}
.history {
text-align: left !important;
}
.error {
color: #ff7373;
text-align: center;
}
@media (max-width: 700px) {
.legacy-info-page {
width: 500px;
}
.info-table {
font-size: 12px;
}
.info-table td,
.info-table th {
padding: 3px;
}
}
</style>
@@ -8,12 +8,21 @@
- 범위: 명령 결과, RNG 소비, 로그, 예약 턴 lifecycle, DB 영속화
현재 ref MariaDB와 core memory를 잇는 공통 runner, 성공 경로 55개,
실행 중 확률 실패 9개 full constraint fallback 7개가 구현됐다.
실행 중 확률 실패 9개, full constraint fallback 12개와 모략 확률 clamp
8개, 모략 결과값 경계 5개, 부상 경계 3개가 구현됐다.
실패 9개는 내정 critical
`주민선정/정착장려/상업투자/기술연구/물자조달`과 모략
`화계/선동/파괴/탈취`이며 RNG 전체 trace, semantic state delta와 실패
로그 본문을 비교한다. 제약 7개는 무소속, 방랑국, 타국 도시, 보급 단절,
금·쌀 부족과 민심 상한을 대표하며 휴식 fallback과 RNG/state delta를
로그 본문을 비교한다. 공통 제약 7개는 무소속, 방랑국, 타국 도시, 보급
단절, 금·쌀 부족과 민심 상한을 대표한다. 모략 제약 5개는 동일 도시,
중립 도시, 불가침국, 계략 비용 금·쌀 부족을 고정한다. 모두 휴식
fallback과 RNG/state delta를 비교한다. clamp 8개는
`화계/선동/파괴/탈취` 각각의 계산 확률 0과
0.5 경계에서 성공 판정 RNG의 무소비 또는 `nextBits(1)` 소비와 전체
state delta를 비교한다. 결과값 5개는 화계 농업·상업, 선동 치안·민심,
파괴 수비·성벽의 0 바닥과 탈취의 대상 국가 자원 상한·미보급 도시 최종
저장 상태를 비교한다. 부상 3개는 화계·선동·파괴의 대상 장수별 판정,
부상도 80 상한, 병력·훈련·사기 0.98 정수 저장과 대상 장수 로그를
비교한다. 나머지 명령별 제약 실패·값 경계·alternative와 전체 core
PostgreSQL 재조회가 완료 기준을 통과하기 전까지 55개 명령 전체의 동적
호환 상태를 `확인`으로 올리지 않는다.
@@ -578,9 +587,23 @@ fixture가 명시한 필드만 비교하면 예상하지 못한 side effect를
| `damage-clamp` | 낮은 농업·상업에서 0 미만 방지 |
| `constraint-denied` | 중립/같은 도시/자원/보급/불가침 제약과 queue fallback |
`success-basic`은 현재의 `City.meta.state``City.state` 혼동을 반드시
실패로 검출해야 한다. fixture가 해당 production line을 고의로 잘못
바꿨을 때 실패하고 복구하면 통과하는 것을 mutation audit에 기록한다.
`success-basic`과 확률 상한 fixture는 `City.meta.state``City.state`
혼동을 검출한다. 상한 fixture에서 실제로 이 결함을 발견해 화계 성공 시
물리 `City.state=32`를 저장하도록 수정했다. 같은 fixture 묶음에서 선동의
전체 city spread가 불필요한 front 재계산을 일으키는 문제와 MariaDB
`city.trust FLOAT` 재조회 정밀도 차이도 발견해 부분 patch와 6자리
유효숫자 저장 경계로 바로잡았다.
결과값 경계 fixture에서는 파괴의 전체 city spread도 선동과 같이
불필요한 front 재계산을 일으키는 문제를 발견해 수비·성벽·state 부분
patch로 좁혔다. 미보급 탈취는 레거시가 자원 감소 update 뒤 원본
`destCity.agri/comm`을 다시 저장하므로 관찰 가능한 최종 자원은 변하지
않는다. 버그로 보이지만 호환 계약으로 보존하고 `state=32`만 남긴다.
부상 경계 fixture에서는 화계의 `계략로 인해` 조사를 레거시
`계략으로 인해`로 수정하고, 선동·파괴에 누락된 대상 장수 부상 로그를
추가했다. 병력·훈련·사기의 0.98 감소는 ref 정수 DB 저장처럼 내림이 아닌
반올림을 적용한다.
## 55개 명령 coverage manifest
@@ -179,10 +179,27 @@ responses pass a separate reference trace and core resolver/API boundary.
Nine general in-action failure cases now also pass the common differential:
five domestic critical failures and four sabotage failures. They compare the
full command RNG trace, semantic state delta and the exact action-log body.
Seven full-constraint cases cover neutral status, wandering nation, city
ownership, supply, gold, rice and trust-cap rejection. Both engines replace
the denied command with rest, consume the same RNG and produce the same
semantic delta. This is not yet a claim that every command-specific
Seven common full-constraint cases cover neutral status, wandering nation,
city ownership, supply, gold, rice and trust-cap rejection. Five sabotage
constraint cases add the occupied-city target, neutral target, non-aggression
status and insufficient sabotage gold or rice. Both engines replace the denied
command with rest, consume the same RNG and produce the same semantic delta.
Eight sabotage clamp cases cover probability zero and 0.5 for
fire attack, agitation, destruction and seizure. The zero boundary skips the
success RNG primitive, while exactly 0.5 consumes `nextBits(1)`; the complete
RNG trace and semantic delta match. These cases also fixed fire-attack city
state persistence and agitation front/trust persistence differences.
Five sabotage value-boundary cases cover zero floors for fire-attack,
agitation and destruction city values, the supplied-nation resource cap for
seizure, and the legacy final state of unsupplied seizure. They also remove
destruction's unintended front recalculation and preserve the legacy
unsupplied-seizure overwrite that leaves city resources unchanged.
Three sabotage injury-boundary cases cover per-defender rolls, the injury cap
of 80, integer persistence after multiplying crew/train/atmosphere by 0.98,
and the target-general injury log for fire attack, agitation and destruction.
They restore the legacy Korean particle in the log, add the two missing logs,
and round rather than floor the integer fields.
This is not yet a claim that every command-specific
constraint, clamp, alternative and persistence boundary has been dynamically
compared.
@@ -46,6 +46,10 @@ const ARGS_SCHEMA = z.object({
});
export type AgitateArgs = z.infer<typeof ARGS_SCHEMA>;
// 레거시 city.trust는 MariaDB FLOAT이며 다음 명령에서 6자리 유효숫자로
// 재조회된다. 메모리 상태도 같은 persistence 경계로 정규화한다.
const toLegacyStoredTrust = (value: number): number => Number(value.toPrecision(6));
export class ActionResolver<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> implements GeneralActionResolver<TriggerState, AgitateArgs> {
@@ -95,7 +99,7 @@ export class ActionResolver<
general.meta.firenum = (typeof general.meta.firenum === 'number' ? general.meta.firenum : 0) + 1;
const newSecu = Math.max(0, destCity.security - result.agriDamage);
const currentTrust = typeof destCity.meta.trust === 'number' ? destCity.meta.trust : 50;
const newTrust = Math.max(0, currentTrust - result.commDamage);
const newTrust = toLegacyStoredTrust(Math.max(0, currentTrust - result.commDamage));
// Log
const commandName = ACTION_NAME;
@@ -118,7 +122,6 @@ export class ActionResolver<
effects.push(
createCityPatchEffect(
{
...destCity,
security: newSecu,
state: 32,
meta: {
@@ -133,6 +136,10 @@ export class ActionResolver<
consumeSuccessfulStrategyItem(this.pipeline, context);
for (const injured of result.injuredGenerals) {
effects.push(createGeneralPatchEffect(injured.patch, injured.id));
ctx.addLog('<M>계략</>으로 인해 <R>부상</>을 당했습니다.', {
generalId: injured.id,
format: LogFormat.MONTH,
});
}
return { effects };
@@ -140,14 +140,12 @@ export class ActionResolver<
)
);
} else {
const commDmg = stolenGold / 12;
const agriDmg = stolenRice / 12;
// 레거시는 미보급 도시 자원을 먼저 감소시키지만 같은 명령 끝의
// 원본 destCity 전체 저장이 이를 덮어쓴다. 관찰 가능한 최종
// 상태는 자원 변화 없이 state 32만 남는다.
effects.push(
createCityPatchEffect(
{
commerce: Math.round(Math.max(0, destCity.commerce - commDmg)),
agriculture: Math.round(Math.max(0, destCity.agriculture - agriDmg)),
state: 32,
},
args.destCityId
@@ -106,7 +106,6 @@ export class ActionResolver<
effects.push(
createCityPatchEffect(
{
...destCity,
defence: newDef,
wall: newWall,
state: 32, // Legacy sabotage state
@@ -118,6 +117,10 @@ export class ActionResolver<
consumeSuccessfulStrategyItem(this.pipeline, context);
for (const injured of result.injuredGenerals) {
effects.push(createGeneralPatchEffect(injured.patch, injured.id));
ctx.addLog('<M>계략</>으로 인해 <R>부상</>을 당했습니다.', {
generalId: injured.id,
format: LogFormat.MONTH,
});
}
return { effects };
@@ -1,12 +1,5 @@
import type { RandomGenerator } from '@sammo-ts/common';
import type {
City,
General,
GeneralMeta,
GeneralTriggerState,
Nation,
TriggerValue,
} from '@sammo-ts/logic/domain/entities.js';
import type { City, General, GeneralMeta, GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js';
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
import {
disallowDiplomacyBetweenStatus,
@@ -219,9 +212,9 @@ export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralT
id: defender.id,
patch: {
injury: clamp(defender.injury + injuryAmount, 0, INJURY_MAX),
crew: Math.floor(defender.crew * 0.98),
atmos: Math.floor(defender.atmos * 0.98),
train: Math.floor(defender.train * 0.98),
crew: Math.round(defender.crew * 0.98),
atmos: Math.round(defender.atmos * 0.98),
train: Math.round(defender.train * 0.98),
},
});
}
@@ -350,18 +343,13 @@ export class ActionResolver<
return { effects: [] };
}
const updatedCityMeta: Record<string, TriggerValue> = {
...context.destCity.meta,
state: CITY_STATE_BURNING,
};
// 타겟 도시는 Draft가 아니므로 Effect 반환
effects.push(
createCityPatchEffect(
{
agriculture: context.destCity.agriculture - result.agriDamage,
commerce: context.destCity.commerce - result.commDamage,
meta: updatedCityMeta,
state: CITY_STATE_BURNING,
},
context.destCity.id
)
@@ -398,7 +386,7 @@ export class ActionResolver<
for (const injured of result.injuredGenerals) {
// 타겟 장수는 Draft가 아니므로 Effect 반환
effects.push(createGeneralPatchEffect(injured.patch, injured.id));
context.addLog(`<M>${ACTION_KEY}</>로 인해 <R>부상</>을 당했습니다.`, {
context.addLog('<M>계략</>로 인해 <R>부상</>을 당했습니다.', {
generalId: injured.id,
format: LogFormat.MONTH,
});
@@ -485,6 +485,238 @@ integration('general command in-action failure matrix', () => {
);
});
type SabotageProbabilityClampCase = {
name: string;
action: 'che_화계' | 'che_선동' | 'che_파괴' | 'che_탈취';
stat: 'leadership' | 'strength' | 'intelligence';
boundary: 'zero' | 'max';
};
const sabotageProbabilityClampCases: SabotageProbabilityClampCase[] = (
[
['che_화계', 'intelligence'],
['che_선동', 'leadership'],
['che_파괴', 'strength'],
['che_탈취', 'strength'],
] as const
).flatMap(([action, stat]) => [
{ name: `${action} probability zero clamp`, action, stat, boundary: 'zero' },
{ name: `${action} probability 0.5 clamp`, action, stat, boundary: 'max' },
]);
integration('general sabotage probability clamp matrix', () => {
it.each(sabotageProbabilityClampCases)(
'$name preserves the legacy clamp and RNG primitive',
async ({ action, stat, boundary }) => {
const isZero = boundary === 'zero';
const request = buildRequest(
action,
{ destCityID: 70 },
{ [stat]: isZero ? 10 : 100 },
{
generals: { 2: { [stat]: isZero ? 100 : 10 } },
cities: {
70: {
security: isZero ? 2_000 : 0,
securityMax: 2_000,
supplyState: 1,
},
},
}
);
request.setup!.world!.hiddenSeed = `general-probability-clamp-${action}-${boundary}`;
const reference = runReferenceTurnCommandTraceRequest(
workspaceRoot!,
request as unknown as Record<string, unknown>
);
const core = await runCoreTurnCommandTrace(request, reference.before);
expect(reference.execution.outcome).toMatchObject({ completed: true });
expect(core.execution.outcome).toMatchObject({
requestedAction: action,
actionKey: action,
usedFallback: false,
});
expect(core.execution.outcome).not.toHaveProperty('blockedReason');
expect(reference.rng[0]).toMatchObject(
isZero
? { operation: 'nextInt', arguments: { maxInclusive: 99 } }
: { operation: 'nextBits', arguments: { bits: 1 } }
);
expect(core.rng).toEqual(reference.rng);
expect(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns: ignoredLifecyclePaths,
})
).toEqual([]);
},
120_000
);
});
type SabotageValueBoundaryCase = {
name: string;
action: 'che_화계' | 'che_선동' | 'che_파괴' | 'che_탈취';
stat: 'leadership' | 'strength' | 'intelligence';
fixturePatches: FixturePatches;
};
const sabotageValueBoundaryCases: SabotageValueBoundaryCase[] = [
{
name: 'fire attack does not reduce agriculture or commerce below zero',
action: 'che_화계',
stat: 'intelligence',
fixturePatches: { cities: { 70: { agriculture: 1, commerce: 1 } } },
},
{
name: 'agitation does not reduce security or trust below zero',
action: 'che_선동',
stat: 'leadership',
fixturePatches: { cities: { 70: { security: 1, trust: 1 } } },
},
{
name: 'destruction does not reduce defence or wall below zero',
action: 'che_파괴',
stat: 'strength',
fixturePatches: { cities: { 70: { defence: 1, wall: 1 } } },
},
{
name: 'seizure does not take more than supplied nation resources',
action: 'che_탈취',
stat: 'strength',
fixturePatches: { nations: { 2: { gold: 1, rice: 1 } } },
},
{
name: 'seizure does not reduce unsupplied city resources below zero',
action: 'che_탈취',
stat: 'strength',
fixturePatches: {
cities: { 70: { agriculture: 1, commerce: 1, supplyState: 0 } },
},
},
];
const hasSuccessfulSabotageLog = (logs: Array<Record<string, unknown>>): boolean =>
logs.some((entry) => typeof entry.text === 'string' && entry.text.includes('성공했습니다.'));
integration('general sabotage value boundary matrix', () => {
it.each(sabotageValueBoundaryCases)(
'$name matches the legacy clamped state delta',
async ({ action, stat, fixturePatches }) => {
const request = buildRequest(
action,
{ destCityID: 70 },
{ [stat]: 100 },
{
...fixturePatches,
generals: {
...fixturePatches.generals,
2: { ...fixturePatches.generals?.[2], [stat]: 10 },
},
cities: {
...fixturePatches.cities,
70: {
...fixturePatches.cities?.[70],
security: 0,
securityMax: 2_000,
},
},
}
);
request.setup!.world!.hiddenSeed = 'general-value-0';
const reference = runReferenceTurnCommandTraceRequest(
workspaceRoot!,
request as unknown as Record<string, unknown>
);
const core = await runCoreTurnCommandTrace(request, reference.before);
expect(reference.execution.outcome).toMatchObject({ completed: true });
expect(core.execution.outcome).toMatchObject({
requestedAction: action,
actionKey: action,
usedFallback: false,
});
expect(hasSuccessfulSabotageLog(reference.after.logs)).toBe(true);
expect(hasSuccessfulSabotageLog(core.after.logs)).toBe(true);
expect(core.rng).toEqual(reference.rng);
expect(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns: ignoredLifecyclePaths,
})
).toEqual([]);
},
120_000
);
});
type SabotageInjuryBoundaryCase = {
action: 'che_화계' | 'che_선동' | 'che_파괴';
stat: 'leadership' | 'strength' | 'intelligence';
hiddenSeed: string;
};
const sabotageInjuryBoundaryCases: SabotageInjuryBoundaryCase[] = [
{ action: 'che_화계', stat: 'intelligence', hiddenSeed: 'general-injury-4' },
{ action: 'che_선동', stat: 'leadership', hiddenSeed: 'general-injury-13' },
{ action: 'che_파괴', stat: 'strength', hiddenSeed: 'general-injury-4' },
];
const injuryLogTexts = (logs: Array<Record<string, unknown>>): string[] =>
logs
.map((entry) => entry.text)
.filter((text): text is string => typeof text === 'string' && text.includes('부상</>을 당했습니다.'));
const legacyInjuryLogBody = (text: string): string => text.replace(/^<C>●<\/>\d+월:/, '');
integration('general sabotage injury boundary matrix', () => {
it.each(sabotageInjuryBoundaryCases)(
'$action matches legacy injury cap, integer persistence, and log',
async ({ action, stat, hiddenSeed }) => {
const request = buildRequest(
action,
{ destCityID: 70 },
{ [stat]: 100 },
{
generals: {
2: {
[stat]: 10,
injury: 79,
crew: 101,
atmos: 51,
train: 51,
},
},
cities: { 70: { security: 0, securityMax: 2_000 } },
}
);
request.setup!.world!.hiddenSeed = hiddenSeed;
const reference = runReferenceTurnCommandTraceRequest(
workspaceRoot!,
request as unknown as Record<string, unknown>
);
const core = await runCoreTurnCommandTrace(request, reference.before);
expect(reference.execution.outcome).toMatchObject({ completed: true });
expect(core.execution.outcome).toMatchObject({
requestedAction: action,
actionKey: action,
usedFallback: false,
});
expect(injuryLogTexts(reference.after.logs)).toHaveLength(1);
expect(injuryLogTexts(core.after.logs)).toEqual(
injuryLogTexts(reference.after.logs).map(legacyInjuryLogBody)
);
expect(core.rng).toEqual(reference.rng);
expect(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns: ignoredLifecyclePaths,
})
).toEqual([]);
},
120_000
);
});
type GeneralConstraintCase = {
name: string;
action: string;
@@ -531,6 +763,40 @@ const constraintCases: GeneralConstraintCase[] = [
action: 'che_주민선정',
fixturePatches: { cities: { 3: { trust: 100 } } },
},
{
name: 'sabotage targets the occupied city',
action: 'che_화계',
args: { destCityID: 3 },
},
{
name: 'sabotage targets a neutral city',
action: 'che_화계',
args: { destCityID: 70 },
fixturePatches: { cities: { 70: { nationId: 0 } } },
},
{
name: 'sabotage targets a non-aggression nation',
action: 'che_화계',
args: { destCityID: 70 },
fixturePatches: {
diplomacy: {
'1:2': { state: 7 },
'2:1': { state: 7 },
},
},
},
{
name: 'insufficient sabotage gold',
action: 'che_화계',
args: { destCityID: 70 },
actorPatch: { gold: 0 },
},
{
name: 'insufficient sabotage rice',
action: 'che_화계',
args: { destCityID: 70 },
actorPatch: { rice: 0 },
},
];
integration('general command full-constraint fallback matrix', () => {