Merge branch 'main' into chore/build-toolchain-20260812

This commit is contained in:
2026-08-12 15:51:03 +00:00
25 changed files with 1177 additions and 166 deletions
+23
View File
@@ -10,6 +10,11 @@ export type BaseMapResult = {
startYear: number;
year: number;
month: number;
techLevelLimit: {
maxLevel: number;
initialLevel: number;
increaseYears: number;
};
cityList: MapCityCompact[];
nationList: MapNationCompact[];
};
@@ -64,6 +69,23 @@ const readState = (meta: Record<string, unknown>): number => {
return 0;
};
const readPositiveInteger = (value: unknown, fallback: number): number => {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return fallback;
}
const normalized = Math.floor(value);
return normalized > 0 ? normalized : fallback;
};
const resolveTechLevelLimit = (worldState: WorldStateRow): BaseMapResult['techLevelLimit'] => {
const constValues = asRecord(asRecord(worldState.config).const);
return {
maxLevel: readPositiveInteger(constValues.maxTechLevel, 12),
initialLevel: readPositiveInteger(constValues.initialAllowedTechLevel, 1),
increaseYears: readPositiveInteger(constValues.techLevelIncYear, 5),
};
};
const normalizeNumberRecord = (value: unknown): Record<number, number> => {
if (!isRecord(value)) {
return {};
@@ -180,6 +202,7 @@ const loadBaseMap = async (
startYear: resolveStartYear(worldState),
year: worldState.currentYear,
month: worldState.currentMonth,
techLevelLimit: resolveTechLevelLimit(worldState),
cityList,
nationList,
};
+34 -5
View File
@@ -18,7 +18,7 @@ import { ConflictingTurnDaemonCommandError } from '../../daemon/databaseTranspor
import { resolveAccessWindows } from '../../services/generalAccess.js';
import { adjustAccountIconForUser } from '../../services/accountIconSync.js';
import { getMyGeneral } from '../shared/general.js';
import { resolveNationNotice } from '../nation/shared.js';
import { loadTraitNames, resolveNationNotice, type TraitNameMap } from '../nation/shared.js';
const zGeneralSettings = z.object({
tnmt: z.number().int().optional(),
@@ -34,6 +34,17 @@ const zImmediateActionInput = z
})
.optional();
const MAIN_RECORD_LIMIT = 15;
const NEUTRAL_NATION_CONTEXT = {
id: 0,
name: '재야',
color: '#000000',
level: 0,
gold: 0,
rice: 0,
tech: 0,
typeCode: 'None',
capitalCityId: null,
} as const;
const resolveImmediateActionRequestId = (
contextRequestId: string | undefined,
@@ -135,6 +146,18 @@ const normalizeItemCode = (value: string | null): string | null => {
return value;
};
const resolveTraitDisplayName = (code: string, names: TraitNameMap): string => {
if (!code || code === 'None') {
return '-';
}
const loadedName = names.get(code)?.name;
if (loadedName) {
return loadedName;
}
// Ref는 class getName()을 표시하므로 로더가 모르는 선택적 특기도 raw namespace는 노출하지 않는다.
return code.replace(/^che_(?:event_)?/u, '');
};
const resolveUserSettings = (meta: Record<string, unknown>) => {
// The legacy general columns are persisted at the top level of General.meta.
// Keep reading the short-lived nested shape for installations that ran the
@@ -265,10 +288,16 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
capitalCityId: true,
},
})
: null,
: Promise.resolve(NEUTRAL_NATION_CONTEXT),
ctx.db.worldState.findFirst({ select: { config: true } }),
]);
const [personalityNames, domesticNames, warNames] = await Promise.all([
loadTraitNames([general.personalCode], 'personality'),
loadTraitNames([general.specialCode], 'domestic'),
loadTraitNames([general.special2Code], 'war'),
]);
const metaRecord = asRecord(general.meta);
const worldConfig = asRecord(worldState?.config);
const constValues = asRecord(worldConfig.const ?? worldConfig.consts);
@@ -303,9 +332,9 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
turnTime: general.turnTime.toISOString(),
crewTypeId: general.crewTypeId,
traits: {
personal: general.personalCode,
specialWar: general.specialCode,
specialDomestic: general.special2Code,
personal: resolveTraitDisplayName(general.personalCode, personalityNames),
specialDomestic: resolveTraitDisplayName(general.specialCode, domesticNames),
specialWar: resolveTraitDisplayName(general.special2Code, warNames),
},
progression: {
experienceLevel: readNumber(metaRecord.explevel, 0),
+8 -1
View File
@@ -19,6 +19,7 @@ export interface ReservedTurnView {
export interface ReservedTurnSnapshot {
revision: number;
turns: ReservedTurnView[];
autorunLimit?: number | null;
}
export interface ReservedTurnUpdate {
@@ -170,13 +171,19 @@ export const listGeneralTurns = async (db: DatabaseClient, generalId: number): P
};
export const getGeneralTurnSnapshot = async (db: DatabaseClient, generalId: number): Promise<ReservedTurnSnapshot> => {
const [turns, revisionRow] = await Promise.all([
const [turns, revisionRow, general] = await Promise.all([
loadGeneralTurns(db, generalId),
db.generalTurnRevision.findUnique({ where: { generalId } }),
db.general.findUnique({ where: { id: generalId }, select: { meta: true } }),
]);
const rawAutorunLimit = isRecord(general?.meta) ? general.meta.autorun_limit : undefined;
return {
revision: revisionRow?.revision ?? 0,
turns: serializeTurnList(turns),
autorunLimit:
typeof rawAutorunLimit === 'number' && Number.isFinite(rawAutorunLimit)
? Math.trunc(rawAutorunLimit)
: null,
};
};
@@ -248,6 +248,40 @@ describe('in-game my information ownership', () => {
);
});
it('returns the Ref-style neutral nation frame and trait display names on the main read model', async () => {
const fixture = createContext({
me: buildGeneral({
nationId: 0,
officerLevel: 0,
personalCode: 'che_안전',
specialCode: 'che_상재',
special2Code: 'che_신산',
}),
});
await expect(appRouter.createCaller(fixture.context).general.me()).resolves.toMatchObject({
general: {
traits: {
personal: '안전',
specialDomestic: '상재',
specialWar: '신산',
},
},
nation: {
id: 0,
name: '재야',
color: '#000000',
level: 0,
gold: 0,
rice: 0,
tech: 0,
typeCode: 'None',
capitalCityId: null,
},
});
expect(fixture.db.nation.findUnique).not.toHaveBeenCalled();
});
it('reads legacy top-level settings and dispatches only the session-owned general', async () => {
const requestCommand = vi.fn(async () => ({ type: 'setMySetting', ok: true, generalId: 7 }));
const fixture = createContext({ requestCommand });
@@ -26,7 +26,13 @@ const buildContext = () => {
findFirst: vi.fn(async () => ({
currentYear: 190,
currentMonth: 3,
config: {},
config: {
const: {
maxTechLevel: 10,
initialAllowedTechLevel: 2,
techLevelIncYear: 4,
},
},
meta: { scenarioMeta: { startYear: 184 } },
})),
},
@@ -38,12 +44,8 @@ const buildContext = () => {
},
$queryRaw: vi
.fn()
.mockResolvedValueOnce([
{ id: 1, level: 5, nationId: 1, region: 1, supplyState: 1, meta: { state: 0 } },
])
.mockResolvedValueOnce([
{ id: 1, name: '촉', color: '#ff0000', capitalCityId: 1, meta: {} },
]),
.mockResolvedValueOnce([{ id: 1, level: 5, nationId: 1, region: 1, supplyState: 1, meta: { state: 0 } }])
.mockResolvedValueOnce([{ id: 1, name: '촉', color: '#ff0000', capitalCityId: 1, meta: {} }]),
};
const context: GameApiContext = {
db: db as unknown as DatabaseClient,
@@ -70,6 +72,11 @@ describe('public.getCachedMap', () => {
expect(result).toMatchObject({
year: 190,
month: 3,
techLevelLimit: {
maxLevel: 10,
initialLevel: 2,
increaseYears: 4,
},
history: [
{ id: 9, text: '<Y>최근 정세</>' },
{ id: 8, text: '이전 정세' },
+2 -1
View File
@@ -824,7 +824,7 @@ describe('appRouter', () => {
});
it('returns reserved general turns', async () => {
const general = buildGeneralRow({ id: 11 });
const general = buildGeneralRow({ id: 11, meta: { autorun_limit: 2408 } });
const generalTurns: GeneralTurnRow[] = [
{
id: 1,
@@ -839,6 +839,7 @@ describe('appRouter', () => {
const response = await caller.turns.reserved.getGeneral({ generalId: 11 });
expect(response.revision).toBe(0);
expect(response.autorunLimit).toBe(2408);
expect(response.turns[0]?.action).toBe('che_화계');
expect(response.turns[0]?.index).toBe(0);
});