361 lines
15 KiB
TypeScript
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;
|
|
},
|
|
),
|
|
);
|
|
});
|
|
});
|
|
});
|