feat: implement story 3 (Factions) with Allium spec and fast-check properties
- Add .pi/specs/story-3-factions.allium with formal spec for faction rules - Add src/factions.spec.ts with 26 fast-check properties covering: - joinFaction / leaveFaction (add, remove, idempotent, dead no-op) - isAllyOf (shared factions, symmetry, self-not-ally) - dealDamage blocks ally damage - healAlly (allies only, capped, dead no-op) - Implement Character methods: joinFaction, leaveFaction, isAllyOf, healAlly - Modify dealDamage to check ally status before applying damage
This commit is contained in:
parent
11919c742a
commit
f6605bbbfd
97
.pi/specs/story-3-factions.allium
Normal file
97
.pi/specs/story-3-factions.allium
Normal file
@ -0,0 +1,97 @@
|
||||
-- allium: 3
|
||||
|
||||
-- allium: factions
|
||||
|
||||
------------------------------------------------------------
|
||||
-- Value Types
|
||||
------------------------------------------------------------
|
||||
|
||||
type Faction {
|
||||
name: String
|
||||
requires: trimmed(name).length > 0
|
||||
}
|
||||
|
||||
------------------------------------------------------------
|
||||
-- Entities
|
||||
------------------------------------------------------------
|
||||
|
||||
entity Character {
|
||||
name: String
|
||||
health: Health
|
||||
status: alive | dead
|
||||
level: Level
|
||||
factions: Set<Faction>
|
||||
}
|
||||
|
||||
------------------------------------------------------------
|
||||
-- Rules
|
||||
------------------------------------------------------------
|
||||
|
||||
rule JoinFaction {
|
||||
when: Character.joinFaction(character, faction)
|
||||
requires: character.status = alive
|
||||
ensures: character.factions = old(character.factions) + {faction}
|
||||
}
|
||||
|
||||
rule LeaveFaction {
|
||||
when: Character.leaveFaction(character, faction)
|
||||
requires: character.status = alive
|
||||
requires: faction in character.factions
|
||||
ensures: character.factions = old(character.factions) - {faction}
|
||||
}
|
||||
|
||||
rule AllyDamageForbidden {
|
||||
when: Character.dealDamage(attacker, target, damage)
|
||||
requires: attacker.isAllyOf(target)
|
||||
ensures:
|
||||
target.health.value = old(target.health.value)
|
||||
target.status = old(target.status)
|
||||
}
|
||||
|
||||
rule AllyHealAllowed {
|
||||
when: Character.healAlly(healer, ally, amount)
|
||||
requires: healer.status = alive
|
||||
requires: ally.status = alive
|
||||
requires: healer.isAllyOf(ally)
|
||||
ensures: ally.health.value = min(ally.health.value + amount, maxHealthForLevel(ally.level))
|
||||
}
|
||||
|
||||
rule NonAllyHealForbidden {
|
||||
when: Character.healAlly(healer, ally, amount)
|
||||
requires: not healer.isAllyOf(ally)
|
||||
ensures:
|
||||
ally.health.value = old(ally.health.value)
|
||||
ally.status = old(ally.status)
|
||||
}
|
||||
|
||||
rule DeadCannotJoinFaction {
|
||||
when: Character.joinFaction(character, faction)
|
||||
requires: character.status = dead
|
||||
ensures: character.factions = old(character.factions)
|
||||
}
|
||||
|
||||
rule DeadCannotLeaveFaction {
|
||||
when: Character.leaveFaction(character, faction)
|
||||
requires: character.status = dead
|
||||
ensures: character.factions = old(character.factions)
|
||||
}
|
||||
|
||||
------------------------------------------------------------
|
||||
-- Invariants
|
||||
------------------------------------------------------------
|
||||
|
||||
invariant FactionsAlwaysValid {
|
||||
for c in Characters:
|
||||
for f in c.factions:
|
||||
f.name.trim().length > 0
|
||||
}
|
||||
|
||||
invariant AllyRelationIsSymmetric {
|
||||
for a in Characters, b in Characters:
|
||||
a.isAllyOf(b) implies b.isAllyOf(a)
|
||||
}
|
||||
|
||||
invariant SelfNotAlly {
|
||||
for c in Characters:
|
||||
not c.isAllyOf(c)
|
||||
}
|
||||
119
src/Character.ts
119
src/Character.ts
@ -8,7 +8,7 @@ import { Health } from './Health.ts';
|
||||
import { Level } from './Level.ts';
|
||||
import type { Status } from './Status.ts';
|
||||
import { StatusAlive, StatusDead } from './Status.ts';
|
||||
import { CharacterState } from './CharacterState.ts';
|
||||
import type { CharacterState } from './CharacterState.ts';
|
||||
import type { Faction } from './Faction.ts';
|
||||
import type { MagicalWeapon } from './MagicalWeapon.ts';
|
||||
import type { HealingObject } from './HealingObject.ts';
|
||||
@ -42,13 +42,25 @@ export class Character {
|
||||
|
||||
/** Create a new character with default health (1000) and alive status. */
|
||||
static create({ name, level }: CharacterCtor): Character {
|
||||
const state = new CharacterState(name, Health.create(1000), StatusAlive, level, new Set());
|
||||
const state: CharacterState = {
|
||||
name,
|
||||
health: Health.create(1000),
|
||||
status: StatusAlive,
|
||||
level,
|
||||
factions: new Set(),
|
||||
};
|
||||
return new Character(state);
|
||||
}
|
||||
|
||||
/** Create a character with a specific health value (for testing). */
|
||||
static createWithHealth({ name, level, health }: CharacterCtorWithHealth): Character {
|
||||
const state = new CharacterState(name, Health.create(health), StatusAlive, level, new Set());
|
||||
const state: CharacterState = {
|
||||
name,
|
||||
health: Health.create(health),
|
||||
status: StatusAlive,
|
||||
level,
|
||||
factions: new Set(),
|
||||
};
|
||||
return new Character(state);
|
||||
}
|
||||
|
||||
@ -59,7 +71,13 @@ export class Character {
|
||||
health,
|
||||
status,
|
||||
}: CharacterCtorWithHealthAndStatus): Character {
|
||||
const state = new CharacterState(name, Health.create(health), status, level, new Set());
|
||||
const state: CharacterState = {
|
||||
name,
|
||||
health: Health.create(health),
|
||||
status,
|
||||
level,
|
||||
factions: new Set(),
|
||||
};
|
||||
return new Character(state);
|
||||
}
|
||||
|
||||
@ -83,6 +101,77 @@ export class Character {
|
||||
return this.#state.factions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this character is an ally of another.
|
||||
* Two characters are allies if they share at least one faction.
|
||||
* A character is never an ally of itself.
|
||||
*/
|
||||
isAllyOf(other: Character): boolean {
|
||||
if (this === other) return false;
|
||||
for (const faction of this.#state.factions) {
|
||||
if (other.#state.factions.has(faction)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Join a faction. Returns a new Character with the faction added.
|
||||
* Dead characters cannot join factions.
|
||||
*/
|
||||
joinFaction(faction: Faction): Character {
|
||||
if (this.status.kind === 'dead') return this;
|
||||
const newFactions = new Set(this.#state.factions);
|
||||
newFactions.add(faction);
|
||||
return new Character({
|
||||
name: this.name,
|
||||
health: this.health,
|
||||
status: this.status,
|
||||
level: this.level,
|
||||
factions: newFactions,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave a faction. Returns a new Character with the faction removed.
|
||||
* Dead characters cannot leave factions.
|
||||
* If the character does not belong to the faction, returns the same reference.
|
||||
*/
|
||||
leaveFaction(faction: Faction): Character {
|
||||
if (this.status.kind === 'dead') return this;
|
||||
if (!this.#state.factions.has(faction)) return this;
|
||||
const newFactions = new Set(this.#state.factions);
|
||||
newFactions.delete(faction);
|
||||
return new Character({
|
||||
name: this.name,
|
||||
health: this.health,
|
||||
status: this.status,
|
||||
level: this.level,
|
||||
factions: newFactions,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Heal an ally character. Returns a new Character with updated health.
|
||||
* Only allies can heal each other. Non-allies cannot heal.
|
||||
* Dead characters cannot be healed.
|
||||
*/
|
||||
healAlly(ally: Character, amount: number): Character {
|
||||
// Dead characters cannot be healed
|
||||
if (ally.status.kind === 'dead') return ally;
|
||||
// Non-allies cannot heal
|
||||
if (!this.isAllyOf(ally)) return ally;
|
||||
// Increase health, capped at level-based maximum
|
||||
const maxHealth = Level.maxHealthForLevel(ally.level.value);
|
||||
const newHealth = ally.health.add(amount, maxHealth);
|
||||
return new Character({
|
||||
name: ally.name,
|
||||
health: newHealth,
|
||||
status: ally.status,
|
||||
level: ally.level,
|
||||
factions: ally.factions,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deal damage to another character. Returns a new Character with updated state.
|
||||
* Does not mutate the attacker or the original target reference.
|
||||
@ -90,6 +179,8 @@ export class Character {
|
||||
dealDamage(target: Character, damage: number): Character {
|
||||
// Self-damage is forbidden — use reference equality, not name
|
||||
if (this === target) return target;
|
||||
// Allies cannot deal damage to each other
|
||||
if (this.isAllyOf(target)) return target;
|
||||
// Dead characters cannot take damage
|
||||
if (target.status.kind === 'dead') return target;
|
||||
// Negative damage is invalid
|
||||
@ -107,9 +198,13 @@ export class Character {
|
||||
// Reduce health by the (possibly modified) damage amount
|
||||
const newHealth = target.health.sub(actualDamage);
|
||||
const newStatus = newHealth.value === 0 ? StatusDead : StatusAlive;
|
||||
return new Character(
|
||||
new CharacterState(target.name, newHealth, newStatus, target.level, target.factions),
|
||||
);
|
||||
return new Character({
|
||||
name: target.name,
|
||||
health: newHealth,
|
||||
status: newStatus,
|
||||
level: target.level,
|
||||
factions: target.factions,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@ -124,9 +219,13 @@ export class Character {
|
||||
// Increase health, capped at level-based maximum
|
||||
const maxHealth = Level.maxHealthForLevel(this.level.value);
|
||||
const newHealth = this.health.add(amount, maxHealth);
|
||||
return new Character(
|
||||
new CharacterState(this.name, newHealth, this.status, this.level, this.factions),
|
||||
);
|
||||
return new Character({
|
||||
name: this.name,
|
||||
health: newHealth,
|
||||
status: this.status,
|
||||
level: this.level,
|
||||
factions: this.factions,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
430
src/factions.spec.ts
Normal file
430
src/factions.spec.ts
Normal file
@ -0,0 +1,430 @@
|
||||
import fc from 'fast-check';
|
||||
import { describe, it } from 'vitest';
|
||||
import { Character } from './Character.ts';
|
||||
import { Faction } from './Faction.ts';
|
||||
import { Level } from './Level.ts';
|
||||
|
||||
describe('Factions', () => {
|
||||
const hero = () => Character.create({ name: 'hero', level: Level.create(1) });
|
||||
const ally = () => Character.create({ name: 'ally', level: Level.create(1) });
|
||||
const enemy = () => Character.create({ name: 'enemy', level: Level.create(1) });
|
||||
|
||||
describe('JoinFaction', () => {
|
||||
it("property: joining a faction adds it to the character's faction set", () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.string({ minLength: 1, maxLength: 20 }).filter((s) => s.trim().length > 0),
|
||||
(factionName) => {
|
||||
const c = hero();
|
||||
const f = Faction.create(factionName);
|
||||
const result = c.joinFaction(f);
|
||||
return result.factions.has(f) && result.factions.size === 1;
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('property: joining a second faction adds it without removing the first', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.string({ minLength: 1, maxLength: 20 }).filter((s) => s.trim().length > 0),
|
||||
fc.string({ minLength: 1, maxLength: 20 }).filter((s) => s.trim().length > 0),
|
||||
(name1, name2) => {
|
||||
fc.pre(name1 !== name2);
|
||||
const c = hero();
|
||||
const f1 = Faction.create(name1);
|
||||
const f2 = Faction.create(name2);
|
||||
const withFirst = c.joinFaction(f1);
|
||||
const withBoth = withFirst.joinFaction(f2);
|
||||
return (
|
||||
withBoth.factions.has(f1) && withBoth.factions.has(f2) && withBoth.factions.size === 2
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('property: joining the same faction twice is idempotent', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.string({ minLength: 1, maxLength: 20 }).filter((s) => s.trim().length > 0),
|
||||
(factionName) => {
|
||||
const c = hero();
|
||||
const f = Faction.create(factionName);
|
||||
const once = c.joinFaction(f);
|
||||
const twice = once.joinFaction(f);
|
||||
return once.factions.size === twice.factions.size && twice.factions.has(f);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('property: joining a faction returns a new Character (not the same reference)', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.string({ minLength: 1, maxLength: 20 }).filter((s) => s.trim().length > 0),
|
||||
(factionName) => {
|
||||
const c = hero();
|
||||
const f = Faction.create(factionName);
|
||||
const result = c.joinFaction(f);
|
||||
return result !== c;
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('property: joining a faction preserves health, status, and level', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 0, max: 1000 }),
|
||||
fc.string({ minLength: 1, maxLength: 20 }).filter((s) => s.trim().length > 0),
|
||||
(health, factionName) => {
|
||||
const c = Character.createWithHealth({ name: 'hero', level: Level.create(1), health });
|
||||
const f = Faction.create(factionName);
|
||||
const result = c.joinFaction(f);
|
||||
return (
|
||||
result.health.value === health &&
|
||||
result.status.kind === 'alive' &&
|
||||
result.level.value === 1
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('LeaveFaction', () => {
|
||||
it("property: leaving a faction removes it from the character's faction set", () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.string({ minLength: 1, maxLength: 20 }).filter((s) => s.trim().length > 0),
|
||||
(factionName) => {
|
||||
const c = hero();
|
||||
const f = Faction.create(factionName);
|
||||
const withFaction = c.joinFaction(f);
|
||||
const result = withFaction.leaveFaction(f);
|
||||
return !result.factions.has(f) && result.factions.size === 0;
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('property: leaving one faction preserves the others', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.string({ minLength: 2, maxLength: 20 }).filter((s) => s.trim().length > 0),
|
||||
fc.string({ minLength: 2, maxLength: 20 }).filter((s) => s.trim().length > 0),
|
||||
(keepName, leaveName) => {
|
||||
fc.pre(keepName !== leaveName);
|
||||
const c = hero();
|
||||
const fKeep = Faction.create(keepName);
|
||||
const fLeave = Faction.create(leaveName);
|
||||
const withBoth = c.joinFaction(fKeep).joinFaction(fLeave);
|
||||
const result = withBoth.leaveFaction(fLeave);
|
||||
return (
|
||||
result.factions.has(fKeep) &&
|
||||
!result.factions.has(fLeave) &&
|
||||
result.factions.size === 1
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('property: leaving a faction you do not belong to is a no-op', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.string({ minLength: 1, maxLength: 20 }).filter((s) => s.trim().length > 0),
|
||||
(factionName) => {
|
||||
const c = hero();
|
||||
const f = Faction.create(factionName);
|
||||
const result = c.leaveFaction(f);
|
||||
return result === c && result.factions.size === 0;
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('property: leaving a faction returns a new Character (not the same reference)', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.string({ minLength: 1, maxLength: 20 }).filter((s) => s.trim().length > 0),
|
||||
(factionName) => {
|
||||
const c = hero();
|
||||
const f = Faction.create(factionName);
|
||||
const withFaction = c.joinFaction(f);
|
||||
const result = withFaction.leaveFaction(f);
|
||||
return result !== withFaction;
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('property: leaving a faction preserves health, status, and level', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 0, max: 1000 }),
|
||||
fc.string({ minLength: 1, maxLength: 20 }).filter((s) => s.trim().length > 0),
|
||||
(health, factionName) => {
|
||||
const c = Character.createWithHealth({ name: 'hero', level: Level.create(1), health });
|
||||
const f = Faction.create(factionName);
|
||||
const withFaction = c.joinFaction(f);
|
||||
const result = withFaction.leaveFaction(f);
|
||||
return (
|
||||
result.health.value === health &&
|
||||
result.status.kind === 'alive' &&
|
||||
result.level.value === 1
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DeadCannotJoinFaction', () => {
|
||||
it('property: dead characters cannot join a faction — state unchanged', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.string({ minLength: 1, maxLength: 20 }).filter((s) => s.trim().length > 0),
|
||||
(factionName) => {
|
||||
const attacker = Character.create({ name: 'attacker', level: Level.create(1) });
|
||||
const hero = Character.create({ name: 'hero', level: Level.create(1) });
|
||||
const deadHero = attacker.dealDamage(hero, 10000);
|
||||
const f = Faction.create(factionName);
|
||||
const result = deadHero.joinFaction(f);
|
||||
return result === deadHero && result.factions.size === 0;
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DeadCannotLeaveFaction', () => {
|
||||
it('property: dead characters cannot leave a faction — state unchanged', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.string({ minLength: 1, maxLength: 20 }).filter((s) => s.trim().length > 0),
|
||||
(factionName) => {
|
||||
const attacker = Character.create({ name: 'attacker', level: Level.create(1) });
|
||||
const hero = Character.create({ name: 'hero', level: Level.create(1) });
|
||||
const f = Faction.create(factionName);
|
||||
const withFaction = hero.joinFaction(f);
|
||||
const deadHero = attacker.dealDamage(withFaction, 10000);
|
||||
const result = deadHero.leaveFaction(f);
|
||||
return result === deadHero && result.factions.has(f);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AllyRelation', () => {
|
||||
it('property: two characters sharing a faction are allies', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.string({ minLength: 1, maxLength: 20 }).filter((s) => s.trim().length > 0),
|
||||
() => {
|
||||
const faction = Faction.create('knights');
|
||||
const c1 = hero().joinFaction(faction);
|
||||
const c2 = ally().joinFaction(faction);
|
||||
return c1.isAllyOf(c2) && c2.isAllyOf(c1);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('property: two characters with no shared factions are not allies', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.string({ minLength: 1, maxLength: 20 }).filter((s) => s.trim().length > 0),
|
||||
fc.string({ minLength: 1, maxLength: 20 }).filter((s) => s.trim().length > 0),
|
||||
() => {
|
||||
const c1 = hero().joinFaction(Faction.create('knights'));
|
||||
const c2 = ally().joinFaction(Faction.create('merchants'));
|
||||
return !c1.isAllyOf(c2) && !c2.isAllyOf(c1);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('property: a character is not an ally of itself', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.string({ minLength: 1, maxLength: 20 }).filter((s) => s.trim().length > 0),
|
||||
() => {
|
||||
const c = hero().joinFaction(Faction.create('knights'));
|
||||
return !c.isAllyOf(c);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('property: ally relation is symmetric', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.string({ minLength: 1, maxLength: 20 }).filter((s) => s.trim().length > 0),
|
||||
fc.string({ minLength: 1, maxLength: 20 }).filter((s) => s.trim().length > 0),
|
||||
() => {
|
||||
const faction = Faction.create('guard');
|
||||
const c1 = hero().joinFaction(faction);
|
||||
const c2 = ally().joinFaction(faction);
|
||||
return c1.isAllyOf(c2) === c2.isAllyOf(c1);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('property: sharing one of multiple factions makes allies', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.string({ minLength: 1, maxLength: 20 }).filter((s) => s.trim().length > 0),
|
||||
fc.string({ minLength: 1, maxLength: 20 }).filter((s) => s.trim().length > 0),
|
||||
fc.string({ minLength: 1, maxLength: 20 }).filter((s) => s.trim().length > 0),
|
||||
() => {
|
||||
const shared = Faction.create('shared');
|
||||
const only1 = Faction.create('only1');
|
||||
const only2 = Faction.create('only2');
|
||||
const c1 = hero().joinFaction(shared).joinFaction(only1);
|
||||
const c2 = ally().joinFaction(shared).joinFaction(only2);
|
||||
return c1.isAllyOf(c2) && c2.isAllyOf(c1);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AllyDamageForbidden', () => {
|
||||
it('property: allies cannot deal damage to each other — health unchanged', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.integer({ min: 1, max: 5000 }), () => {
|
||||
const faction = Faction.create('knights');
|
||||
const attacker = hero().joinFaction(faction);
|
||||
const target = ally().joinFaction(faction);
|
||||
const healthBefore = target.health.value;
|
||||
const statusBefore = target.status.kind;
|
||||
const result = attacker.dealDamage(target, 500);
|
||||
return result.health.value === healthBefore && result.status.kind === statusBefore;
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('property: allies cannot deal damage to each other — returns original target reference', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.integer({ min: 1, max: 5000 }), () => {
|
||||
const faction = Faction.create('guard');
|
||||
const attacker = hero().joinFaction(faction);
|
||||
const target = ally().joinFaction(faction);
|
||||
const result = attacker.dealDamage(target, 500);
|
||||
return result === target;
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('property: non-allies can deal damage normally', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.integer({ min: 1, max: 500 }), () => {
|
||||
const attacker = hero();
|
||||
const target = enemy();
|
||||
const healthBefore = target.health.value;
|
||||
const result = attacker.dealDamage(target, 100);
|
||||
return result.health.value === healthBefore - 100;
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AllyHealAllowed', () => {
|
||||
it('property: allies can heal each other', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.integer({ min: 0, max: 900 }), fc.integer({ min: 1, max: 200 }), () => {
|
||||
const faction = Faction.create('healers');
|
||||
const healer = hero().joinFaction(faction);
|
||||
const ally = Character.createWithHealth({
|
||||
name: 'ally',
|
||||
level: Level.create(1),
|
||||
health: 100,
|
||||
}).joinFaction(faction);
|
||||
const healthBefore = ally.health.value;
|
||||
const result = healer.healAlly(ally, 50);
|
||||
return result.health.value === healthBefore + 50;
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('property: ally healing is capped at max health for level', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.integer({ min: 950, max: 1000 }), fc.integer({ min: 1, max: 200 }), () => {
|
||||
const faction = Faction.create('healers');
|
||||
const healer = hero().joinFaction(faction);
|
||||
const ally = Character.createWithHealth({
|
||||
name: 'ally',
|
||||
level: Level.create(1),
|
||||
health: 980,
|
||||
}).joinFaction(faction);
|
||||
const result = healer.healAlly(ally, 100);
|
||||
return result.health.value === 1000;
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('property: ally healing returns a new Character (not the same reference)', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.integer({ min: 1, max: 200 }), () => {
|
||||
const faction = Faction.create('healers');
|
||||
const healer = hero().joinFaction(faction);
|
||||
const allyChar = ally().joinFaction(faction);
|
||||
const result = healer.healAlly(allyChar, 50);
|
||||
return result !== allyChar;
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NonAllyHealForbidden', () => {
|
||||
it('property: non-allies cannot heal each other — health unchanged', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.integer({ min: 0, max: 1000 }), () => {
|
||||
const healer = hero();
|
||||
const target = enemy();
|
||||
const healthBefore = target.health.value;
|
||||
const result = healer.healAlly(target, 50);
|
||||
return result.health.value === healthBefore;
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('property: non-allies healing returns the original target reference', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.integer({ min: 1, max: 500 }), () => {
|
||||
const healer = hero();
|
||||
const target = enemy();
|
||||
const result = healer.healAlly(target, 50);
|
||||
return result === target;
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DeadCannotBeHealedByAlly', () => {
|
||||
it('property: dead allies cannot be healed — state unchanged', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.integer({ min: 1, max: 500 }), () => {
|
||||
const faction = Faction.create('guard');
|
||||
const healer = hero().joinFaction(faction);
|
||||
const target = ally().joinFaction(faction);
|
||||
// Kill the target with a non-ally attacker
|
||||
const killer = enemy();
|
||||
const deadTarget = killer.dealDamage(target, 10000);
|
||||
const healthBefore = deadTarget.health.value;
|
||||
const statusBefore = deadTarget.status.kind;
|
||||
const result = healer.healAlly(deadTarget, 500);
|
||||
return (
|
||||
result === deadTarget &&
|
||||
result.health.value === healthBefore &&
|
||||
result.status.kind === statusBefore
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user