import fc from 'fast-check'; import { describe, it } from 'vitest'; import { Character } from './characters/Character.ts'; import { Faction } from './factions/Faction.ts'; import { Level } from './value-objects/Level.ts'; import { Damage } from './value-objects/Damage.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, Damage.create(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, Damage.create(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, Damage.create(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, Damage.create(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, Damage.create(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, Damage.create(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 ); }), ); }); }); });