diff --git a/src/config/shop.json b/src/config/shop.json new file mode 100644 index 0000000..22bbc55 --- /dev/null +++ b/src/config/shop.json @@ -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 + } + ] +} diff --git a/src/engine/shopEngine.js b/src/engine/shopEngine.js new file mode 100644 index 0000000..4cdecb7 --- /dev/null +++ b/src/engine/shopEngine.js @@ -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); + } + } +} diff --git a/test/shopEngine.test.js b/test/shopEngine.test.js new file mode 100644 index 0000000..98be411 --- /dev/null +++ b/test/shopEngine.test.js @@ -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();