feat: add career engine with unlock, income, exp, level-up
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user