feat: add shop engine with 3-tab support and artifact upgrades

This commit is contained in:
2026-05-13 03:39:51 +00:00
parent cbed651937
commit 86bd7ba376
3 changed files with 484 additions and 0 deletions
+63
View File
@@ -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
}
]
}
+135
View File
@@ -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);
}
}
}
+286
View File
@@ -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();