feat: add age-based event gating, event timer, and UI polish
- Add minAge/maxAge to events so infants can't go treasure hunting - Cache event panel DOM to prevent high-speed button destruction - Add 10s auto-select countdown for choice events - Fix event title/text field mapping (name/description → title/text) - Add rotating clock icon for time flow feedback - Fix speed/pause button active states - Fix shop affordability check (disable + show insufficient money) - Add red styling for unmet choice requirements - Fix log re-rendering on every tick
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
// ==================== 浏览器端到端测试 ====================
|
||||
|
||||
import puppeteer from 'puppeteer-core';
|
||||
import http from 'http';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// 简单HTTP服务器
|
||||
function startServer(port = 8765) {
|
||||
const mimeTypes = {
|
||||
'.html': 'text/html',
|
||||
'.js': 'application/javascript',
|
||||
'.json': 'application/json',
|
||||
'.css': 'text/css',
|
||||
};
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
let filePath = '.' + (req.url === '/' ? '/index.html' : req.url);
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const contentType = mimeTypes[ext] || 'application/octet-stream';
|
||||
|
||||
fs.readFile(filePath, (err, content) => {
|
||||
if (err) {
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': contentType });
|
||||
res.end(content);
|
||||
});
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
server.listen(port, () => {
|
||||
console.log(`Server running at http://localhost:${port}`);
|
||||
resolve(server);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function runBrowserTest() {
|
||||
const server = await startServer(8765);
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
executablePath: '/usr/bin/chromium-browser',
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
||||
});
|
||||
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
const logs = [];
|
||||
|
||||
page.on('pageerror', (err) => {
|
||||
console.error('Page error:', err.message);
|
||||
errors.push(err.message);
|
||||
});
|
||||
|
||||
page.on('console', (msg) => {
|
||||
const text = msg.text();
|
||||
logs.push(text);
|
||||
if (msg.type() === 'error') {
|
||||
console.error('Console error:', text);
|
||||
errors.push(text);
|
||||
}
|
||||
});
|
||||
|
||||
page.on('response', (response) => {
|
||||
if (response.status() === 404) {
|
||||
console.error('404 Not Found:', response.url());
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
console.log('\n=== 浏览器端到端测试 ===');
|
||||
|
||||
// 加载页面
|
||||
console.log('1. 加载页面...');
|
||||
await page.goto('http://localhost:8765', { waitUntil: 'networkidle0', timeout: 10000 });
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
|
||||
const realErrors = errors.filter(e => !e.includes('favicon.ico') && !e.includes('404'));
|
||||
if (realErrors.length > 0) {
|
||||
throw new Error(`页面加载错误: ${realErrors.join(', ')}`);
|
||||
}
|
||||
console.log(' 页面加载成功,无JS错误');
|
||||
|
||||
// 检查初始状态
|
||||
console.log('2. 检查初始状态...');
|
||||
const age = await page.$eval('#age', el => el.textContent);
|
||||
const money = await page.$eval('#money', el => el.textContent);
|
||||
console.log(` 年龄: ${age}, 银两: ${money}`);
|
||||
|
||||
// 等待一段时间让游戏运行
|
||||
console.log('3. 等待游戏运行5秒...');
|
||||
await new Promise(r => setTimeout(r, 5000));
|
||||
|
||||
// 检查时间是否推进
|
||||
console.log('4. 检查时间推进...');
|
||||
const dayAfter = await page.$eval('#day', el => el.textContent);
|
||||
const yearAfter = await page.$eval('#year', el => el.textContent);
|
||||
console.log(` 年: ${yearAfter}, 天: ${dayAfter}`);
|
||||
|
||||
if (parseInt(dayAfter) <= 0) {
|
||||
throw new Error('时间没有推进');
|
||||
}
|
||||
console.log(' 时间正常推进');
|
||||
|
||||
// 检查职业是否解锁
|
||||
console.log('5. 检查职业面板...');
|
||||
const careerText = await page.$eval('#career-list', el => el.textContent);
|
||||
console.log(` 职业面板: ${careerText.substring(0, 50)}...`);
|
||||
|
||||
// 检查属性面板(6个属性)
|
||||
console.log('6. 检查6属性面板...');
|
||||
const body = await page.$eval('#stat-body', el => el.textContent);
|
||||
const destiny = await page.$eval('#stat-destiny', el => el.textContent);
|
||||
const business = await page.$eval('#stat-business', el => el.textContent);
|
||||
const intelligence = await page.$eval('#stat-intelligence', el => el.textContent);
|
||||
console.log(` 体质: ${body}, 天命: ${destiny}, 经商: ${business}, 智力: ${intelligence}`);
|
||||
|
||||
// 检查商铺面板
|
||||
console.log('7. 检查商铺面板...');
|
||||
const shopExists = await page.$('#shop-panel') !== null;
|
||||
console.log(` 商铺面板存在: ${shopExists}`);
|
||||
|
||||
// 检查神器面板
|
||||
console.log('8. 检查神器面板...');
|
||||
const artifactExists = await page.$('#artifact-list') !== null;
|
||||
console.log(` 神器面板存在: ${artifactExists}`);
|
||||
|
||||
// 点击暂停
|
||||
console.log('9. 测试暂停按钮...');
|
||||
await page.click('#pause-btn');
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
const dayPaused = await page.$eval('#day', el => el.textContent);
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
const dayAfterPause = await page.$eval('#day', el => el.textContent);
|
||||
console.log(` 暂停前天: ${dayPaused}, 暂停后1秒: ${dayAfterPause}`);
|
||||
|
||||
// 点击速度按钮
|
||||
console.log('10. 测试速度切换...');
|
||||
await page.click('[data-speed="1"]');
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
console.log(' 速度切换成功');
|
||||
|
||||
console.log('\n=== 浏览器测试全部通过 ===');
|
||||
|
||||
} catch (e) {
|
||||
console.error('\n=== 浏览器测试失败 ===');
|
||||
console.error(e.message);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await browser.close();
|
||||
server.close();
|
||||
}
|
||||
}
|
||||
|
||||
runBrowserTest();
|
||||
@@ -0,0 +1,235 @@
|
||||
// ==================== 全流程生命周期测试 ====================
|
||||
|
||||
import { createInitialState, resetForRebirth } from '../src/engine/state.js';
|
||||
import { setCareerConfig } from '../src/engine/careerEngine.js';
|
||||
import { setEventConfig } from '../src/engine/eventEngine.js';
|
||||
import { setShopConfig } from '../src/engine/shopEngine.js';
|
||||
import { tick } from '../src/engine/tickLoop.js';
|
||||
import { applyEffect } from '../src/engine/effectApplier.js';
|
||||
import { processDeath } from '../src/engine/deathEngine.js';
|
||||
import { evaluateCondition } from '../src/engine/conditionEvaluator.js';
|
||||
import fs from 'fs';
|
||||
|
||||
function assert(cond, msg) { if (!cond) throw new Error(msg); }
|
||||
|
||||
// 加载配置
|
||||
const careers = JSON.parse(fs.readFileSync('./src/config/careers.json', 'utf8'));
|
||||
const events = JSON.parse(fs.readFileSync('./src/config/events.json', 'utf8'));
|
||||
const shop = JSON.parse(fs.readFileSync('./src/config/shop.json', 'utf8'));
|
||||
const identities = JSON.parse(fs.readFileSync('./src/config/identities.json', 'utf8'));
|
||||
const talents = JSON.parse(fs.readFileSync('./src/config/talents.json', 'utf8'));
|
||||
|
||||
setCareerConfig(careers);
|
||||
setEventConfig(events);
|
||||
setShopConfig(shop);
|
||||
|
||||
function log(msg) { console.log(` ${msg}`); }
|
||||
|
||||
// 辅助函数:自动处理选择事件(测试中不暂停)
|
||||
function autoResolveChoice(state) {
|
||||
if (state.paused && state.currentEvent && state.currentEvent.choices) {
|
||||
// 找到第一个满足条件的选择,否则选最后一个
|
||||
let choice = state.currentEvent.choices.find(c => !c.requirements || evaluateCondition(c.requirements, state));
|
||||
if (!choice) choice = state.currentEvent.choices[state.currentEvent.choices.length - 1];
|
||||
if (choice && choice.effects) {
|
||||
for (const effect of choice.effects) applyEffect(effect, state);
|
||||
}
|
||||
state.paused = false;
|
||||
state.currentEvent = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ====== 测试1:第1世基础流程 ======
|
||||
console.log('\n=== Test 1: 第1世基础流程 ===');
|
||||
{
|
||||
const state = createInitialState();
|
||||
state.identity = 'peasant';
|
||||
state.stats = { body: 4, wisdom: 3, charm: 3, destiny: 3, business: 1, intelligence: 2 };
|
||||
|
||||
// 运行到10岁
|
||||
for (let i = 0; i < 365 * 10; i++) {
|
||||
tick(state, 1, careers, events, shop);
|
||||
autoResolveChoice(state);
|
||||
if (!state.alive) break;
|
||||
}
|
||||
|
||||
log(`年龄: ${state.age}岁`);
|
||||
log(`银两: ${Math.floor(state.money)}`);
|
||||
log(`职业: ${Object.keys(state.careers).join(', ') || '无'}`);
|
||||
|
||||
assert(state.age >= 9, '应该活到接近10岁');
|
||||
assert(Object.keys(state.careers).includes('student'), '应该解锁学童');
|
||||
assert(state.money > 0, '应该有银两收入');
|
||||
log('第1世基础流程 PASS');
|
||||
}
|
||||
|
||||
// ====== 测试2:职业解锁链 ======
|
||||
console.log('\n=== Test 2: 职业解锁链 ===');
|
||||
{
|
||||
const state = createInitialState();
|
||||
state.age = 10;
|
||||
state.stats = { body: 20, wisdom: 30, charm: 10, destiny: 5, business: 5, intelligence: 10 };
|
||||
|
||||
// 设置学童100级
|
||||
state.careers.student = { level: 100, exp: 0 };
|
||||
|
||||
// tick一次检查解锁
|
||||
tick(state, 1, careers, events, shop);
|
||||
autoResolveChoice(state);
|
||||
|
||||
log(`解锁职业: ${Object.keys(state.careers).join(', ')}`);
|
||||
assert(Object.keys(state.careers).includes('book_boy'), '学童100级应解锁书童');
|
||||
|
||||
// 设置学童250级 + 14岁
|
||||
state.careers.student.level = 250;
|
||||
state.age = 14;
|
||||
|
||||
tick(state, 1, careers, events, shop);
|
||||
autoResolveChoice(state);
|
||||
log(`解锁职业: ${Object.keys(state.careers).join(', ')}`);
|
||||
assert(Object.keys(state.careers).includes('scholar'), '学童250级+14岁应解锁书生');
|
||||
|
||||
// 士兵解锁
|
||||
state.stats.body = 15;
|
||||
tick(state, 1, careers, events, shop);
|
||||
autoResolveChoice(state);
|
||||
assert(Object.keys(state.careers).includes('soldier'), '14岁+体质15应解锁士兵');
|
||||
|
||||
log('职业解锁链 PASS');
|
||||
}
|
||||
|
||||
// ====== 测试3:银两系统 ======
|
||||
console.log('\n=== Test 3: 银两系统 ===');
|
||||
{
|
||||
const state = createInitialState();
|
||||
state.age = 10;
|
||||
state.stats = { body: 10, wisdom: 10, charm: 10, destiny: 10, business: 50, intelligence: 10 };
|
||||
state.careers.student = { level: 0, exp: 0 };
|
||||
|
||||
tick(state, 1, careers, events, shop);
|
||||
autoResolveChoice(state);
|
||||
const income = state.money;
|
||||
log(`经商50时每日收入: ${income.toFixed(2)}`);
|
||||
assert(income > 1, '经商50应增加收入(1.5倍)');
|
||||
|
||||
log('银两系统 PASS');
|
||||
}
|
||||
|
||||
// ====== 测试4:经验系统 + 升级 ======
|
||||
console.log('\n=== Test 4: 经验系统 + 升级 ===');
|
||||
{
|
||||
const state = createInitialState();
|
||||
state.age = 10;
|
||||
state.stats = { body: 10, wisdom: 50, charm: 10, destiny: 10, business: 10, intelligence: 10 };
|
||||
state.careers.student = { level: 0, exp: 0 };
|
||||
|
||||
// 运行一段时间
|
||||
for (let i = 0; i < 100; i++) {
|
||||
tick(state, 1, careers, events, shop);
|
||||
autoResolveChoice(state);
|
||||
}
|
||||
|
||||
log(`学童等级: ${state.careers.student.level}`);
|
||||
log(`学童经验: ${Math.floor(state.careers.student.exp)}`);
|
||||
assert(state.careers.student.level > 0, '应该升级了');
|
||||
|
||||
log('经验系统 PASS');
|
||||
}
|
||||
|
||||
// ====== 测试5:入侵事件触发 ======
|
||||
console.log('\n=== Test 5: 入侵事件触发 ===');
|
||||
{
|
||||
const state = createInitialState();
|
||||
state.age = 39;
|
||||
state.stats = { body: 50, wisdom: 50, charm: 50, destiny: 50, business: 50, intelligence: 50 };
|
||||
state.careers.soldier = { level: 100, exp: 0 };
|
||||
|
||||
// 在40岁生日时触发入侵
|
||||
state.day = 364;
|
||||
state.year = 39;
|
||||
|
||||
tick(state, 1, careers, events, shop);
|
||||
|
||||
log(`年龄: ${state.age}`);
|
||||
log(`暂停状态: ${state.paused}`);
|
||||
log(`当前事件: ${state.currentEvent?.id || '无'}`);
|
||||
|
||||
assert(state.age === 40, '应该刚满40岁');
|
||||
assert(state.paused === true, '选择事件应暂停游戏');
|
||||
assert(state.currentEvent?.id === 'invasion_military_40', '应触发军事入侵事件');
|
||||
|
||||
log('入侵事件触发 PASS');
|
||||
}
|
||||
|
||||
// ====== 测试6:跨轮继承 ======
|
||||
console.log('\n=== Test 6: 跨轮继承 ===');
|
||||
{
|
||||
const state = createInitialState();
|
||||
state.identity = 'peasant';
|
||||
state.stats = { body: 4, wisdom: 3, charm: 3, destiny: 3, business: 1, intelligence: 2 };
|
||||
|
||||
// 模拟一轮游戏,获得一些积累
|
||||
state.metaExp = { student: 500 };
|
||||
state.artifacts = { immortal_sword: 3 };
|
||||
state.memories = ['wisdom_boost'];
|
||||
state.unlockedEntries = new Set(['sword_bone']);
|
||||
state.reincarnation = 0;
|
||||
|
||||
// 运行30年,然后手动触发死亡(测试重点是跨轮继承,不是等自然死亡)
|
||||
for (let i = 0; i < 365 * 30; i++) {
|
||||
tick(state, 1, careers, events, shop);
|
||||
autoResolveChoice(state);
|
||||
if (!state.alive) break;
|
||||
}
|
||||
|
||||
// 手动死亡以测试结算
|
||||
applyEffect({ type: 'die', reason: '测试死亡' }, state);
|
||||
assert(state.alive === false, '应该已死亡');
|
||||
|
||||
// 死亡结算
|
||||
processDeath(state, careers, talents);
|
||||
|
||||
log(`死亡前元经验: ${JSON.stringify(state.metaExp)}`);
|
||||
|
||||
// 重生
|
||||
resetForRebirth(state);
|
||||
|
||||
log(`重生后轮次: ${state.reincarnation}`);
|
||||
log(`重生后神器: ${JSON.stringify(state.artifacts)}`);
|
||||
log(`重生后记忆: ${JSON.stringify(state.memories)}`);
|
||||
log(`重生后元经验: ${JSON.stringify(state.metaExp)}`);
|
||||
log(`重生后银两: ${state.money}`);
|
||||
log(`重生后职业: ${JSON.stringify(state.careers)}`);
|
||||
|
||||
assert(state.reincarnation === 1, '轮次应为1');
|
||||
assert(state.artifacts.immortal_sword === 3, '神器应保留');
|
||||
assert(state.metaExp.student > 500, '元经验应增加');
|
||||
assert(state.money === 0, '银两应重置');
|
||||
assert(Object.keys(state.careers).length === 0, '职业应重置');
|
||||
|
||||
log('跨轮继承 PASS');
|
||||
}
|
||||
|
||||
// ====== 测试7:商铺购买 ======
|
||||
console.log('\n=== Test 7: 商铺购买 ======');
|
||||
{
|
||||
const state = createInitialState();
|
||||
state.money = 1000;
|
||||
state.age = 15;
|
||||
state.stats = { body: 10, wisdom: 10, charm: 10, destiny: 10, business: 10, intelligence: 10 };
|
||||
|
||||
import('../src/engine/shopEngine.js').then(({ buyItem, canBuyItem }) => {
|
||||
// 购买大力丸
|
||||
const canBuy = canBuyItem(shop, state, 'power_pill');
|
||||
assert(canBuy === true, '应该能买大力丸');
|
||||
|
||||
const result = buyItem(shop, state, 'power_pill');
|
||||
assert(result === true, '购买应成功');
|
||||
assert(state.stats.body === 15, '体质应+5');
|
||||
assert(state.money === 950, '银两应-50');
|
||||
|
||||
log('商铺购买 PASS');
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== 所有全流程测试通过 ===\n');
|
||||
Reference in New Issue
Block a user