feat(game): match Ref nation basic card
This commit is contained in:
@@ -28,7 +28,20 @@ import {
|
||||
sanitizeInternalDisplayCode,
|
||||
} from '../../services/gameDisplayNames.js';
|
||||
import { getMyGeneral } from '../shared/general.js';
|
||||
import { loadTraitNames, resolveNationNotice, type TraitNameMap } from '../nation/shared.js';
|
||||
import {
|
||||
loadTraitNames,
|
||||
resolveNationBill,
|
||||
resolveNationBlockScout,
|
||||
resolveNationBlockWar,
|
||||
resolveNationNotice,
|
||||
resolveNationRate,
|
||||
type TraitNameMap,
|
||||
} from '../nation/shared.js';
|
||||
import {
|
||||
resolveImpossibleStrategicCommands,
|
||||
resolveMainNationTech,
|
||||
splitNationTraitInfo,
|
||||
} from '../../services/mainNationProjection.js';
|
||||
|
||||
const zGeneralSettings = z.object({
|
||||
tnmt: z.number().int().optional(),
|
||||
@@ -54,6 +67,7 @@ const NEUTRAL_NATION_CONTEXT = {
|
||||
tech: 0,
|
||||
typeCode: 'None',
|
||||
capitalCityId: null,
|
||||
meta: {},
|
||||
} as const;
|
||||
|
||||
const resolveImmediateActionRequestId = (
|
||||
@@ -296,20 +310,42 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
||||
tech: true,
|
||||
typeCode: true,
|
||||
capitalCityId: true,
|
||||
meta: true,
|
||||
},
|
||||
})
|
||||
: Promise.resolve(NEUTRAL_NATION_CONTEXT),
|
||||
ctx.db.worldState.findFirst({ select: { config: true } }),
|
||||
ctx.db.worldState.findFirst({ select: { currentYear: true, currentMonth: true, config: true, meta: true } }),
|
||||
]);
|
||||
const nation = queriedNation ?? NEUTRAL_NATION_CONTEXT;
|
||||
|
||||
const [capitalCity, cityNation] = await Promise.all([
|
||||
const [capitalCity, cityNation, nationPopulation, nationCrew, topChiefRows] = await Promise.all([
|
||||
nation.capitalCityId
|
||||
? ctx.db.city.findUnique({ where: { id: nation.capitalCityId }, select: { name: true } })
|
||||
: Promise.resolve(null),
|
||||
city && city.nationId > 0
|
||||
? ctx.db.nation.findUnique({ where: { id: city.nationId }, select: { name: true } })
|
||||
: Promise.resolve(null),
|
||||
nation.id > 0
|
||||
? ctx.db.city.aggregate({
|
||||
where: { nationId: nation.id },
|
||||
_count: true,
|
||||
_sum: { population: true, populationMax: true },
|
||||
})
|
||||
: Promise.resolve({ _count: 0, _sum: { population: 0, populationMax: 0 } }),
|
||||
nation.id > 0
|
||||
? ctx.db.general.aggregate({
|
||||
where: { nationId: nation.id, npcState: { not: 5 } },
|
||||
_count: true,
|
||||
_sum: { crew: true, leadership: true },
|
||||
})
|
||||
: Promise.resolve({ _count: 0, _sum: { crew: 0, leadership: 0 } }),
|
||||
nation.id > 0
|
||||
? ctx.db.general.findMany({
|
||||
where: { nationId: nation.id, officerLevel: { gte: 11 } },
|
||||
select: { id: true, name: true, npcState: true, officerLevel: true },
|
||||
orderBy: { id: 'asc' },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
const [personalityNames, domesticNames, warNames, nationTypeNames, crewTypeNames, itemNames] = await Promise.all([
|
||||
loadTraitNames([general.personalCode], 'personality'),
|
||||
@@ -327,6 +363,18 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
||||
const settings = resolveUserSettings(metaRecord);
|
||||
const penalties = resolvePenalty(general.penalty);
|
||||
const dedicationLevel = readNumber(metaRecord.dedlevel, 0);
|
||||
const nationMeta = asRecord(nation.meta);
|
||||
const nationType = nationTypeNames.get(nation.typeCode);
|
||||
const nationTypeEffects = splitNationTraitInfo(nationType?.info ?? '');
|
||||
const nationTech = resolveMainNationTech({
|
||||
tech: nation.tech,
|
||||
currentYear: worldState?.currentYear ?? 0,
|
||||
worldConfig: worldState?.config,
|
||||
worldMeta: worldState?.meta,
|
||||
});
|
||||
const topChiefs = Object.fromEntries(
|
||||
topChiefRows.map((chief) => [chief.officerLevel, { id: chief.id, name: chief.name, npcState: chief.npcState }])
|
||||
);
|
||||
const itemName = (code: string | null): string | null => {
|
||||
const normalized = normalizeItemCode(code);
|
||||
return normalized ? (itemNames.get(normalized) ?? sanitizeInternalDisplayCode(normalized)) : null;
|
||||
@@ -406,13 +454,48 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
||||
}
|
||||
: null,
|
||||
nation: {
|
||||
...nation,
|
||||
id: nation.id,
|
||||
name: nation.name,
|
||||
color: nation.color,
|
||||
level: nation.level,
|
||||
gold: nation.gold,
|
||||
rice: nation.rice,
|
||||
tech: nation.tech,
|
||||
typeCode: nation.typeCode,
|
||||
capitalCityId: nation.capitalCityId,
|
||||
levelName: resolveNationLevelName(nation.level),
|
||||
typeName:
|
||||
nation.id === 0
|
||||
? '해당 없음'
|
||||
: (nationTypeNames.get(nation.typeCode)?.name ?? sanitizeInternalDisplayCode(nation.typeCode)),
|
||||
typeName: nation.id === 0 ? '-' : (nationType?.name ?? sanitizeInternalDisplayCode(nation.typeCode)),
|
||||
typePros: nationTypeEffects.pros,
|
||||
typeCons: nationTypeEffects.cons,
|
||||
capitalCityName: nation.id === 0 ? null : (capitalCity?.name ?? null),
|
||||
population: {
|
||||
cityCount: nationPopulation._count,
|
||||
current: nationPopulation._sum.population ?? 0,
|
||||
max: nationPopulation._sum.populationMax ?? 0,
|
||||
},
|
||||
crew: {
|
||||
generalCount: nationCrew._count,
|
||||
current: nationCrew._sum.crew ?? 0,
|
||||
max: (nationCrew._sum.leadership ?? 0) * 100,
|
||||
},
|
||||
power: readNumber(nationMeta.power, 0),
|
||||
bill: resolveNationBill(nationMeta),
|
||||
taxRate: resolveNationRate(nation),
|
||||
strategicCommandLimit: readNumber(nationMeta.strategic_cmd_limit, 0),
|
||||
diplomaticLimit: readNumber(nationMeta.surlimit, 0),
|
||||
prohibitScout: resolveNationBlockScout(nationMeta),
|
||||
prohibitWar: resolveNationBlockWar(nationMeta),
|
||||
techLevel: nationTech.level,
|
||||
techLimited: nationTech.limited,
|
||||
topChiefs,
|
||||
impossibleStrategicCommands:
|
||||
nation.id === 0
|
||||
? []
|
||||
: resolveImpossibleStrategicCommands(
|
||||
nationMeta,
|
||||
worldState?.currentYear ?? 0,
|
||||
worldState?.currentMonth ?? 1
|
||||
),
|
||||
},
|
||||
settings,
|
||||
penalties,
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
|
||||
const STRATEGIC_COMMAND_NAMES = [
|
||||
'필사즉생',
|
||||
'백성동원',
|
||||
'수몰',
|
||||
'허보',
|
||||
'의병모집',
|
||||
'이호경식',
|
||||
'급습',
|
||||
'피장파장',
|
||||
] as const;
|
||||
|
||||
const readFiniteNumber = (value: unknown, fallback = 0): number => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const clamp = (value: number, min: number, max: number): number => Math.min(max, Math.max(min, value));
|
||||
|
||||
export const splitNationTraitInfo = (info: string): { pros: string; cons: string } => {
|
||||
const tokens = info.trim().split(/\s+/u).filter(Boolean);
|
||||
return {
|
||||
pros: tokens.filter((token) => token.endsWith('↑')).join(' '),
|
||||
cons: tokens.filter((token) => token.endsWith('↓')).join(' '),
|
||||
};
|
||||
};
|
||||
|
||||
export const resolveMainNationTech = (options: {
|
||||
tech: number;
|
||||
currentYear: number;
|
||||
worldConfig: unknown;
|
||||
worldMeta: unknown;
|
||||
}): { level: number; limited: boolean } => {
|
||||
const config = asRecord(options.worldConfig);
|
||||
const constValues = asRecord(config.const ?? config.consts);
|
||||
const scenarioMeta = asRecord(asRecord(options.worldMeta).scenarioMeta);
|
||||
const maxLevel = Math.max(1, Math.floor(readFiniteNumber(constValues.maxTechLevel, 12)));
|
||||
const initialLevel = Math.max(1, Math.floor(readFiniteNumber(constValues.initialAllowedTechLevel, 1)));
|
||||
const increaseYears = Math.max(1, Math.floor(readFiniteNumber(constValues.techLevelIncYear, 5)));
|
||||
const startYear = readFiniteNumber(scenarioMeta.startYear, options.currentYear);
|
||||
const relativeMaximum = clamp(
|
||||
Math.floor((options.currentYear - startYear) / increaseYears) + initialLevel,
|
||||
1,
|
||||
maxLevel
|
||||
);
|
||||
const level = clamp(Math.floor(options.tech / 1000), 0, maxLevel);
|
||||
return { level, limited: level >= relativeMaximum };
|
||||
};
|
||||
|
||||
export const resolveImpossibleStrategicCommands = (
|
||||
nationMeta: unknown,
|
||||
currentYear: number,
|
||||
currentMonth: number
|
||||
): Array<{ name: string; remainingTurns: number; availableYear: number; availableMonth: number }> => {
|
||||
const meta = asRecord(nationMeta);
|
||||
const currentYearMonth = Math.floor(currentYear) * 12 + Math.floor(currentMonth) - 1;
|
||||
const result: Array<{ name: string; remainingTurns: number; availableYear: number; availableMonth: number }> = [];
|
||||
|
||||
for (const name of STRATEGIC_COMMAND_NAMES) {
|
||||
const nextAvailable = Math.floor(readFiniteNumber(meta[`next_execute_${name}`], 0));
|
||||
if (nextAvailable <= currentYearMonth) continue;
|
||||
result.push({
|
||||
name,
|
||||
remainingTurns: nextAvailable - currentYearMonth,
|
||||
availableYear: Math.floor(nextAvailable / 12),
|
||||
availableMonth: (nextAvailable % 12) + 1,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
resolveImpossibleStrategicCommands,
|
||||
resolveMainNationTech,
|
||||
splitNationTraitInfo,
|
||||
} from '../src/services/mainNationProjection.js';
|
||||
|
||||
describe('main nation projection', () => {
|
||||
it('splits the Ref nation-type advantages and disadvantages without changing their order', () => {
|
||||
expect(splitNationTraitInfo('농상↑ 민심↑ 쌀수입↓')).toEqual({
|
||||
pros: '농상↑ 민심↑',
|
||||
cons: '쌀수입↓',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the scenario-relative Ref technology grade and limit', () => {
|
||||
expect(
|
||||
resolveMainNationTech({
|
||||
tech: 3_999,
|
||||
currentYear: 190,
|
||||
worldConfig: {
|
||||
const: { maxTechLevel: 12, initialAllowedTechLevel: 1, techLevelIncYear: 5 },
|
||||
},
|
||||
worldMeta: { scenarioMeta: { startYear: 180 } },
|
||||
})
|
||||
).toEqual({ level: 3, limited: true });
|
||||
});
|
||||
|
||||
it('returns only strategic commands whose Ref-compatible cooldown is still active', () => {
|
||||
expect(
|
||||
resolveImpossibleStrategicCommands(
|
||||
{
|
||||
next_execute_수몰: 190 * 12 + 4,
|
||||
next_execute_허보: 190 * 12 + 2,
|
||||
},
|
||||
190,
|
||||
4
|
||||
)
|
||||
).toEqual([{ name: '수몰', remainingTurns: 1, availableYear: 190, availableMonth: 5 }]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user