feat: add invasion and death engines
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
// ==================== 死亡引擎 ====================
|
||||
|
||||
import { applyEffect } from './effectApplier.js';
|
||||
|
||||
export function checkDeath(state) {
|
||||
// 1. age >= 100 → 寿终正寝
|
||||
if (state.age >= 100) {
|
||||
applyEffect({ type: 'die', reason: '寿终正寝' }, state);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. age >= 60 && Math.random() < 0.1 → 年老体衰
|
||||
if (state.age >= 60 && Math.random() < 0.1) {
|
||||
applyEffect({ type: 'die', reason: '年老体衰' }, state);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 3. stats.body <= 0 → 体弱身亡
|
||||
if (state.stats.body <= 0) {
|
||||
applyEffect({ type: 'die', reason: '体弱身亡' }, state);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 4. age >= 45 && !invasion_military_resisted && Math.random() < 0.05 → 死于蛮族入侵
|
||||
if (state.age >= 45 && !state.worldFlags.invasion_military_resisted && Math.random() < 0.05) {
|
||||
applyEffect({ type: 'die', reason: '死于蛮族入侵' }, state);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 5. age >= 50 && !invasion_spiritual_resisted && Math.random() < 0.05 → 被天魔吞噬
|
||||
if (state.age >= 50 && !state.worldFlags.invasion_spiritual_resisted && Math.random() < 0.05) {
|
||||
applyEffect({ type: 'die', reason: '被天魔吞噬' }, state);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 6. age >= 55 && !invasion_political_resisted && Math.random() < 0.05 → 死于战乱
|
||||
if (state.age >= 55 && !state.worldFlags.invasion_political_resisted && Math.random() < 0.05) {
|
||||
applyEffect({ type: 'die', reason: '死于战乱' }, state);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function processDeath(state, careerConfig) {
|
||||
const metaGains = {};
|
||||
|
||||
for (const [careerId, career] of Object.entries(state.careers)) {
|
||||
const cfg = careerConfig.careers.find(c => c.id === careerId);
|
||||
if (!cfg) continue;
|
||||
|
||||
let totalExp = career.exp;
|
||||
for (let i = 0; i < career.level; i++) {
|
||||
totalExp += Math.floor(cfg.expCurve.base * Math.pow(cfg.expCurve.factor, i));
|
||||
}
|
||||
|
||||
const gain = Math.floor(totalExp * 0.1);
|
||||
state.metaExp[careerId] = (state.metaExp[careerId] || 0) + gain;
|
||||
metaGains[careerId] = gain;
|
||||
}
|
||||
|
||||
return { metaGains };
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// ==================== 入侵引擎 ====================
|
||||
|
||||
export function getInvasionStatus(state) {
|
||||
return [
|
||||
{ key: 'military_40', triggered: state.invasionsTriggered.military_40 },
|
||||
{ key: 'spiritual_45', triggered: state.invasionsTriggered.spiritual_45 },
|
||||
{ key: 'political_50', triggered: state.invasionsTriggered.political_50 },
|
||||
{ key: 'final_60', triggered: state.invasionsTriggered.final_60 },
|
||||
];
|
||||
}
|
||||
|
||||
export function isAllInvasionsResisted(state) {
|
||||
return state.worldFlags.invasion_military_resisted &&
|
||||
state.worldFlags.invasion_spiritual_resisted &&
|
||||
state.worldFlags.invasion_political_resisted;
|
||||
}
|
||||
|
||||
export function checkBreakthrough(state) {
|
||||
if (state.worldFlags.breakthrough_general) return 'general';
|
||||
if (state.worldFlags.breakthrough_minister) return 'minister';
|
||||
if (state.worldFlags.breakthrough_immortal) return 'immortal';
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { checkDeath, processDeath } from '../src/engine/deathEngine.js';
|
||||
|
||||
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 assertFalse(value, message) {
|
||||
if (value) {
|
||||
throw new Error(`${message}: expected false, got ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
function createState(overrides = {}) {
|
||||
return {
|
||||
age: 0,
|
||||
alive: true,
|
||||
stats: { body: 10 },
|
||||
worldFlags: {},
|
||||
careers: {},
|
||||
metaExp: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const careersConfig = {
|
||||
careers: [
|
||||
{
|
||||
id: 'student',
|
||||
expCurve: { base: 10, factor: 1.15 },
|
||||
},
|
||||
{
|
||||
id: 'soldier',
|
||||
expCurve: { base: 20, factor: 1.18 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function testCheckDeathOldAge() {
|
||||
const state = createState({ age: 100 });
|
||||
const result = checkDeath(state);
|
||||
assertTrue(result, 'should die at age 100');
|
||||
assertFalse(state.alive, 'alive should be false');
|
||||
assertEqual(state.deathReason, '寿终正寝', 'death reason should be 寿终正寝');
|
||||
}
|
||||
|
||||
function testCheckDeathBodyZero() {
|
||||
const state = createState({ age: 30, stats: { body: 0 } });
|
||||
const result = checkDeath(state);
|
||||
assertTrue(result, 'should die when body <= 0');
|
||||
assertFalse(state.alive, 'alive should be false');
|
||||
assertEqual(state.deathReason, '体弱身亡', 'death reason should be 体弱身亡');
|
||||
}
|
||||
|
||||
function testCheckDeathNotDead() {
|
||||
const state = createState({ age: 30, stats: { body: 5 } });
|
||||
const result = checkDeath(state);
|
||||
assertFalse(result, 'should not die when young and body > 0');
|
||||
assertTrue(state.alive, 'should remain alive');
|
||||
}
|
||||
|
||||
function testCheckDeathMilitaryInvasion() {
|
||||
// Mock Math.random to always return 0.01 (below 0.05 threshold)
|
||||
const originalRandom = Math.random;
|
||||
Math.random = () => 0.01;
|
||||
|
||||
try {
|
||||
const state = createState({ age: 45, stats: { body: 5 } });
|
||||
const result = checkDeath(state);
|
||||
assertTrue(result, 'should die from military invasion');
|
||||
assertFalse(state.alive, 'alive should be false');
|
||||
assertEqual(state.deathReason, '死于蛮族入侵', 'death reason should be 死于蛮族入侵');
|
||||
} finally {
|
||||
Math.random = originalRandom;
|
||||
}
|
||||
}
|
||||
|
||||
function testCheckDeathMilitaryInvasionResisted() {
|
||||
// Mock Math.random to always return 0.01 (below 0.05 threshold)
|
||||
const originalRandom = Math.random;
|
||||
Math.random = () => 0.01;
|
||||
|
||||
try {
|
||||
const state = createState({ age: 45, stats: { body: 5 }, worldFlags: { invasion_military_resisted: true } });
|
||||
const result = checkDeath(state);
|
||||
// Since military is resisted, should fall through to next condition (age >= 60 random check)
|
||||
// But age is 45, so not dead
|
||||
assertFalse(result, 'should not die when military invasion resisted');
|
||||
assertTrue(state.alive, 'should remain alive when resisted');
|
||||
} finally {
|
||||
Math.random = originalRandom;
|
||||
}
|
||||
}
|
||||
|
||||
function testCheckDeathSpiritualInvasion() {
|
||||
const originalRandom = Math.random;
|
||||
let callCount = 0;
|
||||
Math.random = () => {
|
||||
callCount++;
|
||||
return callCount === 1 ? 0.1 : 0.01;
|
||||
};
|
||||
|
||||
try {
|
||||
const state = createState({ age: 50, stats: { body: 5 } });
|
||||
const result = checkDeath(state);
|
||||
assertTrue(result, 'should die from spiritual invasion');
|
||||
assertFalse(state.alive, 'alive should be false');
|
||||
assertEqual(state.deathReason, '被天魔吞噬', 'death reason should be 被天魔吞噬');
|
||||
} finally {
|
||||
Math.random = originalRandom;
|
||||
}
|
||||
}
|
||||
|
||||
function testCheckDeathPoliticalInvasion() {
|
||||
const originalRandom = Math.random;
|
||||
let callCount = 0;
|
||||
Math.random = () => {
|
||||
callCount++;
|
||||
return callCount <= 2 ? 0.1 : 0.01;
|
||||
};
|
||||
|
||||
try {
|
||||
const state = createState({ age: 55, stats: { body: 5 } });
|
||||
const result = checkDeath(state);
|
||||
assertTrue(result, 'should die from political invasion');
|
||||
assertFalse(state.alive, 'alive should be false');
|
||||
assertEqual(state.deathReason, '死于战乱', 'death reason should be 死于战乱');
|
||||
} finally {
|
||||
Math.random = originalRandom;
|
||||
}
|
||||
}
|
||||
|
||||
function testProcessDeath() {
|
||||
const state = createState({
|
||||
careers: {
|
||||
student: { level: 2, exp: 5 },
|
||||
soldier: { level: 1, exp: 10 },
|
||||
},
|
||||
metaExp: {},
|
||||
});
|
||||
|
||||
const result = processDeath(state, careersConfig);
|
||||
|
||||
// student: totalExp = 5 + floor(10 * 1.15^0) + floor(10 * 1.15^1) = 5 + 10 + 11 = 26
|
||||
// gain = floor(26 * 0.1) = 2
|
||||
assertEqual(result.metaGains.student, 2, 'student meta gain should be 2');
|
||||
assertEqual(state.metaExp.student, 2, 'student metaExp should be 2');
|
||||
|
||||
// soldier: totalExp = 10 + floor(20 * 1.18^0) = 10 + 20 = 30
|
||||
// gain = floor(30 * 0.1) = 3
|
||||
assertEqual(result.metaGains.soldier, 3, 'soldier meta gain should be 3');
|
||||
assertEqual(state.metaExp.soldier, 3, 'soldier metaExp should be 3');
|
||||
}
|
||||
|
||||
function testProcessDeathAccumulates() {
|
||||
const state = createState({
|
||||
careers: {
|
||||
student: { level: 0, exp: 10 },
|
||||
},
|
||||
metaExp: { student: 5 },
|
||||
});
|
||||
|
||||
const result = processDeath(state, careersConfig);
|
||||
|
||||
// student: totalExp = 10, gain = floor(10 * 0.1) = 1
|
||||
assertEqual(result.metaGains.student, 1, 'student meta gain should be 1');
|
||||
assertEqual(state.metaExp.student, 6, 'student metaExp should accumulate from 5 to 6');
|
||||
}
|
||||
|
||||
function testProcessDeathNoCareerConfig() {
|
||||
const state = createState({
|
||||
careers: {
|
||||
unknown: { level: 1, exp: 10 },
|
||||
},
|
||||
metaExp: {},
|
||||
});
|
||||
|
||||
const result = processDeath(state, careersConfig);
|
||||
|
||||
assertEqual(Object.keys(result.metaGains).length, 0, 'no gains for unknown career');
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
const tests = [
|
||||
{ name: 'testCheckDeathOldAge', fn: testCheckDeathOldAge },
|
||||
{ name: 'testCheckDeathBodyZero', fn: testCheckDeathBodyZero },
|
||||
{ name: 'testCheckDeathNotDead', fn: testCheckDeathNotDead },
|
||||
{ name: 'testCheckDeathMilitaryInvasion', fn: testCheckDeathMilitaryInvasion },
|
||||
{ name: 'testCheckDeathMilitaryInvasionResisted', fn: testCheckDeathMilitaryInvasionResisted },
|
||||
{ name: 'testCheckDeathSpiritualInvasion', fn: testCheckDeathSpiritualInvasion },
|
||||
{ name: 'testCheckDeathPoliticalInvasion', fn: testCheckDeathPoliticalInvasion },
|
||||
{ name: 'testProcessDeath', fn: testProcessDeath },
|
||||
{ name: 'testProcessDeathAccumulates', fn: testProcessDeathAccumulates },
|
||||
{ name: 'testProcessDeathNoCareerConfig', fn: testProcessDeathNoCareerConfig },
|
||||
];
|
||||
|
||||
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 deathEngine tests PASS');
|
||||
} else {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,172 @@
|
||||
import {
|
||||
getInvasionStatus,
|
||||
isAllInvasionsResisted,
|
||||
checkBreakthrough,
|
||||
} from '../src/engine/invasionEngine.js';
|
||||
|
||||
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 assertArrayEqual(actual, expected, message) {
|
||||
if (actual.length !== expected.length) {
|
||||
throw new Error(`${message}: length mismatch, expected ${expected.length}, got ${actual.length}`);
|
||||
}
|
||||
for (let i = 0; i < actual.length; i++) {
|
||||
if (actual[i].key !== expected[i].key || actual[i].triggered !== expected[i].triggered) {
|
||||
throw new Error(`${message}: mismatch at index ${i}, expected ${JSON.stringify(expected[i])}, got ${JSON.stringify(actual[i])}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function testGetInvasionStatus() {
|
||||
let state = {
|
||||
invasionsTriggered: {
|
||||
military_40: false,
|
||||
spiritual_45: false,
|
||||
political_50: false,
|
||||
final_60: false,
|
||||
},
|
||||
};
|
||||
let status = getInvasionStatus(state);
|
||||
assertArrayEqual(status, [
|
||||
{ key: 'military_40', triggered: false },
|
||||
{ key: 'spiritual_45', triggered: false },
|
||||
{ key: 'political_50', triggered: false },
|
||||
{ key: 'final_60', triggered: false },
|
||||
], 'all false');
|
||||
|
||||
state = {
|
||||
invasionsTriggered: {
|
||||
military_40: true,
|
||||
spiritual_45: true,
|
||||
political_50: true,
|
||||
final_60: true,
|
||||
},
|
||||
};
|
||||
status = getInvasionStatus(state);
|
||||
assertArrayEqual(status, [
|
||||
{ key: 'military_40', triggered: true },
|
||||
{ key: 'spiritual_45', triggered: true },
|
||||
{ key: 'political_50', triggered: true },
|
||||
{ key: 'final_60', triggered: true },
|
||||
], 'all true');
|
||||
|
||||
state = {
|
||||
invasionsTriggered: {
|
||||
military_40: true,
|
||||
spiritual_45: false,
|
||||
political_50: true,
|
||||
final_60: false,
|
||||
},
|
||||
};
|
||||
status = getInvasionStatus(state);
|
||||
assertArrayEqual(status, [
|
||||
{ key: 'military_40', triggered: true },
|
||||
{ key: 'spiritual_45', triggered: false },
|
||||
{ key: 'political_50', triggered: true },
|
||||
{ key: 'final_60', triggered: false },
|
||||
], 'mixed values');
|
||||
}
|
||||
|
||||
function testIsAllInvasionsResisted() {
|
||||
let state = {
|
||||
worldFlags: {
|
||||
invasion_military_resisted: true,
|
||||
invasion_spiritual_resisted: true,
|
||||
invasion_political_resisted: true,
|
||||
},
|
||||
};
|
||||
assertTrue(isAllInvasionsResisted(state), 'all resisted should be true');
|
||||
|
||||
state = {
|
||||
worldFlags: {
|
||||
invasion_military_resisted: true,
|
||||
invasion_spiritual_resisted: true,
|
||||
invasion_political_resisted: false,
|
||||
},
|
||||
};
|
||||
assertEqual(isAllInvasionsResisted(state), false, 'one not resisted should be false');
|
||||
|
||||
state = {
|
||||
worldFlags: {
|
||||
invasion_military_resisted: false,
|
||||
invasion_spiritual_resisted: false,
|
||||
invasion_political_resisted: false,
|
||||
},
|
||||
};
|
||||
assertEqual(isAllInvasionsResisted(state), false, 'none resisted should be false');
|
||||
}
|
||||
|
||||
function testCheckBreakthrough() {
|
||||
let state = {
|
||||
worldFlags: {
|
||||
breakthrough_general: true,
|
||||
breakthrough_minister: false,
|
||||
breakthrough_immortal: false,
|
||||
},
|
||||
};
|
||||
assertEqual(checkBreakthrough(state), 'general', 'general breakthrough');
|
||||
|
||||
state = {
|
||||
worldFlags: {
|
||||
breakthrough_general: false,
|
||||
breakthrough_minister: true,
|
||||
breakthrough_immortal: false,
|
||||
},
|
||||
};
|
||||
assertEqual(checkBreakthrough(state), 'minister', 'minister breakthrough');
|
||||
|
||||
state = {
|
||||
worldFlags: {
|
||||
breakthrough_general: false,
|
||||
breakthrough_minister: false,
|
||||
breakthrough_immortal: true,
|
||||
},
|
||||
};
|
||||
assertEqual(checkBreakthrough(state), 'immortal', 'immortal breakthrough');
|
||||
|
||||
state = {
|
||||
worldFlags: {},
|
||||
};
|
||||
assertEqual(checkBreakthrough(state), null, 'no breakthrough');
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
const tests = [
|
||||
{ name: 'testGetInvasionStatus', fn: testGetInvasionStatus },
|
||||
{ name: 'testIsAllInvasionsResisted', fn: testIsAllInvasionsResisted },
|
||||
{ name: 'testCheckBreakthrough', fn: testCheckBreakthrough },
|
||||
];
|
||||
|
||||
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 invasionEngine tests PASS');
|
||||
} else {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
runTests();
|
||||
Reference in New Issue
Block a user