diff --git a/app/game-api/src/battleSim/environment.ts b/app/game-api/src/battleSim/environment.ts index 264c45fb..3cea4877 100644 --- a/app/game-api/src/battleSim/environment.ts +++ b/app/game-api/src/battleSim/environment.ts @@ -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: { diff --git a/app/game-api/src/router/nation/endpoints/getGeneralList.ts b/app/game-api/src/router/nation/endpoints/getGeneralList.ts index 6909611c..8f8fa718 100644 --- a/app/game-api/src/router/nation/endpoints/getGeneralList.ts +++ b/app/game-api/src/router/nation/endpoints/getGeneralList.ts @@ -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, }; }); diff --git a/app/game-api/src/router/nation/endpoints/getSecretGeneralList.ts b/app/game-api/src/router/nation/endpoints/getSecretGeneralList.ts index 69436d11..5d4a6e77 100644 --- a/app/game-api/src/router/nation/endpoints/getSecretGeneralList.ts +++ b/app/game-api/src/router/nation/endpoints/getSecretGeneralList.ts @@ -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, 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, diff --git a/app/game-api/src/router/public/index.ts b/app/game-api/src/router/public/index.ts index 2516c41a..31728fcd 100644 --- a/app/game-api/src/router/public/index.ts +++ b/app/game-api/src/router/public/index.ts @@ -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. diff --git a/app/game-api/src/router/world/directory.ts b/app/game-api/src/router/world/directory.ts index eb5b901b..866df89c 100644 --- a/app/game-api/src/router/world/directory.ts +++ b/app/game-api/src/router/world/directory.ts @@ -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; diff --git a/app/game-api/src/turns/commandTable.ts b/app/game-api/src/turns/commandTable.ts index ab20a5bc..c37374b6 100644 --- a/app/game-api/src/turns/commandTable.ts +++ b/app/game-api/src/turns/commandTable.ts @@ -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), diff --git a/app/game-api/test/nationGeneralSecretRouter.test.ts b/app/game-api/test/nationGeneralSecretRouter.test.ts index e244985e..84c2b333 100644 --- a/app/game-api/test/nationGeneralSecretRouter.test.ts +++ b/app/game-api/test/nationGeneralSecretRouter.test.ts @@ -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); + }); }); diff --git a/app/game-engine/src/turn/ai/generalAi/core.ts b/app/game-engine/src/turn/ai/generalAi/core.ts index 3b130819..41da9fb8 100644 --- a/app/game-engine/src/turn/ai/generalAi/core.ts +++ b/app/game-engine/src/turn/ai/generalAi/core.ts @@ -8,7 +8,7 @@ import type { TurnCommandEnv, UnitSetDefinition, } from '@sammo-ts/logic'; -import { evaluateConstraints } from '@sammo-ts/logic'; +import { evaluateConstraints, LEGACY_DEFAULT_MAX_LEVEL } from '@sammo-ts/logic'; import type { ConstraintContext } from '@sammo-ts/logic'; import { GAME_TICKS_PER_TURN, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; import { simpleSerialize } from '@sammo-ts/logic/war/utils.js'; @@ -872,17 +872,14 @@ export class GeneralAI { ? resolveLegacyAiStatsWithModules( candidate, this.nation, - this.commandEnv.maxStatLevel ?? this.scenarioConfig.stat.max, + this.commandEnv.maxStatLevel ?? LEGACY_DEFAULT_MAX_LEVEL, this.commandEnv.generalActionModules, this.worldRef, this.world, this.startYear ).fullLeadership - : resolveLegacyAiStats( - candidate, - this.nation, - this.commandEnv.maxStatLevel ?? this.scenarioConfig.stat.max - ).fullLeadership; + : resolveLegacyAiStats(candidate, this.nation, this.commandEnv.maxStatLevel ?? LEGACY_DEFAULT_MAX_LEVEL) + .fullLeadership; if (fullLeadership >= this.nationPolicy.minNpcWarLeadership) { npcWarGenerals[candidate.id] = candidate; } else { @@ -1055,17 +1052,13 @@ export class GeneralAI { ? resolveLegacyAiStatsWithModules( this.general, this.nation, - this.commandEnv.maxStatLevel ?? this.scenarioConfig.stat.max, + this.commandEnv.maxStatLevel ?? LEGACY_DEFAULT_MAX_LEVEL, this.commandEnv.generalActionModules, this.worldRef, this.world, this.startYear ) - : resolveLegacyAiStats( - this.general, - this.nation, - this.commandEnv.maxStatLevel ?? this.scenarioConfig.stat.max - ); + : resolveLegacyAiStats(this.general, this.nation, this.commandEnv.maxStatLevel ?? LEGACY_DEFAULT_MAX_LEVEL); this.general.meta = { ...this.general.meta, ...stats, diff --git a/app/game-engine/src/turn/ai/generalAi/nation/rewards.ts b/app/game-engine/src/turn/ai/generalAi/nation/rewards.ts index dff8e545..877308fe 100644 --- a/app/game-engine/src/turn/ai/generalAi/nation/rewards.ts +++ b/app/game-engine/src/turn/ai/generalAi/nation/rewards.ts @@ -1,5 +1,6 @@ import type { GeneralAI } from '../core.js'; import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js'; +import { LEGACY_DEFAULT_MAX_LEVEL } from '@sammo-ts/logic/scenario/constants.js'; import { findCrewTypeById, getTechCost } from '@sammo-ts/logic/world/unitSet.js'; import type { TurnGeneral } from '../../../types.js'; import { asRecord, readMetaNumber, readRequiredMetaNumber } from '../../aiUtils.js'; @@ -72,12 +73,12 @@ const getFullLeadership = (ai: GeneralAI, general: TurnGeneral): number => { 'leadership', general.stats.leadership ); - const maxStat = ai.commandEnv.maxStatLevel ?? ai.scenarioConfig.stat.max; + const maxStat = ai.commandEnv.maxStatLevel ?? LEGACY_DEFAULT_MAX_LEVEL; return Math.trunc(Math.max(0, Math.min(Number(adjusted), maxStat))); } const nationLevel = ai.nation?.level ?? 0; const officerBonus = general.officerLevel === 12 ? nationLevel * 2 : general.officerLevel >= 5 ? nationLevel : 0; - const maxStat = ai.commandEnv.maxStatLevel ?? ai.scenarioConfig.stat.max; + const maxStat = ai.commandEnv.maxStatLevel ?? LEGACY_DEFAULT_MAX_LEVEL; return Math.max(0, Math.min(general.stats.leadership + officerBonus, maxStat)); }; diff --git a/app/game-engine/src/turn/reservedTurnCommands.ts b/app/game-engine/src/turn/reservedTurnCommands.ts index 5a89fae0..a4317d08 100644 --- a/app/game-engine/src/turn/reservedTurnCommands.ts +++ b/app/game-engine/src/turn/reservedTurnCommands.ts @@ -10,6 +10,7 @@ import type { import { LEGACY_RANDOM_GENERAL_FIRST_NAMES, LEGACY_RANDOM_GENERAL_LAST_NAMES, + LEGACY_DEFAULT_MAX_LEVEL, loadGeneralTurnCommandSpecs, loadNationTurnCommandSpecs, loadActionModuleBundle, @@ -132,7 +133,9 @@ export const buildCommandEnv = (config: ScenarioConfig, unitSet?: UnitSetDefinit ]), initialNationGenLimit: resolveNumber(constValues, ['initialNationGenLimit'], DEFAULT_INITIAL_NATION_GEN_LIMIT), maxTechLevel: resolveNumber(constValues, ['maxTechLevel'], DEFAULT_MAX_TECH_LEVEL), - maxStatLevel: resolveNumber(constValues, ['maxLevel'], config.stat.max), + // `stat.max` bounds join-time allocation (80 by default), while Ref + // runtime stat calculations use GameConst::$maxLevel. + maxStatLevel: resolveNumber(constValues, ['maxLevel'], LEGACY_DEFAULT_MAX_LEVEL), maxDedicationLevel: resolveNumber(constValues, ['maxDedLevel'], 30), statUpgradeLimit: resolveNumber(constValues, ['upgradeLimit'], 30), techLevelIncYear: resolveNumber(constValues, ['techLevelIncYear'], 5), diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index 5a3d4438..d6691e51 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -34,6 +34,7 @@ import { rollUniqueLottery, getNextTurnAt, getBillByLevel, + LEGACY_DEFAULT_MAX_LEVEL, type ItemModule, type UniqueLotteryRunner, } from '@sammo-ts/logic'; @@ -126,7 +127,7 @@ export const applyLegacyGeneralProgression = ( env: TurnCommandEnv, logs: LogEntryDraft[] ): TurnGeneral => { - const maxStatLevel = env.maxStatLevel ?? 255; + const maxStatLevel = env.maxStatLevel ?? LEGACY_DEFAULT_MAX_LEVEL; const maxDedicationLevel = env.maxDedicationLevel ?? 30; const expLevel = Math.max( 0, diff --git a/app/game-engine/test/generalTurnLegacyCompatibility.test.ts b/app/game-engine/test/generalTurnLegacyCompatibility.test.ts index 84a15ba2..bf40595a 100644 --- a/app/game-engine/test/generalTurnLegacyCompatibility.test.ts +++ b/app/game-engine/test/generalTurnLegacyCompatibility.test.ts @@ -4,6 +4,7 @@ import type { TurnSchedule } from '@sammo-ts/logic/turn/calendar.js'; import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; import { createTurnTestHarness } from './helpers/turnTestHarness.js'; import { applyLegacyGeneralProgression } from '../src/turn/reservedTurnHandler.js'; +import { buildCommandEnv } from '../src/turn/reservedTurnCommands.js'; const start = new Date('0200-01-01T00:00:00.000Z'); const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] }; @@ -154,6 +155,21 @@ const makeState = (): TurnWorldState => ({ }); describe('legacy general-turn execution contract', () => { + it('does not reuse the join stat allocation maximum as the runtime level cap', () => { + const previous = makeGeneral({ + experience: 144_000, + meta: { killturn: 24, explevel: 120 }, + }); + const afterRecruitment = makeGeneral({ + experience: 144_001, + meta: { killturn: 24, explevel: 120 }, + }); + const env = buildCommandEnv(makeSnapshot(previous).scenarioConfig); + + expect(env.maxStatLevel).toBe(255); + expect(applyLegacyGeneralProgression(afterRecruitment, previous, 'che_모병', env, []).meta.explevel).toBe(120); + }); + it('preserves the battle-computed level across legacy INT rounding', () => { const previous = makeGeneral({ experience: 6_700, diff --git a/app/game-engine/test/scenarioLoader.test.ts b/app/game-engine/test/scenarioLoader.test.ts index 71fc9388..a60cffeb 100644 --- a/app/game-engine/test/scenarioLoader.test.ts +++ b/app/game-engine/test/scenarioLoader.test.ts @@ -92,4 +92,17 @@ describe('tracked scenario resources', () => { expect([...secretScenarioKeys!].filter((key) => key.startsWith('event_전투특기_'))).toHaveLength(20); expect(secretScenarioKeys?.has('event_전투특기_격노')).toBe(true); }); + + it('keeps join allocation bounds separate from the Ref runtime stat level limit', async () => { + const scenario = await loadScenarioDefinitionById(1); + + expect(scenario.config.stat.max).toBe(80); + expect(buildCommandEnv(scenario.config).maxStatLevel).toBe(255); + expect( + buildCommandEnv({ + ...scenario.config, + const: { ...scenario.config.const, maxLevel: 512 }, + }).maxStatLevel + ).toBe(512); + }); }); diff --git a/packages/logic/src/actions/turn/actionContextHelpers.ts b/packages/logic/src/actions/turn/actionContextHelpers.ts index 8acdee4e..18a85787 100644 --- a/packages/logic/src/actions/turn/actionContextHelpers.ts +++ b/packages/logic/src/actions/turn/actionContextHelpers.ts @@ -1,5 +1,6 @@ import type { General } from '@sammo-ts/logic/domain/entities.js'; import type { ScenarioConfig } from '@sammo-ts/logic/scenario/types.js'; +import { LEGACY_DEFAULT_MAX_LEVEL } from '@sammo-ts/logic/scenario/constants.js'; import type { ScenarioMeta } from '@sammo-ts/logic/world/types.js'; import type { WarAftermathConfig, WarEngineConfig, WarTimeContext } from '@sammo-ts/logic/war/types.js'; import type { UnitSetDefinition } from '@sammo-ts/logic/world/types.js'; @@ -208,7 +209,7 @@ export const buildWarConfig = (scenarioConfig: ScenarioConfig, unitSet: UnitSetD 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: { diff --git a/packages/logic/src/actions/turn/general/che_등용수락.ts b/packages/logic/src/actions/turn/general/che_등용수락.ts index 285d1d5f..abcf9a69 100644 --- a/packages/logic/src/actions/turn/general/che_등용수락.ts +++ b/packages/logic/src/actions/turn/general/che_등용수락.ts @@ -26,6 +26,7 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js' import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; import type { GeneralTurnCommandSpec } from './index.js'; import { parseArgsWithSchema } from '../parseArgs.js'; +import { LEGACY_DEFAULT_MAX_LEVEL } from '@sammo-ts/logic/scenario/constants.js'; const ACTION_NAME = '등용수락'; const ACTION_KEY = 'che_등용수락'; @@ -103,7 +104,7 @@ export class ActionResolver< const recruiterExpLevel = Math.max( 0, Math.min( - this.env.maxStatLevel ?? 255, + this.env.maxStatLevel ?? LEGACY_DEFAULT_MAX_LEVEL, recruiterExperience < 1_000 ? Math.trunc(recruiterExperience / 100) : Math.trunc(Math.sqrt(recruiterExperience / 10)) diff --git a/packages/logic/src/actions/turn/general/che_물자조달.ts b/packages/logic/src/actions/turn/general/che_물자조달.ts index 2fa2753a..ba5cfd40 100644 --- a/packages/logic/src/actions/turn/general/che_물자조달.ts +++ b/packages/logic/src/actions/turn/general/che_물자조달.ts @@ -13,6 +13,7 @@ import { LogCategory, LogFormat } from '@sammo-ts/logic/logging/types.js'; import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'; +import { LEGACY_DEFAULT_MAX_LEVEL } from '@sammo-ts/logic/scenario/constants.js'; import type { GeneralTurnCommandSpec } from './index.js'; export interface ProcureArgs {} @@ -32,7 +33,10 @@ export const roundLegacyAccumulatedInteger = (current: number, delta: number): n export const resolveLegacyExperienceLevel = (experience: number): number => Math.max( 0, - Math.min(255, experience < 1_000 ? Math.trunc(experience / 100) : Math.trunc(Math.sqrt(experience / 10))) + Math.min( + LEGACY_DEFAULT_MAX_LEVEL, + experience < 1_000 ? Math.trunc(experience / 100) : Math.trunc(Math.sqrt(experience / 10)) + ) ); export const resolveLegacyDedicationLevel = (dedication: number): number => Math.max(0, Math.min(30, Math.ceil(Math.sqrt(dedication) / 10))); @@ -65,7 +69,7 @@ export class ActionResolver< const rawLeadership = general.stats.leadership * injuryMultiplier; const rawStrength = general.stats.strength * injuryMultiplier; const rawIntelligence = general.stats.intelligence * injuryMultiplier; - const maxStat = 255; + const maxStat = LEGACY_DEFAULT_MAX_LEVEL; const legacyStat = (stat: 'leadership' | 'strength' | 'intelligence', value: number): number => Math.trunc(Math.max(0, Math.min(maxStat, this.pipeline.onCalcStat(context, stat, value)))); let score = diff --git a/packages/logic/src/actions/turn/general/che_상업투자.ts b/packages/logic/src/actions/turn/general/che_상업투자.ts index 1ce64266..10f434d0 100644 --- a/packages/logic/src/actions/turn/general/che_상업투자.ts +++ b/packages/logic/src/actions/turn/general/che_상업투자.ts @@ -27,6 +27,7 @@ import type { ActionResolveContext, } from '@sammo-ts/logic/actions/turn/actionContext.js'; import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'; +import { LEGACY_DEFAULT_MAX_LEVEL } from '@sammo-ts/logic/scenario/constants.js'; import type { GeneralTurnCommandSpec } from './index.js'; import { clamp } from 'es-toolkit'; @@ -203,7 +204,7 @@ export class CommandResolver { @@ -35,7 +35,7 @@ const updateLegacyProgressionLevels = (general: General): void => { general.experience < 1_000 ? Math.trunc(general.experience / 100) : Math.trunc(Math.sqrt(general.experience / 10)); - general.meta.explevel = clamp(expLevel, 0, MAX_EXP_LEVEL); + general.meta.explevel = clamp(expLevel, 0, LEGACY_DEFAULT_MAX_LEVEL); general.meta.dedlevel = clamp(Math.ceil(Math.sqrt(general.dedication) / 10), 0, MAX_DEDICATION_LEVEL); }; diff --git a/packages/logic/src/war/units/general.ts b/packages/logic/src/war/units/general.ts index e16a1145..3e8b18fa 100644 --- a/packages/logic/src/war/units/general.ts +++ b/packages/logic/src/war/units/general.ts @@ -12,6 +12,7 @@ import type { ActionLogger } from '@sammo-ts/logic/logging/actionLogger.js'; import { LogFormat } from '@sammo-ts/logic/logging/types.js'; import type { WarStatName } from '@sammo-ts/logic/actionModules/types.js'; import { getTechAbility, getTechCost } from '@sammo-ts/logic/world/unitSet.js'; +import { LEGACY_DEFAULT_MAX_LEVEL } from '@sammo-ts/logic/scenario/constants.js'; import type { WarActionPipeline, WarActionContext } from '../actions.js'; import type { WarEngineConfig } from '../types.js'; import type { WarCrewType } from '../crewType.js'; @@ -28,7 +29,6 @@ const META_RANK_PREFIX = 'rank_'; const META_INTEL_EXP = 'intel_exp'; const META_STRENGTH_EXP = 'strength_exp'; const META_LEADERSHIP_EXP = 'leadership_exp'; -const MAX_EXP_LEVEL = 255; const RANK_WARNUM = `${META_RANK_PREFIX}warnum`; const RANK_KILLNUM = `${META_RANK_PREFIX}killnum`; @@ -178,7 +178,7 @@ export class WarUnitGeneral< }) / 4 ); } - const maxGeneralStat = this.config.maxGeneralStat ?? 255; + const maxGeneralStat = this.config.maxGeneralStat ?? LEGACY_DEFAULT_MAX_LEVEL; value = clamp(value, 0, maxGeneralStat); if (withActions) { value = this.actionPipeline.onCalcStat(this.getActionContext(), statName, value); @@ -360,7 +360,7 @@ export class WarUnitGeneral< this.general.experience < 1000 ? Math.trunc(this.general.experience / 100) : Math.trunc(Math.sqrt(this.general.experience / 10)); - const resolvedExpLevel = clamp(nextExpLevel, 0, MAX_EXP_LEVEL); + const resolvedExpLevel = clamp(nextExpLevel, 0, LEGACY_DEFAULT_MAX_LEVEL); this.general.meta[META_EXP_LEVEL] = resolvedExpLevel; if (resolvedExpLevel === previousExpLevel) { return; @@ -546,7 +546,7 @@ export class WarUnitGeneral< // one accumulated stat-exp threshold even though che_출병 itself does // not have the generic command progression tail. const limit = this.config.statUpgradeLimit ?? 30; - const maxStat = this.config.maxGeneralStat ?? 255; + const maxStat = this.config.maxGeneralStat ?? LEGACY_DEFAULT_MAX_LEVEL; const entries = [ ['leadership', META_LEADERSHIP_EXP, '통솔'], ['strength', META_STRENGTH_EXP, '무력'],