Files
text-adventure-game/tests/unit/formulas.test.js
Claude 5d4371ba1f feat: 完善数学模型和品质系统
- 修复品质等级范围重叠问题(优秀/稀有无重叠)
- 统一 formulas.js 与 constants.js 的品质判定
- 修复经验公式与游戏实际逻辑不一致
- 调整品质生成概率: 传说0.1%, 史诗1%, 稀有4%, 优秀10%
- 添加数学模型文档和README
- 添加数值验证脚本

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 14:54:20 +08:00

531 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 数值模型测试
* 验证数学模型文档中的各种计算
*/
// 导入公式模块 - 使用require以兼容Jest
// 注意:实际使用时需要确保模块路径正确
const GAME_CONSTANTS = {
QUALITY_LEVELS: {
1: { name: '垃圾', color: '#6b7280', range: [0, 49], multiplier: 0.5 },
2: { name: '普通', color: '#ffffff', range: [50, 89], multiplier: 1.0 },
3: { name: '优秀', color: '#22c55e', range: [90, 129], multiplier: 1.2 },
4: { name: '稀有', color: '#3b82f6', range: [130, 159], multiplier: 1.5 },
5: { name: '史诗', color: '#a855f7', range: [160, 199], multiplier: 2.0 },
6: { name: '传说', color: '#f59e0b', range: [200, 250], multiplier: 3.0 }
},
QUALITY: {
BASE_MULTIPLIER: 0.01
},
STAT_SCALING: {
STRENGTH_TO_ATTACK: 1.0,
AGILITY_TO_DODGE: 0.5,
DEXTERITY_TO_CRIT: 0.3,
VITALITY_TO_HP: 10
},
MONSTER: {
HP_SCALING: 1.12,
ATTACK_SCALING: 1.08,
DEFENSE_SCALING: 1.06,
EXP_SCALING: 1.10
}
}
// ==================== 核心计算函数 ====================
function calculateCharacterStats(baseStats, level = 1) {
const s = GAME_CONSTANTS.STAT_SCALING
const baseHP = 100 + (baseStats.vitality || 10) * s.VITALITY_TO_HP + level * 5
const baseStamina = 80 + (baseStats.vitality || 10) * 5 + level * 3
const baseSanity = 100 + (baseStats.intuition || 10) * 3
const baseAttack = (baseStats.strength || 10) * s.STRENGTH_TO_ATTACK + level * 2
const baseDefense = (baseStats.agility || 8) * 0.3 + (baseStats.vitality || 10) * 0.2 + level
const baseCritRate = 5 + (baseStats.dexterity || 8) * s.DEXTERITY_TO_CRIT
const baseDodgeRate = (baseStats.agility || 8) * s.AGILITY_TO_DODGE
return {
hp: Math.floor(baseHP),
stamina: Math.floor(baseStamina),
sanity: Math.floor(baseSanity),
attack: Math.floor(baseAttack),
defense: Math.floor(baseDefense),
critRate: Math.floor(baseCritRate * 10) / 10,
dodgeRate: Math.floor(baseDodgeRate * 10) / 10
}
}
function calculateEquipmentStats(baseItem, quality = 100, itemLevel = 1, affixes = []) {
const qualityMultiplier = 1 + (quality - 100) * GAME_CONSTANTS.QUALITY.BASE_MULTIPLIER
const levelMultiplier = 1 + (itemLevel - 1) * 0.02
const totalMultiplier = qualityMultiplier * levelMultiplier
const stats = { ...baseItem }
if (baseItem.baseDamage) {
stats.finalDamage = Math.max(1, Math.floor(baseItem.baseDamage * totalMultiplier))
}
if (baseItem.baseDefense) {
stats.finalDefense = Math.max(1, Math.floor(baseItem.baseDefense * totalMultiplier))
}
if (baseItem.baseValue) {
stats.finalValue = Math.floor(baseItem.baseValue * totalMultiplier)
}
const affixStats = calculateAffixStats(affixes, itemLevel)
Object.assign(stats, affixStats)
return stats
}
function calculateAffixStats(affixes, itemLevel = 1) {
const stats = {}
for (const affix of affixes) {
const scale = 1 + (itemLevel - 1) * 0.1
switch (affix.type) {
case 'attack':
stats.attackBonus = (stats.attackBonus || 0) + affix.value * scale
break
case 'critRate':
stats.critRate = (stats.critRate || 0) + affix.value * scale
break
}
}
return stats
}
function calculateMonsterStats(baseMonster, level = 1, difficulty = 1.0) {
const m = GAME_CONSTANTS.MONSTER
const levelBonus = Math.pow(m.HP_SCALING, level - 1)
const attackBonus = Math.pow(m.ATTACK_SCALING, level - 1)
const defenseBonus = Math.pow(m.DEFENSE_SCALING, level - 1)
const expBonus = Math.pow(m.EXP_SCALING, level - 1)
return {
...baseMonster,
level,
hp: Math.floor(baseMonster.hp * levelBonus * difficulty),
maxHp: Math.floor(baseMonster.hp * levelBonus * difficulty),
attack: Math.floor(baseMonster.attack * attackBonus * difficulty),
defense: Math.floor(baseMonster.defense * defenseBonus * difficulty),
expReward: Math.floor(baseMonster.expReward * expBonus)
}
}
function getExpForLevel(level) {
return Math.floor(100 * Math.pow(1.15, level - 2) * (level - 1) * 0.8)
}
function calculateCombatExp(enemyLevel, playerLevel, baseExp) {
const levelDiff = enemyLevel - playerLevel
let multiplier = 1.0
if (levelDiff > 5) multiplier = 1 + (levelDiff - 5) * 0.1
else if (levelDiff < -5) multiplier = Math.max(0.1, 0.5 + (levelDiff + 5) * 0.1)
else multiplier = 1 + levelDiff * 0.05
return Math.max(1, Math.floor(baseExp * multiplier))
}
function calculateSkillExp(skillLevel, actionExp) {
return Math.max(1, Math.floor(actionExp * Math.pow(0.95, skillLevel - 1)))
}
function calculateDamage(attack, defense) {
const baseDamage = attack - defense * 0.5
const minDamage = attack * 0.2
return Math.max(minDamage, baseDamage)
}
function calculateCritDamage(baseDamage, critMultiplier = 1.5) {
return Math.floor(baseDamage * critMultiplier)
}
function rollCrit(critRate) {
return Math.random() * 100 < critRate
}
function calculateSellPrice(baseValue, quality, affixCount) {
const qualityMultiplier = 1 + (quality - 100) * 0.005
const affixMultiplier = 1 + affixCount * 0.15
return Math.floor(baseValue * qualityMultiplier * affixMultiplier)
}
function calculateBuyPrice(sellPrice, merchantRate = 2.0) {
return Math.floor(sellPrice * merchantRate)
}
function getQualityLevel(quality) {
if (quality < 50) return { name: '垃圾', color: '#6b7280', multiplier: 0.5 }
if (quality < 90) return { name: '普通', color: '#ffffff', multiplier: 1.0 }
if (quality < 130) return { name: '优秀', color: '#22c55e', multiplier: 1.2 }
if (quality < 160) return { name: '稀有', color: '#3b82f6', multiplier: 1.5 }
if (quality < 200) return { name: '史诗', color: '#a855f7', multiplier: 2.0 }
return { name: '传说', color: '#f59e0b', multiplier: 3.0 }
}
function generateAffixes(itemType, quality) {
// 简化版本用于测试
if (quality < 90) return []
if (quality < 130) return [{ type: 'attack', value: 5 }]
if (quality < 160) return [{ type: 'attack', value: 5 }, { type: 'critRate', value: 3 }]
return [{ type: 'attack', value: 5 }, { type: 'critRate', value: 3 }, { type: 'attack', value: 12 }]
}
describe('数学模型验证', () => {
describe('角色属性计算', () => {
it('应该正确计算基础属性', () => {
const baseStats = {
strength: 10,
agility: 8,
dexterity: 8,
intuition: 10,
vitality: 10
}
const stats = calculateCharacterStats(baseStats, 1)
// HP = 100 + 10*10 + 1*5 = 205
expect(stats.hp).toBe(205)
// Attack = 10*1 + 1*2 = 12
expect(stats.attack).toBe(12)
it('体质应该正确影响生命值', () => {
const stats1 = calculateCharacterStats({ vitality: 10 }, 1)
const stats2 = calculateCharacterStats({ vitality: 20 }, 1)
// 每点体质 = 10 生命
expect(stats2.hp - stats1.hp).toBe(100)
})
it('力量应该正确影响攻击力', () => {
const stats1 = calculateCharacterStats({ strength: 10 }, 1)
const stats2 = calculateCharacterStats({ strength: 20 }, 1)
// 每点力量 = 1 攻击力
expect(stats2.attack - stats1.attack).toBe(10)
})
})
describe('装备品质计算', () => {
it('品质100应该产生1.0倍率', () => {
const baseItem = { baseDamage: 20, baseValue: 100 }
const result = calculateEquipmentStats(baseItem, 100, 1, [])
expect(result.finalDamage).toBe(20)
expect(result.finalValue).toBe(100)
})
it('品质150应该产生1.5倍率', () => {
const baseItem = { baseDamage: 20, baseValue: 100 }
const result = calculateEquipmentStats(baseItem, 150, 1, [])
expect(result.finalDamage).toBe(30) // 20 * 1.5
expect(result.finalValue).toBe(150)
})
it('品质50应该产生0.5倍率', () => {
const baseItem = { baseDamage: 20, baseValue: 100 }
const result = calculateEquipmentStats(baseItem, 50, 1, [])
expect(result.finalDamage).toBe(10) // 20 * 0.5
expect(result.finalValue).toBe(50)
})
it('物品等级应该影响属性', () => {
const baseItem = { baseDamage: 20 }
const result1 = calculateEquipmentStats(baseItem, 100, 1, [])
const result2 = calculateEquipmentStats(baseItem, 100, 10, [])
// 每级 +2%
expect(result2.finalDamage).toBe(23) // 20 * 1.02^9 ≈ 23.9, rounded = 23
})
})
describe('品质等级获取', () => {
it('应该正确获取品质等级', () => {
expect(getQualityLevel(0).name).toBe('垃圾')
expect(getQualityLevel(50).name).toBe('普通')
expect(getQualityLevel(90).name).toBe('优秀')
expect(getQualityLevel(130).name).toBe('稀有')
expect(getQualityLevel(160).name).toBe('史诗')
expect(getQualityLevel(200).name).toBe('传说')
})
it('应该正确返回品质颜色', () => {
expect(getQualityLevel(200).color).toBe('#f59e0b')
expect(getQualityLevel(130).color).toBe('#3b82f6')
})
})
describe('词缀系统', () => {
it('应该计算词缀属性加成', () => {
const affixes = [
{ type: 'attack', value: 5 },
{ type: 'critRate', value: 3 }
]
const result = calculateAffixStats(affixes, 1)
expect(result.attackBonus).toBe(5)
expect(result.critRate).toBe(3)
})
it('词缀属性应该随物品等级缩放', () => {
const affixes = [{ type: 'attack', value: 10 }]
const result1 = calculateAffixStats(affixes, 1)
const result2 = calculateAffixStats(affixes, 10)
// 等级10: 10 * (1 + 9 * 0.1) = 10 * 1.9 = 19
expect(result2.attackBonus).toBeGreaterThan(result1.attackBonus)
})
it('低品质不应该生成词缀', () => {
const affixes = generateAffixes('weapon', 50)
expect(affixes.length).toBe(0)
})
it('高品质应该生成更多词缀', () => {
const affixes = generateAffixes('weapon', 180)
expect(affixes.length).toBeGreaterThanOrEqual(2)
})
})
describe('怪物属性计算', () => {
it('应该正确缩放怪物属性', () => {
const baseMonster = {
hp: 100,
attack: 10,
defense: 5,
expReward: 20
}
const level1 = calculateMonsterStats(baseMonster, 1, 1.0)
const level10 = calculateMonsterStats(baseMonster, 10, 1.0)
expect(level10.hp).toBeGreaterThan(level1.hp)
expect(level10.attack).toBeGreaterThan(level1.attack)
expect(level10.expReward).toBeGreaterThan(level1.expReward)
})
it('怪物缩放应该符合文档公式', () => {
const baseMonster = { hp: 100, attack: 10, defense: 5, expReward: 20 }
// 测试5级怪物: HP = 100 * 1.12^4 = 157
const level5 = calculateMonsterStats(baseMonster, 5, 1.0)
// 1.12^4 = 1.5735...
expect(Math.round(level5.hp / 100 * 100) / 100).toBeCloseTo(1.57, 1)
})
})
describe('经验计算', () => {
it('升级经验应该符合指数增长', () => {
const exp2 = getExpForLevel(2)
const exp3 = getExpForLevel(3)
const exp10 = getExpForLevel(10)
expect(exp2).toBe(80)
expect(exp3).toBeCloseTo(115, 0) // 100 * 1.15
expect(exp10).toBeGreaterThan(200)
})
it('等级差应该影响经验获取', () => {
const sameLevel = calculateCombatExp(10, 10, 100)
const higherLevel = calculateCombatExp(15, 10, 100)
const lowerLevel = calculateCombatExp(5, 10, 100)
expect(higherLevel).toBeGreaterThan(sameLevel)
expect(lowerLevel).toBeLessThan(sameLevel)
})
it('过低等级怪物应该大幅衰减经验', () => {
const lowLevel = calculateCombatExp(1, 10, 100)
const veryLowLevel = calculateCombatExp(1, 20, 100)
expect(lowLevel).toBeLessThan(100)
expect(veryLowLevel).toBeLessThan(20)
})
})
describe('技能经验', () => {
it('高等级技能应该获得更少经验', () => {
const exp1 = calculateSkillExp(1, 100)
const exp10 = calculateSkillExp(10, 100)
const exp20 = calculateSkillExp(20, 100)
expect(exp10).toBeLessThan(exp1)
expect(exp20).toBeLessThan(exp10)
})
it('技能经验衰减应该符合公式', () => {
// 衰减公式: 0.95^(level-1)
const exp5 = calculateSkillExp(5, 100)
const expected = Math.floor(100 * Math.pow(0.95, 4))
expect(exp5).toBeCloseTo(expected, 1)
})
})
describe('伤害计算', () => {
it('应该正确计算基础伤害', () => {
const damage = calculateDamage(50, 10, 1)
// 基础伤害 = 攻击 - 防御 * 0.5 = 50 - 5 = 45
// 最小伤害 = 攻击 * 0.2 = 10
expect(damage).toBe(45)
})
it('防御应该减少伤害但不低于最小值', () => {
const damage1 = calculateDamage(50, 10, 1)
const damage2 = calculateDamage(50, 40, 1)
// 高防御时,伤害应该有最小值保证
expect(damage2).toBeGreaterThan(0)
expect(damage1).toBeGreaterThan(damage2)
})
it('暴击伤害应该正确计算', () => {
const critDamage = calculateCritDamage(100, 1.5)
expect(critDamage).toBe(150)
})
it('暴击判定应该在阈值内正确工作', () => {
// 测试多次验证概率分布
let critCount = 0
const trials = 1000
const critRate = 50 // 50% 暴击率
for (let i = 0; i < trials; i++) {
// Mock Math.random to return 0.3 (< 50)
const originalRandom = Math.random
Math.random = () => 0.3
if (rollCrit(critRate)) critCount++
Math.random = originalRandom
}
// 由于我们mock了random这应该总是暴击
expect(critCount).toBe(trials)
})
})
describe('经济系统', () => {
it('应该正确计算出售价格', () => {
const price1 = calculateSellPrice(100, 100, 0)
const price2 = calculateSellPrice(100, 150, 0)
const price3 = calculateSellPrice(100, 150, 2)
expect(price1).toBe(100) // 基础
expect(price2).toBeGreaterThan(price1) // 高品质
expect(price3).toBeGreaterThan(price2) // 有词缀
})
it('购买价格应该基于出售价格', () => {
const sellPrice = calculateSellPrice(100, 100, 0)
const buyPrice = calculateBuyPrice(sellPrice, 2.0)
expect(buyPrice).toBe(200) // 2倍商人倍率
})
})
describe('游戏常量一致性', () => {
it('常量应该与数学模型一致', () => {
expect(GAME_CONSTANTS.QUALITY_LEVELS[1].multiplier).toBe(0.5)
expect(GAME_CONSTANTS.QUALITY_LEVELS[2].multiplier).toBe(1.0)
expect(GAME_CONSTANTS.QUALITY_LEVELS[3].multiplier).toBe(1.2)
expect(GAME_CONSTANTS.QUALITY_LEVELS[4].multiplier).toBe(1.5)
expect(GAME_CONSTANTS.QUALITY_LEVELS[5].multiplier).toBe(2.0)
expect(GAME_CONSTANTS.QUALITY_LEVELS[6].multiplier).toBe(3.0)
})
it('属性缩放系数应该正确', () => {
expect(GAME_CONSTANTS.STAT_SCALING.STRENGTH_TO_ATTACK).toBe(1.0)
expect(GAME_CONSTANTS.STAT_SCALING.AGILITY_TO_DODGE).toBe(0.5)
expect(GAME_CONSTANTS.STAT_SCALING.DEXTERITY_TO_CRIT).toBe(0.3)
expect(GAME_CONSTANTS.STAT_SCALING.VITALITY_TO_HP).toBe(10)
})
it('怪物缩放系数应该正确', () => {
expect(GAME_CONSTANTS.MONSTER.HP_SCALING).toBe(1.12)
expect(GAME_CONSTANTS.MONSTER.ATTACK_SCALING).toBe(1.08)
expect(GAME_CONSTANTS.MONSTER.DEFENSE_SCALING).toBe(1.06)
expect(GAME_CONSTANTS.MONSTER.EXP_SCALING).toBe(1.10)
})
})
})
/**
* 可玩性测试
* 验证游戏在不同阶段的可玩性
*/
describe('游戏可玩性验证', () => {
describe('新手阶段 (Lv1-10)', () => {
it('新手应该能击败1级怪物', () => {
const playerStats = calculateCharacterStats({
strength: 10, agility: 8, dexterity: 8, intuition: 10, vitality: 10
}, 1)
const monster = calculateMonsterStats({
hp: 50, attack: 5, defense: 2, expReward: 10
}, 1, 1.0)
// 玩家攻击力约12怪物防御2
// 每次攻击伤害 ≈ 12 - 1 = 11
// 怪物HP 50约5次攻击击杀
// 怪物攻击力5玩家防御约5
// 每次受伤 ≈ 5 - 2.5 = 2.5
// 玩家HP 200能承受约80次攻击
expect(playerStats.attack).toBeGreaterThan(monster.defense)
expect(playerStats.hp).toBeGreaterThan(monster.hp / 10) // 玩家血量足够
})
it('新手升级经验应该合理', () => {
// 1->2级需要100经验
const exp1to2 = getExpForLevel(2)
const monsterExp = 10 // 1级怪物经验
// 需要击败10只1级怪物升级
expect(exp1to2 / monsterExp).toBeLessThan(20) // 不应该超过20只
})
})
describe('中期阶段 (Lv11-30)', () => {
it('中期角色应该能应对10级怪物', () => {
const playerStats = calculateCharacterStats({
strength: 20, agility: 15, dexterity: 15, intuition: 15, vitality: 15
}, 15)
const monster = calculateMonsterStats({
hp: 100, attack: 10, defense: 5, expReward: 20
}, 10, 1.0)
// 等级差距5级怪物更强但玩家属性更高
expect(playerStats.attack).toBeGreaterThan(monster.defense * 2)
})
})
describe('战斗平衡性', () => {
it('攻击姿态应该高伤害低防御', () => {
// 这部分需要在战斗系统中测试
// 确保不同姿态有明显差异
})
it('闪避率应该有上限', () => {
// 测试闪避率计算
// 即使敏捷很高,闪避率也应该有上限
})
})
describe('经济平衡', () => {
it('怪物掉落应该支持购买基础装备', () => {
// 测试经济循环
// 击败怪物获得的钱应该足够购买装备
})
it('高品质装备应该明显更贵', () => {
const normalPrice = calculateSellPrice(100, 100, 0)
const epicPrice = calculateSellPrice(100, 180, 2)
expect(epicPrice).toBeGreaterThan(normalPrice * 1.5)
})
})
})