1
0
Fork 0
tsgames/test/common/rpg/faction.test.ts

474 lines
18 KiB
TypeScript

import { describe, it, expect } from 'bun:test';
import { World } from '@common/rpg/core/world';
import { Factions } from '@common/rpg/components/faction';
function world() { return new World(); }
// ── Membership ────────────────────────────────────────────────────────────────
describe('Factions.join / isMember / leave', () => {
it('isMember returns false for a brand-new entity', () => {
const w = world();
expect(Factions.isMember(w.createEntity(), 'guards')).toBeFalse();
});
it('join makes isMember return true', () => {
const w = world();
const e = w.createEntity();
Factions.join(e, 'guards');
expect(Factions.isMember(e, 'guards')).toBeTrue();
});
it('isMember is faction-specific', () => {
const w = world();
const e = w.createEntity();
Factions.join(e, 'guards');
expect(Factions.isMember(e, 'bandits')).toBeFalse();
});
it('join is idempotent — double-joining does not duplicate', () => {
const w = world();
const e = w.createEntity();
Factions.join(e, 'guards');
Factions.join(e, 'guards');
expect(Factions.getFactions(e).filter(f => f === 'guards').length).toBe(1);
});
it('entity can belong to multiple factions simultaneously', () => {
const w = world();
const e = w.createEntity();
Factions.join(e, 'guards');
Factions.join(e, 'merchants');
Factions.join(e, 'mages');
expect(Factions.getFactions(e).sort()).toEqual(['guards', 'mages', 'merchants']);
});
it('leave removes membership', () => {
const w = world();
const e = w.createEntity();
Factions.join(e, 'guards');
Factions.leave(e, 'guards');
expect(Factions.isMember(e, 'guards')).toBeFalse();
});
it('leave is safe on a non-member', () => {
const w = world();
const e = w.createEntity();
expect(() => Factions.leave(e, 'guards')).not.toThrow();
});
it('leave only removes the specified faction', () => {
const w = world();
const e = w.createEntity();
Factions.join(e, 'guards');
Factions.join(e, 'merchants');
Factions.leave(e, 'guards');
expect(Factions.isMember(e, 'guards')).toBeFalse();
expect(Factions.isMember(e, 'merchants')).toBeTrue();
});
it('can re-join after leaving', () => {
const w = world();
const e = w.createEntity();
Factions.join(e, 'guards');
Factions.leave(e, 'guards');
Factions.join(e, 'guards');
expect(Factions.isMember(e, 'guards')).toBeTrue();
});
it('getFactions returns empty array for new entity', () => {
const w = world();
expect(Factions.getFactions(w.createEntity())).toEqual([]);
});
it('getFactions reflects leave', () => {
const w = world();
const e = w.createEntity();
Factions.join(e, 'guards');
Factions.join(e, 'merchants');
Factions.leave(e, 'merchants');
expect(Factions.getFactions(e)).toEqual(['guards']);
});
});
// ── getFaction ─────────────────────────────────────────────────────────────────
describe('Factions.getFaction', () => {
it('creates a faction entity on first call', () => {
const w = world();
const faction = Factions.getFaction(w, 'guards');
expect(faction).toBeDefined();
});
it('returns the same component for the same factionId', () => {
const w = world();
const a = Factions.getFaction(w, 'guards');
const b = Factions.getFaction(w, 'guards');
expect(a).toBe(b);
});
it('returns different components for different factionIds', () => {
const w = world();
const guards = Factions.getFaction(w, 'guards');
const bandits = Factions.getFaction(w, 'bandits');
expect(guards).not.toBe(bandits);
});
});
// ── getReputationOf / setReputationOf / adjustReputationOf ────────────────────
// Observer's personal opinion of a named faction.
describe('Factions.setReputationOf / getReputationOf', () => {
it('getReputationOf returns 0 with no data', () => {
const w = world();
expect(Factions.getReputationOf(w.createEntity(), 'guards')).toBe(0);
});
it('setReputationOf persists the value', () => {
const w = world();
const e = w.createEntity();
Factions.setReputationOf(e, 'guards', 50);
expect(Factions.getReputationOf(e, 'guards')).toBe(50);
});
it('setReputationOf overwrites the previous value without duplication', () => {
const w = world();
const e = w.createEntity();
Factions.setReputationOf(e, 'guards', 50);
Factions.setReputationOf(e, 'guards', 75);
expect(Factions.getReputationOf(e, 'guards')).toBe(75);
});
it('setReputationOf allows negative values', () => {
const w = world();
const e = w.createEntity();
Factions.setReputationOf(e, 'bandits', -100);
expect(Factions.getReputationOf(e, 'bandits')).toBe(-100);
});
it('reputations are independent per faction', () => {
const w = world();
const e = w.createEntity();
Factions.setReputationOf(e, 'guards', 80);
Factions.setReputationOf(e, 'bandits', -20);
expect(Factions.getReputationOf(e, 'guards')).toBe(80);
expect(Factions.getReputationOf(e, 'bandits')).toBe(-20);
});
it('reputations are independent per observer', () => {
const w = world();
const a = w.createEntity();
const b = w.createEntity();
Factions.setReputationOf(a, 'guards', 60);
Factions.setReputationOf(b, 'guards', 10);
expect(Factions.getReputationOf(a, 'guards')).toBe(60);
expect(Factions.getReputationOf(b, 'guards')).toBe(10);
});
});
describe('Factions.adjustReputationOf', () => {
it('adjusts from 0 by default', () => {
const w = world();
const e = w.createEntity();
Factions.adjustReputationOf(e, 'guards', 20);
expect(Factions.getReputationOf(e, 'guards')).toBe(20);
});
it('accumulates positive adjustments', () => {
const w = world();
const e = w.createEntity();
Factions.adjustReputationOf(e, 'guards', 20);
Factions.adjustReputationOf(e, 'guards', 15);
expect(Factions.getReputationOf(e, 'guards')).toBe(35);
});
it('accumulates negative adjustments', () => {
const w = world();
const e = w.createEntity();
Factions.adjustReputationOf(e, 'guards', 20);
Factions.adjustReputationOf(e, 'guards', -5);
expect(Factions.getReputationOf(e, 'guards')).toBe(15);
});
it('can drive reputation below zero', () => {
const w = world();
const e = w.createEntity();
Factions.setReputationOf(e, 'guards', 5);
Factions.adjustReputationOf(e, 'guards', -10);
expect(Factions.getReputationOf(e, 'guards')).toBe(-5);
});
it('adjustments are faction-specific', () => {
const w = world();
const e = w.createEntity();
Factions.adjustReputationOf(e, 'guards', 30);
Factions.adjustReputationOf(e, 'bandits', -10);
expect(Factions.getReputationOf(e, 'guards')).toBe(30);
expect(Factions.getReputationOf(e, 'bandits')).toBe(-10);
});
});
// Cross-faction reputation aggregation: observer inherits their own faction's opinion.
describe('Factions.getReputationOf — cross-faction aggregation', () => {
it("observer inherits their faction's reputation toward another faction", () => {
const w = world();
const observer = w.createEntity();
const guardsFaction = Factions.getFaction(w, 'guards').entity;
Factions.join(observer, 'guards');
Factions.setReputationOf(guardsFaction, 'bandits', -50);
// observer personally has 0, but via guards membership inherits -50
expect(Factions.getReputationOf(observer, 'bandits')).toBe(-50);
});
it('personal and inherited reputations are summed', () => {
const w = world();
const observer = w.createEntity();
const guardsFaction = Factions.getFaction(w, 'guards').entity;
Factions.join(observer, 'guards');
Factions.setReputationOf(observer, 'bandits', 20);
Factions.setReputationOf(guardsFaction, 'bandits', 30);
expect(Factions.getReputationOf(observer, 'bandits')).toBe(50);
});
it('cross-faction contributions from multiple memberships are all summed', () => {
const w = world();
const observer = w.createEntity();
const guardsFaction = Factions.getFaction(w, 'guards').entity;
const merchantsFaction = Factions.getFaction(w, 'merchants').entity;
Factions.join(observer, 'guards');
Factions.join(observer, 'merchants');
Factions.setReputationOf(guardsFaction, 'bandits', -40);
Factions.setReputationOf(merchantsFaction, 'bandits', 10);
expect(Factions.getReputationOf(observer, 'bandits')).toBe(-30);
});
it('skipAggregation=true returns only personal reputation', () => {
const w = world();
const observer = w.createEntity();
const guardsFaction = Factions.getFaction(w, 'guards').entity;
Factions.join(observer, 'guards');
Factions.setReputationOf(observer, 'bandits', 10);
Factions.setReputationOf(guardsFaction, 'bandits', 50);
expect(Factions.getReputationOf(observer, 'bandits', true)).toBe(10);
});
});
// ── getReputationIn / setReputationIn / adjustReputationIn ────────────────────
// A named faction's opinion of a target entity.
describe('Factions.setReputationIn / getReputationIn', () => {
it('getReputationIn returns 0 with no data', () => {
const w = world();
expect(Factions.getReputationIn(w.createEntity(), 'guards')).toBe(0);
});
it('setReputationIn persists the value', () => {
const w = world();
const target = w.createEntity();
Factions.setReputationIn(target, 'guards', 40);
expect(Factions.getReputationIn(target, 'guards')).toBe(40);
});
it('setReputationIn overwrites previous value', () => {
const w = world();
const target = w.createEntity();
Factions.setReputationIn(target, 'guards', 40);
Factions.setReputationIn(target, 'guards', 80);
expect(Factions.getReputationIn(target, 'guards')).toBe(80);
});
it('setReputationIn allows negative values', () => {
const w = world();
const target = w.createEntity();
Factions.setReputationIn(target, 'bandits', -60);
expect(Factions.getReputationIn(target, 'bandits')).toBe(-60);
});
it('opinions are independent per faction', () => {
const w = world();
const target = w.createEntity();
Factions.setReputationIn(target, 'guards', 50);
Factions.setReputationIn(target, 'bandits', -30);
expect(Factions.getReputationIn(target, 'guards')).toBe(50);
expect(Factions.getReputationIn(target, 'bandits')).toBe(-30);
});
it('opinions are independent per target', () => {
const w = world();
const t1 = w.createEntity();
const t2 = w.createEntity();
Factions.setReputationIn(t1, 'guards', 70);
Factions.setReputationIn(t2, 'guards', 20);
expect(Factions.getReputationIn(t1, 'guards')).toBe(70);
expect(Factions.getReputationIn(t2, 'guards')).toBe(20);
});
});
describe('Factions.adjustReputationIn', () => {
it('adjusts from 0 by default', () => {
const w = world();
const target = w.createEntity();
Factions.adjustReputationIn(target, 'guards', 25);
expect(Factions.getReputationIn(target, 'guards')).toBe(25);
});
it('accumulates correctly', () => {
const w = world();
const target = w.createEntity();
Factions.adjustReputationIn(target, 'guards', 25);
Factions.adjustReputationIn(target, 'guards', -10);
expect(Factions.getReputationIn(target, 'guards')).toBe(15);
});
});
describe('Factions.getReputationIn — faction-entity target routing', () => {
it("when target is a faction entity, returns the observing faction's opinion of that faction", () => {
const w = world();
const guardsFaction = Factions.getFaction(w, 'guards').entity;
const banditsFaction = Factions.getFaction(w, 'bandits').entity;
Factions.setReputationOf(guardsFaction, 'bandits', -80);
// getReputationIn(banditsFaction, 'guards') = "how do guards see the bandits faction?"
// routes to getReputationOf(guardsFaction, 'bandits')
expect(Factions.getReputationIn(banditsFaction, 'guards')).toBe(-80);
});
});
describe('Factions.getReputationIn — cross-faction aggregation', () => {
it("faction's opinion of a target aggregates across target's faction memberships", () => {
const w = world();
const target = w.createEntity();
const guardsFaction = Factions.getFaction(w, 'guards').entity;
Factions.join(target, 'merchants');
Factions.setReputationOf(guardsFaction, 'merchants', 35);
// guards look at target who is in merchants — cross-faction adds 35
expect(Factions.getReputationIn(target, 'guards')).toBe(35);
});
it('personal and cross-faction contributions are summed', () => {
const w = world();
const target = w.createEntity();
const guardsFaction = Factions.getFaction(w, 'guards').entity;
Factions.join(target, 'merchants');
Factions.setReputationIn(target, 'guards', 10);
Factions.setReputationOf(guardsFaction, 'merchants', 25);
expect(Factions.getReputationIn(target, 'guards')).toBe(35);
});
});
// ── getReputation (full observer → target resolution) ─────────────────────────
describe('Factions.getReputation', () => {
it('returns 0 for two entities with no factions or reputation', () => {
const w = world();
expect(Factions.getReputation(w.createEntity(), w.createEntity())).toBe(0);
});
it('when target is a faction entity, returns observer rep for that faction', () => {
const w = world();
const observer = w.createEntity();
const guardsFaction = Factions.getFaction(w, 'guards').entity;
Factions.setReputationOf(observer, 'guards', 55);
expect(Factions.getReputation(observer, guardsFaction)).toBe(55);
});
it('sums observer rep across all factions target belongs to', () => {
const w = world();
const observer = w.createEntity();
const target = w.createEntity();
Factions.join(target, 'guards');
Factions.join(target, 'merchants');
Factions.setReputationOf(observer, 'guards', 30);
Factions.setReputationOf(observer, 'merchants', 20);
// target is in guards+merchants, observer's rep with each is summed
expect(Factions.getReputation(observer, target)).toBeGreaterThanOrEqual(50);
});
it("observer's faction membership contributes toward target's faction opinion", () => {
const w = world();
const observer = w.createEntity();
const target = w.createEntity();
const guardsFaction = Factions.getFaction(w, 'guards').entity;
Factions.join(observer, 'guards');
Factions.join(target, 'bandits');
Factions.setReputationOf(guardsFaction, 'bandits', -40);
// guards hate bandits; observer (a guard) sees target (a bandit) more negatively
expect(Factions.getReputation(observer, target)).toBeLessThan(0);
});
it('personal opinion is included alongside cross-faction aggregation', () => {
const w = world();
const observer = w.createEntity();
const target = w.createEntity();
Factions.join(target, 'guards');
Factions.setReputationOf(observer, 'guards', 40);
expect(Factions.getReputation(observer, target)).toBe(40);
});
it('symmetric scenario: both sides in factions with mutual opinions', () => {
const w = world();
const hero = w.createEntity();
const villain = w.createEntity();
const heroFaction = Factions.getFaction(w, 'heroes').entity;
const villainFaction = Factions.getFaction(w, 'villains').entity;
Factions.join(hero, 'heroes');
Factions.join(villain, 'villains');
// heroes hate villains
Factions.setReputationOf(heroFaction, 'villains', -100);
// villains hate heroes
Factions.setReputationOf(villainFaction, 'heroes', -100);
const heroSeesVillain = Factions.getReputation(hero, villain);
const villainSeesHero = Factions.getReputation(villain, hero);
expect(heroSeesVillain).toBeLessThan(0);
expect(villainSeesHero).toBeLessThan(0);
});
it('neutral factions produce no reputation effect', () => {
const w = world();
const a = w.createEntity();
const b = w.createEntity();
Factions.join(a, 'merchants');
Factions.join(b, 'farmers');
// no reputation set between merchants and farmers
expect(Factions.getReputation(a, b)).toBe(0);
});
it('target with no faction memberships yields only observer-set personal rep (0 if none)', () => {
const w = world();
const observer = w.createEntity();
const target = w.createEntity();
Factions.join(observer, 'guards');
// target has no factions
expect(Factions.getReputation(observer, target)).toBe(0);
});
});