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 { Level } from './Level.ts';
|
||||||
import type { Status } from './Status.ts';
|
import type { Status } from './Status.ts';
|
||||||
import { StatusAlive, StatusDead } 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 { Faction } from './Faction.ts';
|
||||||
import type { MagicalWeapon } from './MagicalWeapon.ts';
|
import type { MagicalWeapon } from './MagicalWeapon.ts';
|
||||||
import type { HealingObject } from './HealingObject.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. */
|
/** Create a new character with default health (1000) and alive status. */
|
||||||
static create({ name, level }: CharacterCtor): Character {
|
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);
|
return new Character(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create a character with a specific health value (for testing). */
|
/** Create a character with a specific health value (for testing). */
|
||||||
static createWithHealth({ name, level, health }: CharacterCtorWithHealth): Character {
|
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);
|
return new Character(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -59,7 +71,13 @@ export class Character {
|
|||||||
health,
|
health,
|
||||||
status,
|
status,
|
||||||
}: CharacterCtorWithHealthAndStatus): Character {
|
}: 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);
|
return new Character(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -83,6 +101,77 @@ export class Character {
|
|||||||
return this.#state.factions;
|
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.
|
* Deal damage to another character. Returns a new Character with updated state.
|
||||||
* Does not mutate the attacker or the original target reference.
|
* Does not mutate the attacker or the original target reference.
|
||||||
@ -90,6 +179,8 @@ export class Character {
|
|||||||
dealDamage(target: Character, damage: number): Character {
|
dealDamage(target: Character, damage: number): Character {
|
||||||
// Self-damage is forbidden — use reference equality, not name
|
// Self-damage is forbidden — use reference equality, not name
|
||||||
if (this === target) return target;
|
if (this === target) return target;
|
||||||
|
// Allies cannot deal damage to each other
|
||||||
|
if (this.isAllyOf(target)) return target;
|
||||||
// Dead characters cannot take damage
|
// Dead characters cannot take damage
|
||||||
if (target.status.kind === 'dead') return target;
|
if (target.status.kind === 'dead') return target;
|
||||||
// Negative damage is invalid
|
// Negative damage is invalid
|
||||||
@ -107,9 +198,13 @@ export class Character {
|
|||||||
// Reduce health by the (possibly modified) damage amount
|
// Reduce health by the (possibly modified) damage amount
|
||||||
const newHealth = target.health.sub(actualDamage);
|
const newHealth = target.health.sub(actualDamage);
|
||||||
const newStatus = newHealth.value === 0 ? StatusDead : StatusAlive;
|
const newStatus = newHealth.value === 0 ? StatusDead : StatusAlive;
|
||||||
return new Character(
|
return new Character({
|
||||||
new CharacterState(target.name, newHealth, newStatus, target.level, target.factions),
|
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
|
// Increase health, capped at level-based maximum
|
||||||
const maxHealth = Level.maxHealthForLevel(this.level.value);
|
const maxHealth = Level.maxHealthForLevel(this.level.value);
|
||||||
const newHealth = this.health.add(amount, maxHealth);
|
const newHealth = this.health.add(amount, maxHealth);
|
||||||
return new Character(
|
return new Character({
|
||||||
new CharacterState(this.name, newHealth, this.status, this.level, this.factions),
|
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