import fc from 'fast-check'; import { describe, it } from 'vitest'; import { Character } from './Character.ts'; import { Level } from './value-objects/Level.ts'; import { MagicalWeapon } from './MagicalWeapon.ts'; import { HealingObject } from './HealingObject.ts'; describe('Magical Objects', () => { describe('WeaponDealsDamage', () => { it('property: weapon deals its fixed damage amount', () => { fc.assert( fc.property( fc.integer({ min: 1, max: 500 }), fc.integer({ min: 1, max: 500 }), fc.integer({ min: 1, max: 10 }), (damage, weaponHP, level) => { const attacker = Character.create({ name: 'hero', level: Level.create(level) }); const target = Character.create({ name: 'goblin', level: Level.create(level) }); const weapon = MagicalWeapon.create({ damage, maxHealth: weaponHP, owner: attacker, }); const result = attacker.useWeapon(weapon, target); return result.target.health.value === Math.max(0, 1000 - damage); }, ), ); }); it('property: weapon health decreases by 1 after use', () => { fc.assert( fc.property( fc.integer({ min: 2, max: 500 }), fc.integer({ min: 1, max: 500 }), (weaponHP, damage) => { const attacker = Character.create({ name: 'hero', level: Level.create(1) }); const target = Character.create({ name: 'goblin', level: Level.create(1) }); const weapon = MagicalWeapon.create({ damage, maxHealth: weaponHP, owner: attacker }); const result = attacker.useWeapon(weapon, target); return result.weapon.health === weaponHP - 1; }, ), ); }); it('property: weapon is destroyed when health reaches 0', () => { fc.assert( fc.property(fc.integer({ min: 1, max: 500 }), (damage) => { const attacker = Character.create({ name: 'hero', level: Level.create(1) }); const target = Character.create({ name: 'goblin', level: Level.create(1) }); const weapon = MagicalWeapon.create({ damage, maxHealth: 1, owner: attacker }); const result = attacker.useWeapon(weapon, target); return result.weapon.status.kind === 'destroyed'; }), ); }); it('property: weapon remains alive when health > 0 after use', () => { fc.assert( fc.property( fc.integer({ min: 2, max: 500 }), fc.integer({ min: 1, max: 500 }), (weaponHP, damage) => { const attacker = Character.create({ name: 'hero', level: Level.create(1) }); const target = Character.create({ name: 'goblin', level: Level.create(1) }); const weapon = MagicalWeapon.create({ damage, maxHealth: weaponHP, owner: attacker }); const result = attacker.useWeapon(weapon, target); return result.weapon.status.kind === 'alive'; }, ), ); }); }); describe('DeadCannotUseWeapon', () => { it('property: dead character cannot use weapon — state unchanged', () => { fc.assert( fc.property( fc.integer({ min: 1, max: 500 }), fc.integer({ min: 1, max: 500 }), (damage, weaponHP) => { const attacker = Character.create({ name: 'hero', level: Level.create(1) }); const target = Character.create({ name: 'goblin', level: Level.create(1) }); const weapon = MagicalWeapon.create({ damage, maxHealth: weaponHP, owner: attacker }); // Kill the attacker using a separate killer const killer = Character.create({ name: 'boss', level: Level.create(1) }); const deadAttacker = killer.dealDamage(attacker, 10000); const weaponHPBefore = weapon.health; const targetHealthBefore = target.health.value; const result = deadAttacker.useWeapon(weapon, target); return ( result.weapon.health === weaponHPBefore && result.target.health.value === targetHealthBefore ); }, ), ); }); }); describe('NonOwnerCannotUseWeapon', () => { it('property: non-owner cannot use weapon — state unchanged', () => { fc.assert( fc.property( fc.integer({ min: 1, max: 500 }), fc.integer({ min: 1, max: 500 }), (damage, weaponHP) => { const owner = Character.create({ name: 'owner', level: Level.create(1) }); const thief = Character.create({ name: 'thief', level: Level.create(1) }); const target = Character.create({ name: 'goblin', level: Level.create(1) }); const weapon = MagicalWeapon.create({ damage, maxHealth: weaponHP, owner }); const weaponHPBefore = weapon.health; const targetHealthBefore = target.health.value; const result = thief.useWeapon(weapon, target); return ( result.weapon.health === weaponHPBefore && result.target.health.value === targetHealthBefore ); }, ), ); }); }); describe('DestroyedWeaponCannotDealDamage', () => { it('property: destroyed weapon cannot deal damage — state unchanged', () => { fc.assert( fc.property(fc.integer({ min: 1, max: 500 }), (damage) => { const owner = Character.create({ name: 'owner', level: Level.create(1) }); const target = Character.create({ name: 'goblin', level: Level.create(1) }); const weapon = MagicalWeapon.create({ damage, maxHealth: 1, owner }); // Destroy the weapon first const firstUse = owner.useWeapon(weapon, target); const destroyedWeapon = firstUse.weapon; const targetHealthBefore = firstUse.target.health.value; // Try to use again on the destroyed weapon const result = owner.useWeapon(destroyedWeapon, firstUse.target); return result.weapon.health === 0 && result.target.health.value === targetHealthBefore; }), ); }); }); describe('HealingObjectHealsCharacter', () => { it('property: healing object gives health up to its remaining health', () => { fc.assert( fc.property( fc.integer({ min: 1, max: 500 }), fc.integer({ min: 1, max: 500 }), fc.integer({ min: 1, max: 500 }), (objectHP, healAmount, characterHealth) => { fc.pre(healAmount >= objectHP); fc.pre(characterHealth + objectHP <= 1000); const character = Character.createWithHealth({ name: 'hero', level: Level.create(1), health: characterHealth, }); const object = HealingObject.create({ maxHealth: objectHP, currentHealth: objectHP }); const result = character.useHealingObject(object, healAmount); return result.character.health.value === characterHealth + objectHP; }, ), ); }); it('property: healing object gives health up to character max when object has more', () => { fc.assert( fc.property( fc.integer({ min: 1, max: 500 }), fc.integer({ min: 1, max: 500 }), fc.integer({ min: 900, max: 999 }), (objectHP, healAmount, characterHealth) => { fc.pre(objectHP >= healAmount); fc.pre(characterHealth + healAmount > 1000); const character = Character.createWithHealth({ name: 'hero', level: Level.create(1), health: characterHealth, }); const object = HealingObject.create({ maxHealth: objectHP, currentHealth: objectHP }); const result = character.useHealingObject(object, healAmount); return result.character.health.value === 1000; }, ), ); }); it('property: healing object health decreases by actual healed amount', () => { fc.assert( fc.property( fc.integer({ min: 1, max: 500 }), fc.integer({ min: 1, max: 500 }), fc.integer({ min: 1, max: 999 }), (objectHP, healAmount, characterHealth) => { fc.pre(characterHealth + healAmount <= 1000); fc.pre(healAmount <= objectHP); const character = Character.createWithHealth({ name: 'hero', level: Level.create(1), health: characterHealth, }); const object = HealingObject.create({ maxHealth: objectHP, currentHealth: objectHP }); const result = character.useHealingObject(object, healAmount); return result.object.health === objectHP - healAmount; }, ), ); }); it('property: healing object is destroyed when health reaches 0', () => { fc.assert( fc.property(fc.integer({ min: 1, max: 500 }), (objectHP) => { // Character needs enough headroom to accept all object health const characterHealth = Math.max(1, 1000 - objectHP); const character = Character.createWithHealth({ name: 'hero', level: Level.create(1), health: characterHealth, }); const object = HealingObject.create({ maxHealth: objectHP, currentHealth: objectHP }); const result = character.useHealingObject(object, objectHP); return result.object.status.kind === 'destroyed'; }), ); }); it('property: healing object remains alive when health > 0 after use', () => { fc.assert( fc.property( fc.integer({ min: 2, max: 500 }), fc.integer({ min: 1, max: 999 }), (objectHP, characterHealth) => { const character = Character.createWithHealth({ name: 'hero', level: Level.create(1), health: characterHealth, }); const object = HealingObject.create({ maxHealth: objectHP, currentHealth: objectHP }); const result = character.useHealingObject(object, 1); return result.object.status.kind === 'alive'; }, ), ); }); }); describe('DeadCannotUseHealingObject', () => { it('property: dead character cannot use healing object — state unchanged', () => { fc.assert( fc.property(fc.integer({ min: 1, max: 500 }), (objectHP) => { const character = Character.create({ name: 'hero', level: Level.create(1) }); const object = HealingObject.create({ maxHealth: objectHP, currentHealth: objectHP }); // Kill the character using a separate killer const killer = Character.create({ name: 'boss', level: Level.create(1) }); const deadCharacter = killer.dealDamage(character, 10000); const objectHPBefore = object.health; const characterHealthBefore = deadCharacter.health.value; const result = deadCharacter.useHealingObject(object, 100); return ( result.object.health === objectHPBefore && result.character.health.value === characterHealthBefore ); }), ); }); }); describe('DestroyedHealingObjectCannotHeal', () => { it('property: destroyed healing object cannot heal — state unchanged', () => { fc.assert( fc.property(fc.integer({ min: 1, max: 500 }), (objectHP) => { // Character needs headroom to drain the object const characterHealth = Math.max(1, 1000 - objectHP); const character = Character.createWithHealth({ name: 'hero', level: Level.create(1), health: characterHealth, }); const object = HealingObject.create({ maxHealth: objectHP, currentHealth: objectHP }); // Drain the object to 0 const firstUse = character.useHealingObject(object, objectHP); const destroyedObject = firstUse.object; const healedCharacter = firstUse.character; const characterHealthBefore = healedCharacter.health.value; // Try to use again on the destroyed object const result = healedCharacter.useHealingObject(destroyedObject, 100); return ( result.object.health === 0 && result.character.health.value === characterHealthBefore ); }), ); }); }); describe('Invariants', () => { it('property: weapon health never goes negative', () => { fc.assert( fc.property( fc.integer({ min: 1, max: 500 }), fc.integer({ min: 1, max: 10000 }), (weaponHP, damage) => { const attacker = Character.create({ name: 'hero', level: Level.create(1) }); const target = Character.create({ name: 'goblin', level: Level.create(1) }); const weapon = MagicalWeapon.create({ damage, maxHealth: weaponHP, owner: attacker }); const result = attacker.useWeapon(weapon, target); return result.weapon.health >= 0; }, ), ); }); it('property: healing object health never goes negative', () => { fc.assert( fc.property( fc.integer({ min: 1, max: 500 }), fc.integer({ min: 1, max: 10000 }), (objectHP, healAmount) => { const character = Character.create({ name: 'hero', level: Level.create(1) }); const object = HealingObject.create({ maxHealth: objectHP, currentHealth: objectHP }); const result = character.useHealingObject(object, healAmount); return result.object.health >= 0; }, ), ); }); it('property: weapon health never exceeds maxHealth', () => { fc.assert( fc.property( fc.integer({ min: 1, max: 500 }), fc.integer({ min: 1, max: 500 }), (weaponHP, damage) => { const attacker = Character.create({ name: 'hero', level: Level.create(1) }); const target = Character.create({ name: 'goblin', level: Level.create(1) }); const weapon = MagicalWeapon.create({ damage, maxHealth: weaponHP, owner: attacker }); const result = attacker.useWeapon(weapon, target); return result.weapon.health <= weaponHP; }, ), ); }); it('property: healing object health never exceeds maxHealth', () => { fc.assert( fc.property( fc.integer({ min: 1, max: 500 }), fc.integer({ min: 1, max: 10000 }), (objectHP, healAmount) => { const character = Character.create({ name: 'hero', level: Level.create(1) }); const object = HealingObject.create({ maxHealth: objectHP, currentHealth: objectHP }); const result = character.useHealingObject(object, healAmount); return result.object.health <= objectHP; }, ), ); }); }); });