From 6373ee1bac08f20058fb9bd3b2e4a1bffedf9c69 Mon Sep 17 00:00:00 2001 From: congsh <2452821485@qq.com> Date: Wed, 13 May 2026 03:43:07 +0000 Subject: [PATCH] feat: add event engine with age trigger, random pool, choice filtering --- src/config/events.json | 222 ++++++++++++++++++++++++ src/engine/eventEngine.js | 71 ++++++++ test/eventEngine.test.js | 355 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 648 insertions(+) create mode 100644 src/config/events.json create mode 100644 src/engine/eventEngine.js create mode 100644 test/eventEngine.test.js diff --git a/src/config/events.json b/src/config/events.json new file mode 100644 index 0000000..df0a92e --- /dev/null +++ b/src/config/events.json @@ -0,0 +1,222 @@ +{ + "events": [ + { + "id": "daily_training", + "name": "日常修炼", + "description": "你坚持日常修炼,体质有所提升。", + "trigger": { + "type": "random", + "pool": "normal" + }, + "weight": 50, + "effects": [ + { + "type": "addStat", + "stat": "体质", + "value": 1 + } + ] + }, + { + "id": "study_hard", + "name": "刻苦学习", + "description": "你发奋读书,悟性有所提升。", + "trigger": { + "type": "random", + "pool": "normal" + }, + "weight": 50, + "effects": [ + { + "type": "addStat", + "stat": "悟性", + "value": 1 + } + ] + }, + { + "id": "invasion_military_40", + "name": "蛮族入侵", + "description": "北方蛮族大举入侵,边境告急!", + "trigger": { + "type": "age", + "value": 40 + }, + "isChoice": true, + "choices": [ + { + "id": "resist", + "text": "率军抵抗", + "requirements": { + "type": "careerLevel", + "careerId": "soldier", + "op": ">=", + "value": 50 + }, + "effects": [ + { + "type": "addLog", + "text": "你率领大军击退了蛮族入侵,威震四方!" + } + ] + }, + { + "id": "flee", + "text": "弃城逃跑", + "effects": [ + { + "type": "addLog", + "text": "你选择了弃城逃跑,虽然保住了性命,但名声扫地。" + } + ] + } + ] + }, + { + "id": "invasion_spiritual_45", + "name": "天魔降世", + "description": "天魔降世,人间陷入混乱,你该如何应对?", + "trigger": { + "type": "age", + "value": 45 + }, + "isChoice": true, + "choices": [ + { + "id": "suppress", + "text": "出手镇压", + "requirements": { + "type": "careerLevel", + "careerId": "taoist", + "op": ">=", + "value": 50 + }, + "effects": [ + { + "type": "addLog", + "text": "你以无上道法镇压天魔,拯救了苍生!" + } + ] + }, + { + "id": "hide", + "text": "躲藏起来", + "effects": [ + { + "type": "addLog", + "text": "你选择了躲藏起来,避开了这场浩劫。" + } + ] + } + ] + }, + { + "id": "invasion_political_50", + "name": "王朝崩坏", + "description": "王朝内部腐败,民不聊生,天下大乱。", + "trigger": { + "type": "age", + "value": 50 + }, + "isChoice": true, + "choices": [ + { + "id": "assist", + "text": "辅佐明君", + "requirements": { + "type": "stat", + "stat": "智力", + "op": ">=", + "value": 50 + }, + "effects": [ + { + "type": "addLog", + "text": "你以智谋辅佐明君,力挽狂澜,拯救了王朝。" + } + ] + }, + { + "id": "selfish", + "text": "独善其身", + "effects": [ + { + "type": "addLog", + "text": "你选择了独善其身,在乱世中保全了自己。" + } + ] + } + ] + }, + { + "id": "final_crisis_60", + "name": "最终危机", + "description": "世界面临最终危机,这是你最后的选择。", + "trigger": { + "type": "age", + "value": 60 + }, + "isChoice": true, + "choices": [ + { + "id": "general", + "text": "以将军之力平定天下", + "requirements": { + "type": "careerLevel", + "careerId": "soldier", + "op": ">=", + "value": 50 + }, + "effects": [ + { + "type": "addLog", + "text": "你以将军之力平定天下,成为一代名将!" + } + ] + }, + { + "id": "chancellor", + "text": "以宰相之智治理国家", + "requirements": { + "type": "stat", + "stat": "智力", + "op": ">=", + "value": 50 + }, + "effects": [ + { + "type": "addLog", + "text": "你以宰相之智治理国家,名垂青史!" + } + ] + }, + { + "id": "cultivator", + "text": "以修仙之道超脱轮回", + "requirements": { + "type": "careerLevel", + "careerId": "taoist", + "op": ">=", + "value": 50 + }, + "effects": [ + { + "type": "addLog", + "text": "你以修仙之道超脱轮回,飞升成仙!" + } + ] + }, + { + "id": "powerless", + "text": "无力回天", + "effects": [ + { + "type": "addLog", + "text": "你感到无力回天,只能接受命运的安排。" + } + ] + } + ] + } + ] +} diff --git a/src/engine/eventEngine.js b/src/engine/eventEngine.js new file mode 100644 index 0000000..302cd95 --- /dev/null +++ b/src/engine/eventEngine.js @@ -0,0 +1,71 @@ +// ==================== 事件引擎 ==================== + +import { evaluateCondition } from './conditionEvaluator.js'; + +let _eventConfig = null; + +export function setEventConfig(config) { + _eventConfig = config; +} + +export function getEventConfig() { + return _eventConfig; +} + +export function checkAgeEvents(config, state) { + const triggered = []; + if (!config || !config.events) return triggered; + if (!state.triggeredEvents) state.triggeredEvents = new Set(); + + for (const event of config.events) { + if (event.trigger?.type !== 'age') continue; + if (state.triggeredEvents.has(event.id)) continue; + if (state.age === event.trigger.value) { + triggered.push(event); + state.triggeredEvents.add(event.id); + } + } + return triggered; +} + +export function pickRandomEvent(config, state, pool) { + if (!config || !config.events) return null; + if (!state.triggeredEvents) state.triggeredEvents = new Set(); + + const candidates = config.events.filter(event => { + if (event.trigger?.type !== 'random') return false; + if (event.trigger.pool !== pool) return false; + if (state.triggeredEvents.has(event.id)) return false; + return true; + }); + + if (candidates.length === 0) return null; + + const totalWeight = candidates.reduce((sum, e) => sum + (e.weight || 0), 0); + if (totalWeight <= 0) return null; + + let roll = Math.random() * totalWeight; + for (const event of candidates) { + roll -= (event.weight || 0); + if (roll <= 0) return event; + } + return candidates[candidates.length - 1]; +} + +export function getAvailableChoices(event, state) { + if (!event || !event.choices) return []; + return event.choices.filter(choice => { + if (!choice.requirements) return true; + return evaluateCondition(choice.requirements, state); + }); +} + +export function executeEvent(event, state, applyEffectFn) { + if (!event || !event.effects) return; + if (!state.triggeredEvents) state.triggeredEvents = new Set(); + + for (const effect of event.effects) { + applyEffectFn(effect, state); + } + state.triggeredEvents.add(event.id); +} diff --git a/test/eventEngine.test.js b/test/eventEngine.test.js new file mode 100644 index 0000000..b350473 --- /dev/null +++ b/test/eventEngine.test.js @@ -0,0 +1,355 @@ +import { + setEventConfig, + getEventConfig, + checkAgeEvents, + pickRandomEvent, + getAvailableChoices, + executeEvent, +} from '../src/engine/eventEngine.js'; +import { evaluateCondition } from '../src/engine/conditionEvaluator.js'; +import { applyEffect } from '../src/engine/effectApplier.js'; + +// ==================== 测试数据 ==================== + +const testConfig = { + events: [ + { + id: 'daily_training', + name: '日常修炼', + trigger: { type: 'random', pool: 'normal' }, + weight: 50, + effects: [{ type: 'addStat', stat: '体质', value: 1 }], + }, + { + id: 'study_hard', + name: '刻苦学习', + trigger: { type: 'random', pool: 'normal' }, + weight: 50, + effects: [{ type: 'addStat', stat: '悟性', value: 1 }], + }, + { + id: 'invasion_military_40', + name: '蛮族入侵', + trigger: { type: 'age', value: 40 }, + isChoice: true, + choices: [ + { + id: 'resist', + text: '率军抵抗', + requirements: { + type: 'careerLevel', + careerId: 'soldier', + op: '>=', + value: 50, + }, + effects: [{ type: 'addLog', text: '你率领大军击退了蛮族入侵!' }], + }, + { + id: 'flee', + text: '弃城逃跑', + effects: [{ type: 'addLog', text: '你选择了弃城逃跑。' }], + }, + ], + }, + { + id: 'invasion_spiritual_45', + name: '天魔降世', + trigger: { type: 'age', value: 45 }, + isChoice: true, + choices: [ + { + id: 'suppress', + text: '出手镇压', + requirements: { + type: 'careerLevel', + careerId: 'taoist', + op: '>=', + value: 50, + }, + effects: [{ type: 'addLog', text: '你以无上道法镇压天魔!' }], + }, + { + id: 'hide', + text: '躲藏起来', + effects: [{ type: 'addLog', text: '你选择了躲藏起来。' }], + }, + ], + }, + { + id: 'invasion_political_50', + name: '王朝崩坏', + trigger: { type: 'age', value: 50 }, + isChoice: true, + choices: [ + { + id: 'assist', + text: '辅佐明君', + requirements: { + type: 'stat', + stat: '智力', + op: '>=', + value: 50, + }, + effects: [{ type: 'addLog', text: '你以智谋辅佐明君!' }], + }, + { + id: 'selfish', + text: '独善其身', + effects: [{ type: 'addLog', text: '你选择了独善其身。' }], + }, + ], + }, + { + id: 'final_crisis_60', + name: '最终危机', + trigger: { type: 'age', value: 60 }, + isChoice: true, + choices: [ + { + id: 'general', + text: '以将军之力平定天下', + requirements: { + type: 'careerLevel', + careerId: 'soldier', + op: '>=', + value: 50, + }, + effects: [{ type: 'addLog', text: '你以将军之力平定天下!' }], + }, + { + id: 'chancellor', + text: '以宰相之智治理国家', + requirements: { + type: 'stat', + stat: '智力', + op: '>=', + value: 50, + }, + effects: [{ type: 'addLog', text: '你以宰相之智治理国家!' }], + }, + { + id: 'cultivator', + text: '以修仙之道超脱轮回', + requirements: { + type: 'careerLevel', + careerId: 'taoist', + op: '>=', + value: 50, + }, + effects: [{ type: 'addLog', text: '你以修仙之道超脱轮回!' }], + }, + { + id: 'powerless', + text: '无力回天', + effects: [{ type: 'addLog', text: '你感到无力回天。' }], + }, + ], + }, + ], +}; + +// ==================== 测试辅助函数 ==================== + +function assertEqual(actual, expected, message) { + const actualStr = JSON.stringify(actual); + const expectedStr = JSON.stringify(expected); + if (actualStr !== expectedStr) { + throw new Error( + `${message}\n 期望: ${expectedStr}\n 实际: ${actualStr}` + ); + } +} + +function assertTrue(value, message) { + if (!value) { + throw new Error(message || `期望为 true,实际为 ${value}`); + } +} + +function assertFalse(value, message) { + if (value) { + throw new Error(message || `期望为 false,实际为 ${value}`); + } +} + +// ==================== 测试用例 ==================== + +function testSetAndGetEventConfig() { + setEventConfig(testConfig); + const config = getEventConfig(); + assertEqual(config.events.length, 6, '配置应有6个事件'); +} + +function testCheckAgeEvents() { + // 测试40岁触发入侵事件 + const state = { + age: 40, + triggeredEvents: new Set(), + }; + + const triggered = checkAgeEvents(testConfig, state); + assertEqual(triggered.length, 1, '40岁应触发1个事件'); + assertEqual(triggered[0].id, 'invasion_military_40', '40岁应触发蛮族入侵事件'); + assertTrue( + state.triggeredEvents.has('invasion_military_40'), + '事件ID应加入triggeredEvents' + ); + + // 再次检查,不应重复触发 + const triggeredAgain = checkAgeEvents(testConfig, state); + assertEqual(triggeredAgain.length, 0, '已触发的事件不应重复触发'); + + // 测试45岁触发 + const state45 = { + age: 45, + triggeredEvents: new Set(), + }; + const triggered45 = checkAgeEvents(testConfig, state45); + assertEqual(triggered45.length, 1, '45岁应触发1个事件'); + assertEqual(triggered45[0].id, 'invasion_spiritual_45', '45岁应触发天魔降世事件'); + + // 测试50岁触发 + const state50 = { + age: 50, + triggeredEvents: new Set(), + }; + const triggered50 = checkAgeEvents(testConfig, state50); + assertEqual(triggered50.length, 1, '50岁应触发1个事件'); + assertEqual(triggered50[0].id, 'invasion_political_50', '50岁应触发王朝崩坏事件'); + + // 测试60岁触发 + const state60 = { + age: 60, + triggeredEvents: new Set(), + }; + const triggered60 = checkAgeEvents(testConfig, state60); + assertEqual(triggered60.length, 1, '60岁应触发1个事件'); + assertEqual(triggered60[0].id, 'final_crisis_60', '60岁应触发最终危机事件'); + + // 测试非触发年龄 + const state30 = { + age: 30, + triggeredEvents: new Set(), + }; + const triggered30 = checkAgeEvents(testConfig, state30); + assertEqual(triggered30.length, 0, '30岁不应触发任何事件'); +} + +function testPickRandomEvent() { + // 从normal池抽取 + const state = { + triggeredEvents: new Set(), + }; + + const event = pickRandomEvent(testConfig, state, 'normal'); + assertTrue(event !== null, '应从normal池抽到一个事件'); + assertTrue( + event.id === 'daily_training' || event.id === 'study_hard', + '抽到的事件应在normal池中' + ); + + // 测试空池 + const emptyEvent = pickRandomEvent(testConfig, state, 'nonexistent'); + assertEqual(emptyEvent, null, '不存在的池应返回null'); + + // 测试所有事件都被触发后 + state.triggeredEvents.add('daily_training'); + state.triggeredEvents.add('study_hard'); + const noEvent = pickRandomEvent(testConfig, state, 'normal'); + assertEqual(noEvent, null, '所有事件触发后应返回null'); +} + +function testGetAvailableChoices() { + // 测试无requirement的选项始终可用 + const event = testConfig.events.find(e => e.id === 'invasion_military_40'); + const stateNoSoldier = { + careers: {}, + }; + const choicesNoSoldier = getAvailableChoices(event, stateNoSoldier); + assertEqual(choicesNoSoldier.length, 1, '无士兵职业时应有1个可用选项'); + assertEqual(choicesNoSoldier[0].id, 'flee', '可用选项应为逃跑'); + + // 测试满足requirement + const stateWithSoldier = { + careers: { + soldier: { level: 50 }, + }, + }; + const choicesWithSoldier = getAvailableChoices(event, stateWithSoldier); + assertEqual(choicesWithSoldier.length, 2, '有士兵50级时应有2个可用选项'); + + // 测试最终危机事件 + const finalEvent = testConfig.events.find(e => e.id === 'final_crisis_60'); + const stateAll = { + careers: { + soldier: { level: 50 }, + taoist: { level: 50 }, + }, + stats: { + 智力: 50, + }, + }; + const allChoices = getAvailableChoices(finalEvent, stateAll); + assertEqual(allChoices.length, 4, '满足所有条件时应有4个可用选项'); + + const stateNone = { + careers: {}, + stats: {}, + }; + const noChoices = getAvailableChoices(finalEvent, stateNone); + assertEqual(noChoices.length, 1, '不满足任何条件时应有1个可用选项(无力回天)'); + assertEqual(noChoices[0].id, 'powerless', '唯一可用选项应为无力回天'); +} + +function testExecuteEvent() { + const event = testConfig.events.find(e => e.id === 'daily_training'); + const state = { + stats: {}, + triggeredEvents: new Set(), + }; + + executeEvent(event, state, applyEffect); + assertEqual(state.stats['体质'], 1, '执行事件后体质应增加1'); + assertTrue( + state.triggeredEvents.has('daily_training'), + '事件ID应加入triggeredEvents' + ); +} + +// ==================== 运行测试 ==================== + +function runTests() { + const tests = [ + { name: 'testSetAndGetEventConfig', fn: testSetAndGetEventConfig }, + { name: 'testCheckAgeEvents', fn: testCheckAgeEvents }, + { name: 'testPickRandomEvent', fn: testPickRandomEvent }, + { name: 'testGetAvailableChoices', fn: testGetAvailableChoices }, + { name: 'testExecuteEvent', fn: testExecuteEvent }, + ]; + + let passed = 0; + let failed = 0; + + for (const test of tests) { + try { + test.fn(); + console.log(` PASS: ${test.name}`); + passed++; + } catch (err) { + console.log(` FAIL: ${test.name}`); + console.log(` ${err.message}`); + failed++; + } + } + + console.log(`\n${passed} passed, ${failed} failed`); + + if (failed === 0) { + console.log('All eventEngine tests PASS'); + process.exit(0); + } else { + process.exit(1); + } +} + +runTests();