fix: 장수 런타임 레벨 상한을 Ref 기준으로 복구
가입 능력치 배분 상한과 런타임 레벨 상한을 분리하고, Ref 기본값 255를 공유 상수로 통합한다. 모병 후 레벨 유지와 국가 장수 목록의 고레벨 표시를 회귀 테스트로 고정한다.
This commit is contained in:
@@ -1,7 +1,12 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { loadUnitSetDefinitionByName } from '@sammo-ts/game-engine/scenario/unitSetLoader.js';
|
||||
import { normalizeScenarioEffect, type ScenarioEffectKey, type WarEngineConfig } from '@sammo-ts/logic';
|
||||
import {
|
||||
LEGACY_DEFAULT_MAX_LEVEL,
|
||||
normalizeScenarioEffect,
|
||||
type ScenarioEffectKey,
|
||||
type WarEngineConfig,
|
||||
} from '@sammo-ts/logic';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import type { UnitSetDefinition } from '@sammo-ts/logic';
|
||||
|
||||
@@ -104,7 +109,7 @@ export const buildBattleSimEnvironment = async (
|
||||
maxAtmosByCommand: resolveNumber(constValues, ['maxAtmosByCommand'], DEFAULT_WAR_CONFIG.maxAtmosByCommand),
|
||||
maxTrainByWar: resolveNumber(constValues, ['maxTrainByWar'], DEFAULT_WAR_CONFIG.maxTrainByWar),
|
||||
maxAtmosByWar: resolveNumber(constValues, ['maxAtmosByWar'], DEFAULT_WAR_CONFIG.maxAtmosByWar),
|
||||
maxGeneralStat: resolveNumber(constValues, ['maxLevel'], 255),
|
||||
maxGeneralStat: resolveNumber(constValues, ['maxLevel'], LEGACY_DEFAULT_MAX_LEVEL),
|
||||
statUpgradeLimit: resolveNumber(constValues, ['upgradeLimit'], 30),
|
||||
castleCrewTypeId,
|
||||
armTypes: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { asNumber, asRecord } from '@sammo-ts/common';
|
||||
import { LEGACY_DEFAULT_MAX_LEVEL } from '@sammo-ts/logic';
|
||||
|
||||
import { accessAuthedProcedure } from '../../../trpc.js';
|
||||
import { resolveDedicationLevelName, sanitizeInternalDisplayCode } from '../../../services/gameDisplayNames.js';
|
||||
@@ -12,10 +13,10 @@ import {
|
||||
resolveNationPermission,
|
||||
} from '../shared.js';
|
||||
|
||||
const experienceLevel = (experience: number): number =>
|
||||
const experienceLevel = (experience: number, maxLevel: number): number =>
|
||||
Math.max(
|
||||
0,
|
||||
Math.min(100, experience < 1000 ? Math.floor(experience / 100) : Math.floor(Math.sqrt(experience / 10)))
|
||||
Math.min(maxLevel, experience < 1000 ? Math.floor(experience / 100) : Math.floor(Math.sqrt(experience / 10)))
|
||||
);
|
||||
const dedicationLevel = (dedication: number, maxLevel: number): number =>
|
||||
Math.max(0, Math.min(maxLevel, Math.ceil(Math.sqrt(dedication) / 10)));
|
||||
@@ -88,7 +89,9 @@ export const getGeneralList = accessAuthedProcedure.query(async ({ ctx }) => {
|
||||
const nationTrait = (await loadTraitNames([nation.typeCode], 'nation')).get(nation.typeCode);
|
||||
const permission = resolveNationPermission(general, nation.meta, true);
|
||||
const config = asRecord(worldState?.config);
|
||||
const maxDedicationLevel = Math.max(0, Math.trunc(asNumber(asRecord(config.const).maxDedLevel, 30)));
|
||||
const constValues = asRecord(config.const);
|
||||
const maxExperienceLevel = Math.max(0, Math.trunc(asNumber(constValues.maxLevel, LEGACY_DEFAULT_MAX_LEVEL)));
|
||||
const maxDedicationLevel = Math.max(0, Math.trunc(asNumber(constValues.maxDedLevel, 30)));
|
||||
const visibleList = list.map((entry) => {
|
||||
const entryDedicationLevel = dedicationLevel(entry.dedication, maxDedicationLevel);
|
||||
const dedicationDisplay = {
|
||||
@@ -101,7 +104,7 @@ export const getGeneralList = accessAuthedProcedure.query(async ({ ctx }) => {
|
||||
return {
|
||||
...safeEntry,
|
||||
refreshScoreTotal: accessByGeneral.get(entry.id) ?? 0,
|
||||
experienceLevel: experienceLevel(entry.experience),
|
||||
experienceLevel: experienceLevel(entry.experience, maxExperienceLevel),
|
||||
...dedicationDisplay,
|
||||
};
|
||||
}
|
||||
@@ -114,7 +117,7 @@ export const getGeneralList = accessAuthedProcedure.query(async ({ ctx }) => {
|
||||
troopName: null,
|
||||
officerCity: 0,
|
||||
officerCityName: null,
|
||||
experienceLevel: experienceLevel(entry.experience),
|
||||
experienceLevel: experienceLevel(entry.experience, maxExperienceLevel),
|
||||
...dedicationDisplay,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { asNumber, asRecord } from '@sammo-ts/common';
|
||||
import { LEGACY_DEFAULT_MAX_LEVEL } from '@sammo-ts/logic';
|
||||
|
||||
import { loadUnitSetDefinitionByName } from '@sammo-ts/game-engine/scenario/unitSetLoader.js';
|
||||
import { accessAuthedProcedure } from '../../../trpc.js';
|
||||
@@ -16,10 +17,10 @@ const readNumber = (record: Record<string, unknown>, keys: string[], fallback =
|
||||
};
|
||||
const woundedStat = (value: number, injury: number): number =>
|
||||
injury > 0 ? Math.floor((value * (100 - injury)) / 100) : value;
|
||||
const experienceLevel = (experience: number): number =>
|
||||
const experienceLevel = (experience: number, maxLevel: number): number =>
|
||||
Math.max(
|
||||
0,
|
||||
Math.min(100, experience < 1000 ? Math.floor(experience / 100) : Math.floor(Math.sqrt(experience / 10)))
|
||||
Math.min(maxLevel, experience < 1000 ? Math.floor(experience / 100) : Math.floor(Math.sqrt(experience / 10)))
|
||||
);
|
||||
const leadershipBonus = (officerLevel: number, nationLevel: number): number =>
|
||||
officerLevel === 12 ? nationLevel * 2 : officerLevel >= 5 ? nationLevel : 0;
|
||||
@@ -55,6 +56,10 @@ export const getSecretGeneralList = accessAuthedProcedure.query(async ({ ctx })
|
||||
ctx.db.worldState.findFirst({ select: { config: true } }),
|
||||
]);
|
||||
const worldConfig = asRecord(worldState?.config);
|
||||
const maxExperienceLevel = Math.max(
|
||||
0,
|
||||
Math.trunc(asNumber(asRecord(worldConfig.const).maxLevel, LEGACY_DEFAULT_MAX_LEVEL))
|
||||
);
|
||||
const environment = asRecord(worldConfig.environment ?? worldConfig.map);
|
||||
const unitSetName =
|
||||
typeof environment.unitSet === 'string' && environment.unitSet.trim() ? environment.unitSet : ctx.profile.id;
|
||||
@@ -90,7 +95,7 @@ export const getSecretGeneralList = accessAuthedProcedure.query(async ({ ctx })
|
||||
intelligence: woundedStat(general.intel, general.injury),
|
||||
},
|
||||
leadershipBonus: leadershipBonus(general.officerLevel, nation.level),
|
||||
experienceLevel: experienceLevel(general.experience),
|
||||
experienceLevel: experienceLevel(general.experience, maxExperienceLevel),
|
||||
troopId: general.troopId,
|
||||
troopName: troopNames.get(general.troopId) ?? null,
|
||||
gold: general.gold,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { asNumber, asRecord } from '@sammo-ts/common';
|
||||
import { LogCategory, LogScope } from '@sammo-ts/logic';
|
||||
import { LEGACY_DEFAULT_MAX_LEVEL, LogCategory, LogScope } from '@sammo-ts/logic';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { GameApiContext } from '../../context.js';
|
||||
@@ -573,7 +573,7 @@ export const publicRouter = router({
|
||||
const nationMap = new Map(nations.map((nation) => [nation.id, nation]));
|
||||
const worldConfig = asRecord(worldState?.config);
|
||||
const worldConstants = asRecord(worldConfig.const);
|
||||
const maxLevel = Math.max(0, Math.floor(asNumber(worldConstants.maxLevel, 255)));
|
||||
const maxLevel = Math.max(0, Math.floor(asNumber(worldConstants.maxLevel, LEGACY_DEFAULT_MAX_LEVEL)));
|
||||
const maxDedLevel = Math.max(0, Math.floor(asNumber(worldConstants.maxDedLevel, 30)));
|
||||
|
||||
// Legacy a_npcList.php shows select_pool humans first and possessed npc=1 rows.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { LEGACY_DEFAULT_MAX_LEVEL } from '@sammo-ts/logic';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { accessAuthedInputProcedure, authedProcedure } from '../../trpc.js';
|
||||
@@ -253,7 +254,7 @@ export const getGeneralDirectory = accessAuthedInputProcedure(z.object({ sort: z
|
||||
const accessMap = new Map(accessLogs.map((row) => [row.generalId, row.refreshScoreTotal]));
|
||||
const config = asRecord(worldState?.config);
|
||||
const constValues = asRecord(config.const);
|
||||
const maxLevel = readNumber(constValues.maxLevel, 255);
|
||||
const maxLevel = readNumber(constValues.maxLevel, LEGACY_DEFAULT_MAX_LEVEL);
|
||||
const maxDedLevel = readNumber(constValues.maxDedLevel, 30);
|
||||
const worldMeta = asRecord(worldState?.meta);
|
||||
const isUnited = readNumber(worldMeta.isUnited ?? worldMeta.isunited) > 0;
|
||||
|
||||
@@ -18,7 +18,7 @@ import type {
|
||||
TriggerValue,
|
||||
UnitSetDefinition,
|
||||
} from '@sammo-ts/logic';
|
||||
import { evaluateConstraints } from '@sammo-ts/logic';
|
||||
import { evaluateConstraints, LEGACY_DEFAULT_MAX_LEVEL } from '@sammo-ts/logic';
|
||||
import type { GeneralActionModule } from '@sammo-ts/logic/actionModules/general.js';
|
||||
import { CommandResolver as RecruitmentCommandResolver } from '@sammo-ts/logic/actions/turn/general/che_징병.js';
|
||||
import { projectItemSlots, readItemInventoryFromMeta } from '@sammo-ts/logic/items/index.js';
|
||||
@@ -340,7 +340,7 @@ const buildCommandEnv = (worldState: WorldStateRow): CommandEnv => {
|
||||
defaultSpecialWar: resolveOptionalString(constValues, ['defaultSpecialWar']),
|
||||
initialNationGenLimit: resolveNumber(constValues, ['initialNationGenLimit'], 0),
|
||||
maxTechLevel: resolveNumber(constValues, ['maxTechLevel'], 12),
|
||||
maxStatLevel: resolveNumber(constValues, ['maxLevel'], 255),
|
||||
maxStatLevel: resolveNumber(constValues, ['maxLevel'], LEGACY_DEFAULT_MAX_LEVEL),
|
||||
techLevelIncYear: resolveNumber(constValues, ['techLevelIncYear'], 5),
|
||||
initialAllowedTechLevel: resolveNumber(constValues, ['initialAllowedTechLevel'], 1),
|
||||
baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], 0),
|
||||
|
||||
@@ -60,7 +60,7 @@ const token = (userId: string): GameSessionTokenPayload => ({
|
||||
user: { id: userId, username: userId, displayName: userId, roles: [] },
|
||||
sanctions: {},
|
||||
});
|
||||
const fixture = (generals: GeneralRow[], userId = 'u1') => {
|
||||
const fixture = (generals: GeneralRow[], userId = 'u1', maxLevel?: number) => {
|
||||
const db = {
|
||||
general: {
|
||||
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) =>
|
||||
@@ -83,7 +83,9 @@ const fixture = (generals: GeneralRow[], userId = 'u1') => {
|
||||
},
|
||||
city: { findMany: vi.fn(async () => [{ id: 1, name: '업' }]) },
|
||||
troop: { findMany: vi.fn(async () => [{ troopLeaderId: 2, name: '선봉대' }]) },
|
||||
worldState: { findFirst: vi.fn(async () => null) },
|
||||
worldState: {
|
||||
findFirst: vi.fn(async () => ({ config: { const: maxLevel === undefined ? {} : { maxLevel } } })),
|
||||
},
|
||||
generalTurn: {
|
||||
findMany: vi.fn(async () => [
|
||||
{
|
||||
@@ -118,7 +120,7 @@ const fixture = (generals: GeneralRow[], userId = 'u1') => {
|
||||
|
||||
describe('nation general and secret office permissions', () => {
|
||||
it('redacts ordinary-member details and denies the secret office', async () => {
|
||||
const { caller } = fixture([general()]);
|
||||
const { caller } = fixture([general({ experience: 144_000 })]);
|
||||
const result = await caller.nation.getGeneralList();
|
||||
expect(result.viewer).toEqual({ generalId: 1, permission: 0 });
|
||||
expect(result.generals[0]).toMatchObject({
|
||||
@@ -129,6 +131,7 @@ describe('nation general and secret office permissions', () => {
|
||||
dedicationLevel: 1,
|
||||
dedicationText: '30품관',
|
||||
bill: 600,
|
||||
experienceLevel: 120,
|
||||
});
|
||||
expect(result.generals[0]).not.toHaveProperty('crew');
|
||||
await expect(caller.nation.getSecretGeneralList()).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||
@@ -136,12 +139,21 @@ describe('nation general and secret office permissions', () => {
|
||||
it('uses the session-owned general and scopes secret rows to that nation', async () => {
|
||||
const first = general();
|
||||
const actor = general({ id: 2, userId: 'u2', officerLevel: 5, meta: { belong: 1 } });
|
||||
const ally = general({ id: 3, userId: 'u3', gold: 3000, crew: 200, train: 80, atmos: 80 });
|
||||
const ally = general({
|
||||
id: 3,
|
||||
userId: 'u3',
|
||||
gold: 3000,
|
||||
crew: 200,
|
||||
train: 80,
|
||||
atmos: 80,
|
||||
experience: 400_000,
|
||||
});
|
||||
const foreign = general({ id: 4, userId: 'u4', nationId: 2, gold: 99999 });
|
||||
const { caller, db } = fixture([first, actor, ally, foreign], 'u2');
|
||||
const result = await caller.nation.getSecretGeneralList();
|
||||
expect(result.viewer).toEqual({ generalId: 2, permission: 2 });
|
||||
expect(result.generals.map((g) => g.id)).toEqual([1, 2, 3]);
|
||||
expect(result.generals.find((entry) => entry.id === 3)?.experienceLevel).toBe(200);
|
||||
expect(result.summary).toMatchObject({ gold: 5000, crew: 800, generalCount: 3 });
|
||||
expect(result.generals[0]?.reservedCommands).toEqual([
|
||||
{ action: 'che_징병', args: { crewType: 1, amount: 300 } },
|
||||
@@ -159,4 +171,15 @@ describe('nation general and secret office permissions', () => {
|
||||
const { caller } = fixture([penalized]);
|
||||
await expect(caller.nation.getSecretGeneralList()).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||
});
|
||||
it('honors an explicit Ref maxLevel override for projected experience levels', async () => {
|
||||
const actor = general({ officerLevel: 5, experience: 400_000 });
|
||||
const { caller } = fixture([actor], 'u1', 150);
|
||||
|
||||
const [generalList, secretList] = await Promise.all([
|
||||
caller.nation.getGeneralList(),
|
||||
caller.nation.getSecretGeneralList(),
|
||||
]);
|
||||
expect(generalList.generals[0]?.experienceLevel).toBe(150);
|
||||
expect(secretList.generals[0]?.experienceLevel).toBe(150);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user