feat: add shop engine with 3-tab support and artifact upgrades
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "taoist_license",
|
||||
"name": "道碟",
|
||||
"tab": "item",
|
||||
"cost": 500,
|
||||
"unlockConditions": [
|
||||
{ "type": "age", "op": ">=", "value": 10 },
|
||||
{ "type": "or", "conditions": [
|
||||
{ "type": "identity", "value": "taoist_orphan" },
|
||||
{ "type": "worldFlag", "flag": "met_taoist_priest", "value": true }
|
||||
]}
|
||||
],
|
||||
"effects": [
|
||||
{ "type": "unlockCareer", "careerId": "taoist_priest" },
|
||||
{ "type": "setFlag", "flag": "has_taoist_license", "value": true }
|
||||
],
|
||||
"resetOnRebirth": true
|
||||
},
|
||||
{
|
||||
"id": "power_pill",
|
||||
"name": "大力丸",
|
||||
"tab": "item",
|
||||
"cost": 50,
|
||||
"unlockConditions": [],
|
||||
"effects": [
|
||||
{ "type": "addStat", "stat": "body", "value": 5 }
|
||||
],
|
||||
"resetOnRebirth": true
|
||||
}
|
||||
],
|
||||
"buffs": [
|
||||
{
|
||||
"id": "clever_brush",
|
||||
"name": "妙笔生花",
|
||||
"tab": "buff",
|
||||
"cost": 200,
|
||||
"unlockConditions": [],
|
||||
"effects": [
|
||||
{ "type": "addStat", "stat": "wisdom", "value": 10 }
|
||||
],
|
||||
"resetOnRebirth": true
|
||||
}
|
||||
],
|
||||
"artifacts": [
|
||||
{
|
||||
"id": "immortal_sword",
|
||||
"name": "仙剑",
|
||||
"tab": "artifact",
|
||||
"baseCost": 1000,
|
||||
"costGrowth": 1.5,
|
||||
"unlockConditions": [
|
||||
{ "type": "careerLevel", "careerId": "taoist_priest", "op": ">=", "value": 50 }
|
||||
],
|
||||
"effects": [
|
||||
{ "type": "addStat", "stat": "body", "value": 10 }
|
||||
],
|
||||
"resetOnRebirth": false,
|
||||
"maxLevel": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { evaluateCondition } from './conditionEvaluator.js';
|
||||
import { applyEffect } from './effectApplier.js';
|
||||
import { canAfford, spendMoney } from './moneySystem.js';
|
||||
|
||||
let _shopConfig = null;
|
||||
|
||||
export function setShopConfig(config) {
|
||||
_shopConfig = config;
|
||||
}
|
||||
|
||||
export function getShopConfig() {
|
||||
return _shopConfig;
|
||||
}
|
||||
|
||||
export function findEntry(config, id) {
|
||||
if (!config || !id) return null;
|
||||
|
||||
const sections = ['items', 'buffs', 'artifacts'];
|
||||
for (const section of sections) {
|
||||
const list = config[section];
|
||||
if (!Array.isArray(list)) continue;
|
||||
const entry = list.find(e => e.id === id);
|
||||
if (entry) {
|
||||
return { ...entry, section };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getArtifactUpgradeCost(config, artifactId, currentLevel) {
|
||||
if (!config || !config.artifacts || !artifactId) return null;
|
||||
const entry = config.artifacts.find(a => a.id === artifactId);
|
||||
if (!entry) return null;
|
||||
return Math.floor(entry.baseCost * Math.pow(entry.costGrowth, currentLevel));
|
||||
}
|
||||
|
||||
export function canBuyItem(config, state, itemId) {
|
||||
const entry = findEntry(config, itemId);
|
||||
if (!entry) return false;
|
||||
|
||||
// Check unlock conditions
|
||||
if (Array.isArray(entry.unlockConditions) && entry.unlockConditions.length > 0) {
|
||||
const allMet = entry.unlockConditions.every(cond => evaluateCondition(cond, state));
|
||||
if (!allMet) return false;
|
||||
}
|
||||
|
||||
// Check max level for artifacts
|
||||
if (entry.section === 'artifacts') {
|
||||
const currentLevel = state.artifacts?.[itemId] || 0;
|
||||
if (entry.maxLevel !== undefined && currentLevel >= entry.maxLevel) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check cost
|
||||
let cost;
|
||||
if (entry.section === 'artifacts') {
|
||||
const currentLevel = state.artifacts?.[itemId] || 0;
|
||||
cost = getArtifactUpgradeCost(config, itemId, currentLevel);
|
||||
} else {
|
||||
cost = entry.cost;
|
||||
}
|
||||
|
||||
if (cost === null || cost === undefined) return false;
|
||||
return canAfford(state, cost);
|
||||
}
|
||||
|
||||
export function buyItem(config, state, itemId) {
|
||||
if (!canBuyItem(config, state, itemId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const entry = findEntry(config, itemId);
|
||||
if (!entry) return false;
|
||||
|
||||
// Calculate cost
|
||||
let cost;
|
||||
if (entry.section === 'artifacts') {
|
||||
const currentLevel = state.artifacts?.[itemId] || 0;
|
||||
cost = getArtifactUpgradeCost(config, itemId, currentLevel);
|
||||
} else {
|
||||
cost = entry.cost;
|
||||
}
|
||||
|
||||
// Spend money
|
||||
if (!spendMoney(state, cost)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Apply effects
|
||||
if (Array.isArray(entry.effects)) {
|
||||
for (const effect of entry.effects) {
|
||||
applyEffect(effect, state);
|
||||
}
|
||||
}
|
||||
|
||||
// Record purchase
|
||||
if (entry.section === 'items') {
|
||||
if (!state.shopItems) state.shopItems = {};
|
||||
state.shopItems[itemId] = (state.shopItems[itemId] || 0) + 1;
|
||||
} else if (entry.section === 'buffs') {
|
||||
if (!state.activeBuffs) state.activeBuffs = {};
|
||||
state.activeBuffs[itemId] = { expiresDay: -1 };
|
||||
} else if (entry.section === 'artifacts') {
|
||||
if (!state.artifacts) state.artifacts = {};
|
||||
state.artifacts[itemId] = (state.artifacts[itemId] || 0) + 1;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function resetShopItems(state) {
|
||||
state.shopItems = {};
|
||||
state.activeBuffs = {};
|
||||
// Artifacts are preserved
|
||||
}
|
||||
|
||||
export function applyArtifactEffects(config, state) {
|
||||
if (!config || !state.artifacts) return;
|
||||
|
||||
for (const [artifactId, level] of Object.entries(state.artifacts)) {
|
||||
if (level <= 0) continue;
|
||||
|
||||
const entry = config.artifacts?.find(a => a.id === artifactId);
|
||||
if (!entry || !Array.isArray(entry.effects)) continue;
|
||||
|
||||
for (const effect of entry.effects) {
|
||||
const scaledEffect = {
|
||||
...effect,
|
||||
value: effect.value * level
|
||||
};
|
||||
applyEffect(scaledEffect, state);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import {
|
||||
setShopConfig,
|
||||
getShopConfig,
|
||||
findEntry,
|
||||
canBuyItem,
|
||||
buyItem,
|
||||
getArtifactUpgradeCost,
|
||||
resetShopItems,
|
||||
applyArtifactEffects,
|
||||
} from '../src/engine/shopEngine.js';
|
||||
|
||||
// Load shop config
|
||||
import shopConfig from '../src/config/shop.json' assert { type: 'json' };
|
||||
|
||||
function createState(overrides = {}) {
|
||||
return {
|
||||
money: 0,
|
||||
age: 0,
|
||||
stats: {},
|
||||
careers: {},
|
||||
worldFlags: {},
|
||||
shopItems: {},
|
||||
activeBuffs: {},
|
||||
artifacts: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function assertEqual(actual, expected, message) {
|
||||
if (actual !== expected) {
|
||||
console.error(`FAIL: ${message}`);
|
||||
console.error(` Expected: ${expected}`);
|
||||
console.error(` Actual: ${actual}`);
|
||||
throw new Error(`Assertion failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertTrue(value, message) {
|
||||
if (!value) {
|
||||
console.error(`FAIL: ${message}`);
|
||||
throw new Error(`Assertion failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertDeepEqual(actual, expected, message) {
|
||||
const actualStr = JSON.stringify(actual);
|
||||
const expectedStr = JSON.stringify(expected);
|
||||
if (actualStr !== expectedStr) {
|
||||
console.error(`FAIL: ${message}`);
|
||||
console.error(` Expected: ${expectedStr}`);
|
||||
console.error(` Actual: ${actualStr}`);
|
||||
throw new Error(`Assertion failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function testCanBuyItem() {
|
||||
console.log('Running testCanBuyItem...');
|
||||
|
||||
// Test 1: Basic item - enough money, no unlock conditions
|
||||
let state = createState({ money: 100 });
|
||||
assertTrue(canBuyItem(shopConfig, state, 'power_pill'), 'Should be able to buy power_pill with enough money');
|
||||
|
||||
// Test 2: Basic item - not enough money
|
||||
state = createState({ money: 10 });
|
||||
assertEqual(canBuyItem(shopConfig, state, 'power_pill'), false, 'Should not afford power_pill with insufficient money');
|
||||
|
||||
// Test 3: Item with unlock conditions - age not met
|
||||
state = createState({ money: 1000, age: 5, identity: 'taoist_orphan' });
|
||||
assertEqual(canBuyItem(shopConfig, state, 'taoist_license'), false, 'Should not buy taoist_license if age < 10');
|
||||
|
||||
// Test 4: Item with unlock conditions - age met, identity matches
|
||||
state = createState({ money: 1000, age: 15, identity: 'taoist_orphan' });
|
||||
assertTrue(canBuyItem(shopConfig, state, 'taoist_license'), 'Should buy taoist_license with age >= 10 and correct identity');
|
||||
|
||||
// Test 5: Item with unlock conditions - age met, worldFlag matches
|
||||
state = createState({ money: 1000, age: 15, worldFlags: { met_taoist_priest: true } });
|
||||
assertTrue(canBuyItem(shopConfig, state, 'taoist_license'), 'Should buy taoist_license with age >= 10 and worldFlag');
|
||||
|
||||
// Test 6: Item with unlock conditions - age met, neither condition matches
|
||||
state = createState({ money: 1000, age: 15, identity: 'commoner' });
|
||||
assertEqual(canBuyItem(shopConfig, state, 'taoist_license'), false, 'Should not buy taoist_license without matching identity or flag');
|
||||
|
||||
// Test 7: Buff - enough money
|
||||
state = createState({ money: 300 });
|
||||
assertTrue(canBuyItem(shopConfig, state, 'clever_brush'), 'Should be able to buy clever_brush with enough money');
|
||||
|
||||
// Test 8: Artifact - unlock condition not met
|
||||
state = createState({ money: 2000 });
|
||||
assertEqual(canBuyItem(shopConfig, state, 'immortal_sword'), false, 'Should not buy immortal_sword without career level');
|
||||
|
||||
// Test 9: Artifact - unlock condition met
|
||||
state = createState({ money: 2000, careers: { taoist_priest: { level: 50, exp: 0 } } });
|
||||
assertTrue(canBuyItem(shopConfig, state, 'immortal_sword'), 'Should buy immortal_sword with sufficient career level');
|
||||
|
||||
// Test 10: Artifact - max level reached
|
||||
state = createState({ money: 100000, careers: { taoist_priest: { level: 50, exp: 0 } }, artifacts: { immortal_sword: 10 } });
|
||||
assertEqual(canBuyItem(shopConfig, state, 'immortal_sword'), false, 'Should not buy artifact at max level');
|
||||
|
||||
console.log(' testCanBuyItem PASSED');
|
||||
}
|
||||
|
||||
function testBuyItem() {
|
||||
console.log('Running testBuyItem...');
|
||||
|
||||
// Test 1: Buy basic item
|
||||
let state = createState({ money: 100 });
|
||||
const result1 = buyItem(shopConfig, state, 'power_pill');
|
||||
assertTrue(result1, 'buyItem should return true for successful purchase');
|
||||
assertEqual(state.money, 50, 'Should deduct cost from money');
|
||||
assertEqual(state.shopItems.power_pill, 1, 'Should record item purchase');
|
||||
assertEqual(state.stats.body, 5, 'Should apply effect (addStat body +5)');
|
||||
|
||||
// Test 2: Buy item that fails canBuyItem
|
||||
state = createState({ money: 10 });
|
||||
const result2 = buyItem(shopConfig, state, 'power_pill');
|
||||
assertEqual(result2, false, 'buyItem should return false when cannot afford');
|
||||
assertEqual(state.money, 10, 'Should not deduct money when purchase fails');
|
||||
|
||||
// Test 3: Buy buff
|
||||
state = createState({ money: 300 });
|
||||
const result3 = buyItem(shopConfig, state, 'clever_brush');
|
||||
assertTrue(result3, 'buyItem should return true for buff purchase');
|
||||
assertEqual(state.money, 100, 'Should deduct buff cost');
|
||||
assertDeepEqual(state.activeBuffs.clever_brush, { expiresDay: -1 }, 'Should record buff with expiresDay: -1');
|
||||
assertEqual(state.stats.wisdom, 10, 'Should apply buff effect (addStat wisdom +10)');
|
||||
|
||||
// Test 4: Buy artifact
|
||||
state = createState({ money: 2000, careers: { taoist_priest: { level: 50, exp: 0 } } });
|
||||
const result4 = buyItem(shopConfig, state, 'immortal_sword');
|
||||
assertTrue(result4, 'buyItem should return true for artifact purchase');
|
||||
assertEqual(state.money, 1000, 'Should deduct artifact cost (baseCost=1000)');
|
||||
assertEqual(state.artifacts.immortal_sword, 1, 'Should record artifact level');
|
||||
assertEqual(state.stats.body, 10, 'Should apply artifact effect (addStat body +10)');
|
||||
|
||||
// Test 5: Buy item with multiple effects
|
||||
state = createState({ money: 1000, age: 15, identity: 'taoist_orphan' });
|
||||
const result5 = buyItem(shopConfig, state, 'taoist_license');
|
||||
assertTrue(result5, 'buyItem should return true for taoist_license');
|
||||
assertEqual(state.money, 500, 'Should deduct taoist_license cost');
|
||||
assertEqual(state.shopItems.taoist_license, 1, 'Should record taoist_license purchase');
|
||||
assertTrue(state.careers.taoist_priest !== undefined, 'Should unlock career');
|
||||
assertEqual(state.worldFlags.has_taoist_license, true, 'Should set world flag');
|
||||
|
||||
console.log(' testBuyItem PASSED');
|
||||
}
|
||||
|
||||
function testGetArtifactUpgradeCost() {
|
||||
console.log('Running testGetArtifactUpgradeCost...');
|
||||
|
||||
// Level 0: baseCost * 1.5^0 = 1000 * 1 = 1000
|
||||
const cost0 = getArtifactUpgradeCost(shopConfig, 'immortal_sword', 0);
|
||||
assertEqual(cost0, 1000, 'Level 0 cost should be baseCost');
|
||||
|
||||
// Level 1: baseCost * 1.5^1 = 1000 * 1.5 = 1500
|
||||
const cost1 = getArtifactUpgradeCost(shopConfig, 'immortal_sword', 1);
|
||||
assertEqual(cost1, 1500, 'Level 1 cost should be baseCost * 1.5');
|
||||
|
||||
// Level 2: baseCost * 1.5^2 = 1000 * 2.25 = 2250
|
||||
const cost2 = getArtifactUpgradeCost(shopConfig, 'immortal_sword', 2);
|
||||
assertEqual(cost2, 2250, 'Level 2 cost should be baseCost * 1.5^2');
|
||||
|
||||
// Level 3: baseCost * 1.5^3 = 1000 * 3.375 = 3375
|
||||
const cost3 = getArtifactUpgradeCost(shopConfig, 'immortal_sword', 3);
|
||||
assertEqual(cost3, 3375, 'Level 3 cost should be baseCost * 1.5^3');
|
||||
|
||||
// Verify Math.floor is applied
|
||||
const cost5 = getArtifactUpgradeCost(shopConfig, 'immortal_sword', 5);
|
||||
const expected5 = Math.floor(1000 * Math.pow(1.5, 5));
|
||||
assertEqual(cost5, expected5, 'Level 5 cost should use Math.floor');
|
||||
|
||||
console.log(' testGetArtifactUpgradeCost PASSED');
|
||||
}
|
||||
|
||||
function testResetShopItems() {
|
||||
console.log('Running testResetShopItems...');
|
||||
|
||||
let state = createState({
|
||||
shopItems: { power_pill: 3, taoist_license: 1 },
|
||||
activeBuffs: { clever_brush: { expiresDay: -1 } },
|
||||
artifacts: { immortal_sword: 5 },
|
||||
});
|
||||
|
||||
resetShopItems(state);
|
||||
|
||||
assertDeepEqual(state.shopItems, {}, 'shopItems should be reset to empty object');
|
||||
assertDeepEqual(state.activeBuffs, {}, 'activeBuffs should be reset to empty object');
|
||||
assertEqual(state.artifacts.immortal_sword, 5, 'artifacts should be preserved');
|
||||
|
||||
console.log(' testResetShopItems PASSED');
|
||||
}
|
||||
|
||||
function testApplyArtifactEffects() {
|
||||
console.log('Running testApplyArtifactEffects...');
|
||||
|
||||
// Test 1: Single artifact, level 1
|
||||
let state = createState({ artifacts: { immortal_sword: 1 } });
|
||||
applyArtifactEffects(shopConfig, state);
|
||||
assertEqual(state.stats.body, 10, 'Level 1 artifact should add 10 body');
|
||||
|
||||
// Test 2: Single artifact, level 3
|
||||
state = createState({ artifacts: { immortal_sword: 3 } });
|
||||
applyArtifactEffects(shopConfig, state);
|
||||
assertEqual(state.stats.body, 30, 'Level 3 artifact should add 30 body (10 * 3)');
|
||||
|
||||
// Test 3: Multiple artifacts (simulate by adding another artifact config)
|
||||
const customConfig = {
|
||||
artifacts: [
|
||||
{
|
||||
id: 'immortal_sword',
|
||||
effects: [{ type: 'addStat', stat: 'body', value: 10 }],
|
||||
},
|
||||
{
|
||||
id: 'wisdom_scroll',
|
||||
effects: [{ type: 'addStat', stat: 'wisdom', value: 5 }],
|
||||
},
|
||||
],
|
||||
};
|
||||
state = createState({ artifacts: { immortal_sword: 2, wisdom_scroll: 4 } });
|
||||
applyArtifactEffects(customConfig, state);
|
||||
assertEqual(state.stats.body, 20, 'Multiple artifacts: immortal_sword level 2 should add 20 body');
|
||||
assertEqual(state.stats.wisdom, 20, 'Multiple artifacts: wisdom_scroll level 4 should add 20 wisdom (5 * 4)');
|
||||
|
||||
// Test 4: Artifact level 0 should not apply
|
||||
state = createState({ artifacts: { immortal_sword: 0 } });
|
||||
applyArtifactEffects(shopConfig, state);
|
||||
assertEqual(state.stats.body, undefined, 'Level 0 artifact should not apply effects');
|
||||
|
||||
console.log(' testApplyArtifactEffects PASSED');
|
||||
}
|
||||
|
||||
function testSetAndGetShopConfig() {
|
||||
console.log('Running testSetAndGetShopConfig...');
|
||||
|
||||
const customConfig = { items: [], buffs: [], artifacts: [] };
|
||||
setShopConfig(customConfig);
|
||||
assertEqual(getShopConfig(), customConfig, 'getShopConfig should return set config');
|
||||
|
||||
// Reset to shopConfig for other tests
|
||||
setShopConfig(shopConfig);
|
||||
|
||||
console.log(' testSetAndGetShopConfig PASSED');
|
||||
}
|
||||
|
||||
function testFindEntry() {
|
||||
console.log('Running testFindEntry...');
|
||||
|
||||
const item = findEntry(shopConfig, 'power_pill');
|
||||
assertTrue(item !== null, 'findEntry should find existing item');
|
||||
assertEqual(item.id, 'power_pill', 'findEntry should return correct item id');
|
||||
assertEqual(item.section, 'items', 'findEntry should return correct section for item');
|
||||
|
||||
const buff = findEntry(shopConfig, 'clever_brush');
|
||||
assertTrue(buff !== null, 'findEntry should find existing buff');
|
||||
assertEqual(buff.section, 'buffs', 'findEntry should return correct section for buff');
|
||||
|
||||
const artifact = findEntry(shopConfig, 'immortal_sword');
|
||||
assertTrue(artifact !== null, 'findEntry should find existing artifact');
|
||||
assertEqual(artifact.section, 'artifacts', 'findEntry should return correct section for artifact');
|
||||
|
||||
const notFound = findEntry(shopConfig, 'nonexistent');
|
||||
assertEqual(notFound, null, 'findEntry should return null for nonexistent id');
|
||||
|
||||
console.log(' testFindEntry PASSED');
|
||||
}
|
||||
|
||||
function runAllTests() {
|
||||
console.log('=== shopEngine Tests ===\n');
|
||||
|
||||
try {
|
||||
testSetAndGetShopConfig();
|
||||
testFindEntry();
|
||||
testCanBuyItem();
|
||||
testBuyItem();
|
||||
testGetArtifactUpgradeCost();
|
||||
testResetShopItems();
|
||||
testApplyArtifactEffects();
|
||||
|
||||
console.log('\nAll shopEngine tests PASS');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error(`\nTests FAILED: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
runAllTests();
|
||||
Reference in New Issue
Block a user