rpg-combat-pi-01/src/magical-objects.spec.ts

361 lines
15 KiB
TypeScript

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;
},
),
);
});
});
});