From c9a3b4a9819643e010bd669b0b1489c0f3381b6b Mon Sep 17 00:00:00 2001 From: congsh <2452821485@qq.com> Date: Wed, 13 May 2026 04:23:58 +0000 Subject: [PATCH] feat: add invasion and death engines --- src/engine/deathEngine.js | 63 ++++++++++ src/engine/invasionEngine.js | 23 ++++ test/deathEngine.test.js | 226 +++++++++++++++++++++++++++++++++++ test/invasionEngine.test.js | 172 ++++++++++++++++++++++++++ 4 files changed, 484 insertions(+) create mode 100644 src/engine/deathEngine.js create mode 100644 src/engine/invasionEngine.js create mode 100644 test/deathEngine.test.js create mode 100644 test/invasionEngine.test.js diff --git a/src/engine/deathEngine.js b/src/engine/deathEngine.js new file mode 100644 index 0000000..72462e3 --- /dev/null +++ b/src/engine/deathEngine.js @@ -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 }; +} diff --git a/src/engine/invasionEngine.js b/src/engine/invasionEngine.js new file mode 100644 index 0000000..c6a8bd0 --- /dev/null +++ b/src/engine/invasionEngine.js @@ -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; +} diff --git a/test/deathEngine.test.js b/test/deathEngine.test.js new file mode 100644 index 0000000..98c51e1 --- /dev/null +++ b/test/deathEngine.test.js @@ -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(); diff --git a/test/invasionEngine.test.js b/test/invasionEngine.test.js new file mode 100644 index 0000000..48c8707 --- /dev/null +++ b/test/invasionEngine.test.js @@ -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();