From a063f51b20baf0168a14dc4d286c2a81095988e9 Mon Sep 17 00:00:00 2001 From: congsh <2452821485@qq.com> Date: Wed, 13 May 2026 03:35:02 +0000 Subject: [PATCH] feat: add career engine with unlock, income, exp, level-up --- src/config/careers.json | 111 +++++++++++++++++++ src/engine/careerEngine.js | 74 +++++++++++++ test/careerEngine.test.js | 221 +++++++++++++++++++++++++++++++++++++ 3 files changed, 406 insertions(+) create mode 100644 src/config/careers.json create mode 100644 src/engine/careerEngine.js create mode 100644 test/careerEngine.test.js diff --git a/src/config/careers.json b/src/config/careers.json new file mode 100644 index 0000000..8210eee --- /dev/null +++ b/src/config/careers.json @@ -0,0 +1,111 @@ +{ + "careers": [ + { + "id": "student", + "name": "学童", + "type": "scholar", + "unlockConditions": { + "type": "age", + "op": ">=", + "value": 6 + }, + "dailyIncome": 1, + "expCurve": { + "base": 10, + "factor": 1.15 + } + }, + { + "id": "book_boy", + "name": "书童", + "type": "scholar", + "unlockConditions": { + "type": "careerLevel", + "op": ">=", + "careerId": "student", + "value": 100 + }, + "dailyIncome": 2, + "expCurve": { + "base": 50, + "factor": 1.2 + } + }, + { + "id": "scholar", + "name": "书生", + "type": "scholar", + "unlockConditions": { + "type": "and", + "conditions": [ + { + "type": "careerLevel", + "op": ">=", + "careerId": "student", + "value": 250 + }, + { + "type": "age", + "op": ">=", + "value": 14 + } + ] + }, + "dailyIncome": -3, + "expCurve": { + "base": 100, + "factor": 1.25 + } + }, + { + "id": "soldier", + "name": "士兵", + "type": "military", + "unlockConditions": { + "type": "and", + "conditions": [ + { + "type": "age", + "op": ">=", + "value": 14 + }, + { + "type": "stat", + "op": ">=", + "stat": "body", + "value": 10 + } + ] + }, + "dailyIncome": 2, + "expCurve": { + "base": 20, + "factor": 1.18 + } + }, + { + "id": "taoist_priest", + "name": "道士", + "type": "immortal", + "unlockConditions": { + "type": "and", + "conditions": [ + { + "type": "age", + "op": ">=", + "value": 10 + }, + { + "type": "hasItem", + "itemId": "taoist_license" + } + ] + }, + "dailyIncome": 1, + "expCurve": { + "base": 30, + "factor": 1.22 + } + } + ] +} diff --git a/src/engine/careerEngine.js b/src/engine/careerEngine.js new file mode 100644 index 0000000..3d3c440 --- /dev/null +++ b/src/engine/careerEngine.js @@ -0,0 +1,74 @@ +import { evaluateCondition } from './conditionEvaluator.js'; + +const STAT_MAP = { + scholar: 'wisdom', + military: 'body', + jianghu: 'charm', + political: 'intelligence', + immortal: 'wisdom', + business: 'business', +}; + +let _config = null; + +export function setCareerConfig(config) { + _config = config; +} + +export function getCareerConfig() { + return _config; +} + +export function checkUnlocks(config, state) { + if (!state.careers) { + state.careers = {}; + } + for (const career of config.careers) { + if (!state.careers[career.id]) { + if (evaluateCondition(career.unlockConditions, state)) { + state.careers[career.id] = { level: 0, exp: 0 }; + } + } + } +} + +export function calcDailyIncome(config, state) { + let total = 0; + for (const career of config.careers) { + if (state.careers?.[career.id]) { + total += career.dailyIncome; + } + } + const businessStat = state.stats?.business ?? 0; + const bonus = 1 + Math.min(businessStat, 100) * 0.01; + return total * bonus; +} + +export function calcDailyExp(config, state, careerId) { + const careerCfg = config.careers.find(c => c.id === careerId); + if (!careerCfg) return 0; + const base = careerCfg.expCurve.base; + const statKey = STAT_MAP[careerCfg.type]; + const stat = state.stats?.[statKey] ?? 0; + const statBonus = 1 + Math.min(stat, 100) * 0.01; + const meta = state.metaExp?.[careerId] || 0; + const metaBonus = 1 + Math.log(1 + meta / 100); + return base * statBonus * metaBonus; +} + +export function checkLevelUp(config, state) { + if (!state.careers) return; + for (const career of config.careers) { + const careerState = state.careers[career.id]; + if (!careerState) continue; + const { base, factor } = career.expCurve; + let need = base * Math.pow(factor, careerState.level); + while (careerState.exp >= need) { + careerState.exp -= need; + careerState.level++; + need = base * Math.pow(factor, careerState.level); + } + } +} + +export { STAT_MAP }; diff --git a/test/careerEngine.test.js b/test/careerEngine.test.js new file mode 100644 index 0000000..33481e7 --- /dev/null +++ b/test/careerEngine.test.js @@ -0,0 +1,221 @@ +import { + setCareerConfig, + getCareerConfig, + checkUnlocks, + calcDailyIncome, + calcDailyExp, + checkLevelUp, + STAT_MAP, +} from '../src/engine/careerEngine.js'; + +const careersConfig = { + careers: [ + { + id: 'student', + name: '学童', + type: 'scholar', + unlockConditions: { type: 'age', op: '>=', value: 6 }, + dailyIncome: 1, + expCurve: { base: 10, factor: 1.15 }, + }, + { + id: 'book_boy', + name: '书童', + type: 'scholar', + unlockConditions: { type: 'careerLevel', op: '>=', careerId: 'student', value: 100 }, + dailyIncome: 2, + expCurve: { base: 50, factor: 1.2 }, + }, + { + id: 'scholar', + name: '书生', + type: 'scholar', + unlockConditions: { + type: 'and', + conditions: [ + { type: 'careerLevel', op: '>=', careerId: 'student', value: 250 }, + { type: 'age', op: '>=', value: 14 }, + ], + }, + dailyIncome: -3, + expCurve: { base: 100, factor: 1.25 }, + }, + { + id: 'soldier', + name: '士兵', + type: 'military', + unlockConditions: { + type: 'and', + conditions: [ + { type: 'age', op: '>=', value: 14 }, + { type: 'stat', op: '>=', stat: 'body', value: 10 }, + ], + }, + dailyIncome: 2, + expCurve: { base: 20, factor: 1.18 }, + }, + { + id: 'taoist_priest', + name: '道士', + type: 'immortal', + unlockConditions: { + type: 'and', + conditions: [ + { type: 'age', op: '>=', value: 10 }, + { type: 'hasItem', itemId: 'taoist_license' }, + ], + }, + dailyIncome: 1, + expCurve: { base: 30, factor: 1.22 }, + }, + ], +}; + +function assertEqual(actual, expected, message) { + if (actual !== expected) { + throw new Error(`${message}: expected ${expected}, got ${actual}`); + } +} + +function assertTrue(value, message) { + if (!value) { + throw new Error(`${message}: expected true, got ${value}`); + } +} + +function testCheckUnlocks() { + // Test age-based unlock + let state = { age: 6, stats: {}, careers: {} }; + checkUnlocks(careersConfig, state); + assertTrue(state.careers.student !== undefined, 'student should unlock at age 6'); + assertEqual(state.careers.student.level, 0, 'student initial level'); + assertEqual(state.careers.student.exp, 0, 'student initial exp'); + + // Test career level unlock + state = { age: 20, stats: {}, careers: { student: { level: 100, exp: 0 } } }; + checkUnlocks(careersConfig, state); + assertTrue(state.careers.book_boy !== undefined, 'book_boy should unlock when student reaches 100'); + + // Test combined unlock (student 250 + age 14) + state = { age: 14, stats: { body: 20 }, careers: { student: { level: 250, exp: 0 } } }; + checkUnlocks(careersConfig, state); + assertTrue(state.careers.scholar !== undefined, 'scholar should unlock at student 250 and age 14'); + + // Test not unlocking when conditions not met + state = { age: 5, stats: {}, careers: {} }; + checkUnlocks(careersConfig, state); + assertTrue(state.careers.student === undefined, 'student should not unlock at age 5'); + + // Test soldier unlock (age 14 + body >= 10) + state = { age: 14, stats: { body: 10 }, careers: {} }; + checkUnlocks(careersConfig, state); + assertTrue(state.careers.soldier !== undefined, 'soldier should unlock at age 14 with body 10'); + + // Test taoist_priest unlock (age 10 + hasItem) + state = { age: 10, stats: {}, shopItems: { taoist_license: 1 }, careers: {} }; + checkUnlocks(careersConfig, state); + assertTrue(state.careers.taoist_priest !== undefined, 'taoist_priest should unlock at age 10 with taoist_license'); +} + +function testCalcDailyIncome() { + // Test basic income + let state = { stats: { business: 0 }, careers: { student: { level: 0, exp: 0 } } }; + let income = calcDailyIncome(careersConfig, state); + assertEqual(income, 1, 'basic income with student only'); + + // Test multiple careers + state = { stats: { business: 0 }, careers: { student: { level: 0, exp: 0 }, soldier: { level: 0, exp: 0 } } }; + income = calcDailyIncome(careersConfig, state); + assertEqual(income, 3, 'income with student + soldier'); + + // Test business bonus + state = { stats: { business: 50 }, careers: { student: { level: 0, exp: 0 } } }; + income = calcDailyIncome(careersConfig, state); + assertEqual(income, 1.5, 'income with 50 business bonus'); + + // Test business cap at 100 + state = { stats: { business: 150 }, careers: { student: { level: 0, exp: 0 } } }; + income = calcDailyIncome(careersConfig, state); + assertEqual(income, 2, 'income with business capped at 100'); +} + +function testCalcDailyExp() { + // Test basic exp + let state = { stats: { wisdom: 0 }, metaExp: {} }; + let exp = calcDailyExp(careersConfig, state, 'student'); + assertEqual(exp, 10, 'basic student exp with 0 wisdom'); + + // Test stat bonus + state = { stats: { wisdom: 50 }, metaExp: {} }; + exp = calcDailyExp(careersConfig, state, 'student'); + assertEqual(exp, 15, 'student exp with 50 wisdom bonus'); + + // Test meta exp bonus + state = { stats: { wisdom: 0 }, metaExp: { student: 100 } }; + exp = calcDailyExp(careersConfig, state, 'student'); + const expectedMetaBonus = 1 + Math.log(2); + assertEqual(exp, 10 * expectedMetaBonus, 'student exp with meta bonus'); + + // Test stat cap at 100 + state = { stats: { wisdom: 150 }, metaExp: {} }; + exp = calcDailyExp(careersConfig, state, 'student'); + assertEqual(exp, 20, 'student exp with wisdom capped at 100'); + + // Test military type uses body stat + state = { stats: { body: 50 }, metaExp: {} }; + exp = calcDailyExp(careersConfig, state, 'soldier'); + assertEqual(exp, 30, 'soldier exp with 50 body bonus'); +} + +function testCheckLevelUp() { + // Test single level up + let state = { careers: { student: { level: 0, exp: 12 } } }; + checkLevelUp(careersConfig, state); + assertEqual(state.careers.student.level, 1, 'student should level up once'); + assertEqual(state.careers.student.exp, 2, 'remaining exp after level up'); + + // Test multiple level ups + state = { careers: { student: { level: 0, exp: 30 } } }; + checkLevelUp(careersConfig, state); + assertEqual(state.careers.student.level, 2, 'student should level up twice'); + // need0=10, need1=11.5, total=21.5, remaining=8.5 + assertTrue(Math.abs(state.careers.student.exp - 8.5) < 0.0001, 'remaining exp after 2 level ups'); + + // Test no level up when exp insufficient + state = { careers: { student: { level: 0, exp: 5 } } }; + checkLevelUp(careersConfig, state); + assertEqual(state.careers.student.level, 0, 'student should not level up with 5 exp'); + assertEqual(state.careers.student.exp, 5, 'exp unchanged'); +} + +function runTests() { + let passed = 0; + let failed = 0; + + const tests = [ + { name: 'testCheckUnlocks', fn: testCheckUnlocks }, + { name: 'testCalcDailyIncome', fn: testCalcDailyIncome }, + { name: 'testCalcDailyExp', fn: testCalcDailyExp }, + { name: 'testCheckLevelUp', fn: testCheckLevelUp }, + ]; + + for (const test of tests) { + try { + test.fn(); + console.log(` PASS: ${test.name}`); + passed++; + } catch (err) { + console.log(` FAIL: ${test.name} - ${err.message}`); + failed++; + } + } + + console.log(`\n${passed} passed, ${failed} failed`); + if (failed === 0) { + console.log('All careerEngine tests PASS'); + } else { + process.exit(1); + } +} + +runTests();