rpg-combat-pi-01/src/factions.spec.ts

432 lines
16 KiB
TypeScript

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