feat: add event engine with age trigger, random pool, choice filtering

This commit is contained in:
2026-05-13 03:43:07 +00:00
parent 86bd7ba376
commit 6373ee1bac
3 changed files with 648 additions and 0 deletions
+222
View File
@@ -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": "你感到无力回天,只能接受命运的安排。"
}
]
}
]
}
]
}
+71
View File
@@ -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);
}
+355
View File
@@ -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();