Rework faction system
This commit is contained in:
parent
2a82c13fb4
commit
4b2a16c28f
|
|
@ -57,18 +57,93 @@ export enum Color {
|
|||
export const DEFAULT_FG = Color.WHITE;
|
||||
export const DEFAULT_BG = Color.BLACK;
|
||||
|
||||
export enum Direction {
|
||||
NORTH = 0,
|
||||
EAST = 1,
|
||||
SOUTH = 2,
|
||||
WEST = 3,
|
||||
UP = 4,
|
||||
DOWN = 5,
|
||||
export enum Glyphs {
|
||||
// Pure single-line box drawing
|
||||
SINGLE_HORIZONTAL = '─',
|
||||
SINGLE_VERTICAL = '│',
|
||||
SINGLE_TOP_LEFT = '┌',
|
||||
SINGLE_TOP_RIGHT = '┐',
|
||||
SINGLE_BOTTOM_LEFT = '└',
|
||||
SINGLE_BOTTOM_RIGHT = '┘',
|
||||
SINGLE_TEE_RIGHT = '├',
|
||||
SINGLE_TEE_LEFT = '┤',
|
||||
SINGLE_TEE_DOWN = '┬',
|
||||
SINGLE_TEE_UP = '┴',
|
||||
SINGLE_CROSS = '┼',
|
||||
|
||||
// Pure double-line box drawing
|
||||
DOUBLE_HORIZONTAL = '═',
|
||||
DOUBLE_VERTICAL = '║',
|
||||
DOUBLE_TOP_LEFT = '╔',
|
||||
DOUBLE_TOP_RIGHT = '╗',
|
||||
DOUBLE_BOTTOM_LEFT = '╚',
|
||||
DOUBLE_BOTTOM_RIGHT = '╝',
|
||||
DOUBLE_TEE_RIGHT = '╠',
|
||||
DOUBLE_TEE_LEFT = '╣',
|
||||
DOUBLE_TEE_DOWN = '╦',
|
||||
DOUBLE_TEE_UP = '╩',
|
||||
DOUBLE_CROSS = '╬',
|
||||
|
||||
// Mixed single/double combinations (_DBL_H = double horizontal, _DBL_V = double vertical)
|
||||
MIX_TOP_LEFT_DBL_H = '╒',
|
||||
MIX_TOP_LEFT_DBL_V = '╓',
|
||||
MIX_TOP_RIGHT_DBL_H = '╕',
|
||||
MIX_TOP_RIGHT_DBL_V = '╖',
|
||||
MIX_BOTTOM_LEFT_DBL_H = '╘',
|
||||
MIX_BOTTOM_LEFT_DBL_V = '╙',
|
||||
MIX_BOTTOM_RIGHT_DBL_H = '╛',
|
||||
MIX_BOTTOM_RIGHT_DBL_V = '╜',
|
||||
MIX_TEE_RIGHT_DBL_H = '╞',
|
||||
MIX_TEE_RIGHT_DBL_V = '╟',
|
||||
MIX_TEE_LEFT_DBL_H = '╡',
|
||||
MIX_TEE_LEFT_DBL_V = '╢',
|
||||
MIX_TEE_DOWN_DBL_H = '╤',
|
||||
MIX_TEE_DOWN_DBL_V = '╥',
|
||||
MIX_TEE_UP_DBL_H = '╧',
|
||||
MIX_TEE_UP_DBL_V = '╨',
|
||||
MIX_CROSS_DBL_H = '╪',
|
||||
MIX_CROSS_DBL_V = '╫',
|
||||
|
||||
// Block elements
|
||||
SPACE = ' ',
|
||||
LIGHT_SHADE = '░',
|
||||
MEDIUM_SHADE = '▒',
|
||||
DARK_SHADE = '▓',
|
||||
FULL_BLOCK = '█',
|
||||
UPPER_HALF_BLOCK = '▀',
|
||||
LOWER_HALF_BLOCK = '▄',
|
||||
LEFT_HALF_BLOCK = '▌',
|
||||
RIGHT_HALF_BLOCK = '▐',
|
||||
|
||||
// Arrows
|
||||
ARROW_LEFT = '←',
|
||||
ARROW_UP = '↑',
|
||||
ARROW_RIGHT = '→',
|
||||
ARROW_DOWN = '↓',
|
||||
ARROW_HORIZONTAL = '↔',
|
||||
ARROW_VERTICAL = '↕',
|
||||
TRIANGLE_UP = '▲',
|
||||
TRIANGLE_RIGHT = '►',
|
||||
TRIANGLE_DOWN = '▼',
|
||||
TRIANGLE_LEFT = '◄',
|
||||
|
||||
// Symbols
|
||||
SMILEY = '☺',
|
||||
SMILEY_FILLED = '☻',
|
||||
FEMALE = '♀',
|
||||
MALE = '♂',
|
||||
SPADE = '♠',
|
||||
CLUB = '♣',
|
||||
HEART = '♥',
|
||||
DIAMOND = '♦',
|
||||
}
|
||||
|
||||
export const isVertical = (char: string) => char === '│';
|
||||
export const isHorizontal = (char: string) => char === '─';
|
||||
export const isCorner = (char: string) => '┌┐└┘'.includes(char);
|
||||
export const isVertical = (char: string) => char === Glyphs.SINGLE_VERTICAL || char === Glyphs.DOUBLE_VERTICAL;
|
||||
export const isHorizontal = (char: string) => char === Glyphs.SINGLE_HORIZONTAL || char === Glyphs.DOUBLE_HORIZONTAL;
|
||||
export const isCorner = (char: string) => [
|
||||
Glyphs.SINGLE_TOP_LEFT, Glyphs.SINGLE_TOP_RIGHT, Glyphs.SINGLE_BOTTOM_LEFT, Glyphs.SINGLE_BOTTOM_RIGHT,
|
||||
Glyphs.DOUBLE_TOP_LEFT, Glyphs.DOUBLE_TOP_RIGHT, Glyphs.DOUBLE_BOTTOM_LEFT, Glyphs.DOUBLE_BOTTOM_RIGHT,
|
||||
].includes(char as Glyphs);
|
||||
|
||||
interface BoxOptions {
|
||||
vertical?: string;
|
||||
|
|
@ -226,7 +301,7 @@ export class TextDisplay {
|
|||
}
|
||||
}
|
||||
|
||||
setChar(x: number, y: number, char: Char = '█') {
|
||||
setChar(x: number, y: number, char: Char = Glyphs.FULL_BLOCK) {
|
||||
this.setCharRaw(x | 0, y | 0, ...parseChar(char));
|
||||
}
|
||||
|
||||
|
|
@ -313,7 +388,7 @@ export class TextDisplay {
|
|||
}
|
||||
}
|
||||
|
||||
drawVLine(x: number, y1: number, y2: number, char: Char = '│') {
|
||||
drawVLine(x: number, y1: number, y2: number, char: Char = Glyphs.SINGLE_VERTICAL) {
|
||||
x = x | 0; y1 = y1 | 0; y2 = y2 | 0;
|
||||
if (y2 < y1) { const t = y2; y2 = y1; y1 = t; }
|
||||
|
||||
|
|
@ -327,7 +402,7 @@ export class TextDisplay {
|
|||
}
|
||||
}
|
||||
|
||||
drawHLine(x1: number, x2: number, y: number, char: Char = '─') {
|
||||
drawHLine(x1: number, x2: number, y: number, char: Char = Glyphs.SINGLE_HORIZONTAL) {
|
||||
x1 = x1 | 0; x2 = x2 | 0; y = y | 0;
|
||||
if (x2 < x1) { const t = x2; x2 = x1; x1 = t; }
|
||||
x1 = Math.max(this.clipLeft, x1);
|
||||
|
|
@ -343,12 +418,12 @@ export class TextDisplay {
|
|||
drawBox(x: number, y: number, width: number, height: number, options: BoxOptions = {}) {
|
||||
x = x | 0; y = y | 0;
|
||||
const {
|
||||
vertical = '│',
|
||||
horizontal = '─',
|
||||
topLeft = '┌',
|
||||
topRight = '┐',
|
||||
bottomLeft = '└',
|
||||
bottomRight = '┘',
|
||||
vertical = Glyphs.SINGLE_VERTICAL,
|
||||
horizontal = Glyphs.SINGLE_HORIZONTAL,
|
||||
topLeft = Glyphs.SINGLE_TOP_LEFT,
|
||||
topRight = Glyphs.SINGLE_TOP_RIGHT,
|
||||
bottomLeft = Glyphs.SINGLE_BOTTOM_LEFT,
|
||||
bottomRight = Glyphs.SINGLE_BOTTOM_RIGHT,
|
||||
fg = DEFAULT_FG,
|
||||
bg = DEFAULT_BG,
|
||||
fill,
|
||||
|
|
@ -389,14 +464,14 @@ export class TextDisplay {
|
|||
this.drawString(text, x + 1, y + 1, fg, bg);
|
||||
}
|
||||
|
||||
fillBox(x: number, y: number, width: number, height: number, char: Char = '█') {
|
||||
fillBox(x: number, y: number, width: number, height: number, char: Char = Glyphs.FULL_BLOCK) {
|
||||
x = x | 0; y = y | 0;
|
||||
for (let i = y; i < y + height; i++) {
|
||||
this.drawHLine(x, x + width - 1, i, char);
|
||||
}
|
||||
}
|
||||
|
||||
drawLine(fromX: number, fromY: number, toX: number, toY: number, char: Char = '█') {
|
||||
drawLine(fromX: number, fromY: number, toX: number, toY: number, char: Char = Glyphs.FULL_BLOCK) {
|
||||
const [ch, fg, bg] = parseChar(char);
|
||||
const options: BresenhamLineOptions = {
|
||||
minX: this.clipLeft,
|
||||
|
|
@ -424,11 +499,11 @@ export class TextDisplay {
|
|||
}
|
||||
}
|
||||
|
||||
drawCircle(cx: number, cy: number, radius: number, char: Char = '█') {
|
||||
drawCircle(cx: number, cy: number, radius: number, char: Char = Glyphs.FULL_BLOCK) {
|
||||
this.#circle(cx, cy, radius, char, false);
|
||||
}
|
||||
|
||||
fillCircle(cx: number, cy: number, radius: number, char: Char = '█') {
|
||||
fillCircle(cx: number, cy: number, radius: number, char: Char = Glyphs.FULL_BLOCK) {
|
||||
this.#circle(cx, cy, radius, char, true);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,22 @@
|
|||
import { Component, type Entity } from "../core/world";
|
||||
import { Component, Entity, World } from "../core/world";
|
||||
import type { RPGVariables } from "../types";
|
||||
import { action, component } from "../utils/decorators";
|
||||
|
||||
// ── Faction ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@component
|
||||
class Faction extends Component<{ factionId: string }> {
|
||||
constructor(factionId: string) {
|
||||
super({ factionId });
|
||||
}
|
||||
|
||||
get factionId(): string { return this.state.factionId; }
|
||||
}
|
||||
|
||||
// ── FactionMember ─────────────────────────────────────────────────────────────
|
||||
|
||||
@component
|
||||
export class FactionMember extends Component<{ factionId: string }> {
|
||||
class FactionMember extends Component<{ factionId: string }> {
|
||||
constructor(factionId: string) {
|
||||
super({ factionId });
|
||||
}
|
||||
|
|
@ -19,10 +30,35 @@ export class FactionMember extends Component<{ factionId: string }> {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Reputation ────────────────────────────────────────────────────────────────
|
||||
// ── ReputationOf ──────────────────────────────────────────────────────────────
|
||||
// Observer's opinion of a faction.
|
||||
|
||||
@component
|
||||
export class Reputation extends Component<{ factionId: string; score: number }> {
|
||||
class ReputationOf extends Component<{ factionId: string; score: number }> {
|
||||
constructor(factionId: string, score = 0) {
|
||||
super({ factionId, score });
|
||||
}
|
||||
|
||||
get score(): number { return this.state.score; }
|
||||
|
||||
get factionId(): string { return this.state.factionId; }
|
||||
|
||||
adjust(delta: number): void {
|
||||
this.state.score += delta;
|
||||
}
|
||||
|
||||
override getVariables(): RPGVariables {
|
||||
return {
|
||||
[this.factionId]: this.score,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── ReputationIn ──────────────────────────────────────────────────────────────
|
||||
// A faction's opinion of a specific entity.
|
||||
|
||||
@component
|
||||
class ReputationIn extends Component<{ factionId: string; score: number }> {
|
||||
constructor(factionId: string, score = 0) {
|
||||
super({ factionId, score });
|
||||
}
|
||||
|
|
@ -45,7 +81,7 @@ export class Reputation extends Component<{ factionId: string; score: number }>
|
|||
//── FactionManager ────────────────────────────────────────────────────────────
|
||||
|
||||
@component
|
||||
export class FactionManager extends Component<{}> {
|
||||
class FactionManager extends Component<{}> {
|
||||
constructor() { super({}); }
|
||||
|
||||
@action
|
||||
|
|
@ -59,13 +95,23 @@ export class FactionManager extends Component<{}> {
|
|||
}
|
||||
|
||||
@action
|
||||
setReputation({ factionId, value }: { factionId: string, value: number }): void {
|
||||
Factions.setReputation(this.entity, factionId, value);
|
||||
setReputationOf({ factionId, value }: { factionId: string, value: number }): void {
|
||||
Factions.setReputationOf(this.entity, factionId, value);
|
||||
}
|
||||
|
||||
@action
|
||||
adjustReputation({ factionId, value }: { factionId: string, value: number }): void {
|
||||
Factions.adjustReputation(this.entity, factionId, value);
|
||||
adjustReputationOf({ factionId, value }: { factionId: string, value: number }): void {
|
||||
Factions.adjustReputationOf(this.entity, factionId, value);
|
||||
}
|
||||
|
||||
@action
|
||||
setReputationIn({ factionId, value }: { factionId: string, value: number }): void {
|
||||
Factions.setReputationIn(this.entity, factionId, value);
|
||||
}
|
||||
|
||||
@action
|
||||
adjustReputationIn({ factionId, value }: { factionId: string, value: number }): void {
|
||||
Factions.adjustReputationIn(this.entity, factionId, value);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -77,10 +123,22 @@ export namespace Factions {
|
|||
entity.add(new FactionManager());
|
||||
}
|
||||
}
|
||||
|
||||
export function getFaction(world: World, factionId: string): Faction {
|
||||
const existing = world.findComponent(Faction, (c) => c.factionId === factionId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const factionEntity = world.createEntity();
|
||||
factionEntity.add(new FactionManager());
|
||||
return factionEntity.add(new Faction(factionId));
|
||||
}
|
||||
|
||||
export function join(entity: Entity, factionId: string): void {
|
||||
addFactionManager(entity);
|
||||
const existing = entity.get(FactionMember, (c) => c.factionId === factionId);
|
||||
if (!existing) {
|
||||
if (!entity.has(FactionMember, (c) => c.factionId === factionId)) {
|
||||
getFaction(entity.world, factionId);
|
||||
entity.add(new FactionMember(factionId));
|
||||
}
|
||||
}
|
||||
|
|
@ -95,38 +153,152 @@ export namespace Factions {
|
|||
}
|
||||
|
||||
export function getFactions(entity: Entity): string[] {
|
||||
return entity.getAll(FactionMember).map(m => m.factionId);
|
||||
return [
|
||||
...entity.getAll(FactionMember),
|
||||
...entity.getAll(Faction),
|
||||
].map(m => m.factionId);
|
||||
}
|
||||
|
||||
export function getReputation(entity: Entity, factionId: string): number {
|
||||
return entity.get(Reputation, (c) => c.factionId === factionId)?.score ?? 0;
|
||||
// How observer sees a faction. Aggregates cross-faction scores when observer
|
||||
// is not itself a faction entity.
|
||||
export function getReputationOf(observer: Entity | Component<any>, factionId: string, skipAggregation = false): number {
|
||||
if (!(observer instanceof Entity)) {
|
||||
observer = observer.entity;
|
||||
}
|
||||
const personal = observer.get(ReputationOf, (c) => c.factionId === factionId)?.score ?? 0;
|
||||
|
||||
if (observer.has(Faction) || skipAggregation) {
|
||||
return personal;
|
||||
}
|
||||
|
||||
export function setReputation(entity: Entity, factionId: string, value: number): void {
|
||||
addFactionManager(entity);
|
||||
const existing = entity.get(Reputation, (c) => c.factionId === factionId);
|
||||
const crossFaction = getFactions(observer)
|
||||
.map(myFactionId => getFaction(observer.world, myFactionId).entity.get(ReputationOf, (c) => c.factionId === factionId)?.score ?? 0)
|
||||
.reduce((a, b) => a + b, 0);
|
||||
|
||||
return personal + crossFaction;
|
||||
}
|
||||
|
||||
// How a specific faction sees target. Aggregates across target's faction memberships.
|
||||
// Faction-faction reputation is canonical on ReputationOf of the observer faction.
|
||||
export function getReputationIn(target: Entity | Component<any>, factionId: string, skipAggregation = false): number {
|
||||
if (!(target instanceof Entity)) {
|
||||
target = target.entity;
|
||||
}
|
||||
const targetFaction = target.get(Faction);
|
||||
const observer = getFaction(target.world, factionId).entity;
|
||||
|
||||
if (targetFaction) {
|
||||
const targetFactionId = targetFaction.factionId;
|
||||
return getReputationOf(observer, targetFactionId);
|
||||
}
|
||||
|
||||
const personal = target.get(ReputationIn, (c) => c.factionId === factionId)?.score ?? 0;
|
||||
|
||||
if (skipAggregation) {
|
||||
return personal;
|
||||
}
|
||||
|
||||
const crossFaction = getFactions(target)
|
||||
.map(memberFactionId => getReputationOf(observer, memberFactionId))
|
||||
.reduce((a, b) => a + b, 0);
|
||||
|
||||
return personal + crossFaction;
|
||||
}
|
||||
|
||||
// How observer sees target. Routes to getReputationOf when target is a faction
|
||||
// entity; otherwise sums observer's opinion of each faction target belongs to.
|
||||
export function getReputation(observer: Entity | Component<any>, target: Entity | Component<any>): number {
|
||||
if (!(observer instanceof Entity)) {
|
||||
observer = observer.entity;
|
||||
}
|
||||
if (!(target instanceof Entity)) {
|
||||
target = target.entity;
|
||||
}
|
||||
const targetFaction = target.get(Faction);
|
||||
|
||||
if (targetFaction) {
|
||||
return getReputationOf(observer, targetFaction.factionId);
|
||||
}
|
||||
|
||||
return [
|
||||
...getFactions(target).map(fid => getReputationOf(observer, fid)),
|
||||
...getFactions(observer).map(fid => getReputationIn(target, fid, true)),
|
||||
].reduce((a, b) => a + b, 0);
|
||||
}
|
||||
|
||||
export function setReputationOf(observer: Entity | Component<any>, factionId: string, value: number): void {
|
||||
if (!(observer instanceof Entity)) {
|
||||
observer = observer.entity;
|
||||
}
|
||||
addFactionManager(observer);
|
||||
getFaction(observer.world, factionId);
|
||||
|
||||
const existing = observer.get(ReputationOf, (c) => c.factionId === factionId);
|
||||
if (existing) {
|
||||
existing.state.score = value;
|
||||
} else {
|
||||
entity.add(new Reputation(factionId, value));
|
||||
observer.add(new ReputationOf(factionId, value));
|
||||
}
|
||||
}
|
||||
|
||||
export function adjustReputation(entity: Entity, factionId: string, delta: number): void {
|
||||
addFactionManager(entity);
|
||||
const existing = entity.get(Reputation, (c) => c.factionId === factionId);
|
||||
export function adjustReputationOf(observer: Entity | Component<any>, factionId: string, delta: number): void {
|
||||
if (!(observer instanceof Entity)) {
|
||||
observer = observer.entity;
|
||||
}
|
||||
addFactionManager(observer);
|
||||
getFaction(observer.world, factionId);
|
||||
|
||||
const existing = observer.get(ReputationOf, (c) => c.factionId === factionId);
|
||||
if (existing) {
|
||||
existing.adjust(delta);
|
||||
} else {
|
||||
entity.add(new Reputation(factionId, delta));
|
||||
observer.add(new ReputationOf(factionId, delta));
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the observer's minimum reputation across all of target's factions.
|
||||
* Returns null if target has no FactionMember components. */
|
||||
export function getReputationBetween(observer: Entity, target: Entity): number | null {
|
||||
const factions = getFactions(target);
|
||||
if (factions.length === 0) return null;
|
||||
return Math.min(...factions.map(f => getReputation(observer, f)));
|
||||
export function setReputationIn(target: Entity | Component<any>, factionId: string, value: number): void {
|
||||
if (!(target instanceof Entity)) {
|
||||
target = target.entity;
|
||||
}
|
||||
const targetFaction = target.get(Faction);
|
||||
if (targetFaction) {
|
||||
const targetFactionId = targetFaction.factionId;
|
||||
const observer = getFaction(target.world, factionId).entity;
|
||||
setReputationOf(observer, targetFactionId, value);
|
||||
return;
|
||||
}
|
||||
|
||||
addFactionManager(target);
|
||||
getFaction(target.world, factionId);
|
||||
|
||||
const existing = target.get(ReputationIn, (c) => c.factionId === factionId);
|
||||
if (existing) {
|
||||
existing.state.score = value;
|
||||
} else {
|
||||
target.add(new ReputationIn(factionId, value));
|
||||
}
|
||||
}
|
||||
|
||||
export function adjustReputationIn(target: Entity | Component<any>, factionId: string, delta: number): void {
|
||||
if (!(target instanceof Entity)) {
|
||||
target = target.entity;
|
||||
}
|
||||
const targetFaction = target.get(Faction);
|
||||
if (targetFaction) {
|
||||
const targetFactionId = targetFaction.factionId;
|
||||
const observer = getFaction(target.world, factionId).entity;
|
||||
adjustReputationOf(observer, targetFactionId, delta);
|
||||
return;
|
||||
}
|
||||
|
||||
addFactionManager(target);
|
||||
getFaction(target.world, factionId);
|
||||
|
||||
const existing = target.get(ReputationIn, (c) => c.factionId === factionId);
|
||||
if (existing) {
|
||||
existing.adjust(delta);
|
||||
} else {
|
||||
target.add(new ReputationIn(factionId, delta));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,11 +62,3 @@ export const createViewport = (world: World, viewportData: ViewportData) => {
|
|||
|
||||
return viewport;
|
||||
}
|
||||
|
||||
export const getViewport = (world: World): Viewport | null => {
|
||||
for (const [, , viewport] of world.query(Viewport)) {
|
||||
return viewport;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -344,6 +344,13 @@ export class World {
|
|||
return target;
|
||||
}
|
||||
|
||||
findComponent<T extends Component<any>>(ctor: Class<T>, filter?: ComponentFilter<T>): T | undefined {
|
||||
for (const [, , c] of this.query(ctor)) {
|
||||
if (!filter || filter(c)) return c;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
query<T extends Component<any>>(ctor: Class<T>): Generator<[Entity, string, T]>;
|
||||
query<A extends Component<any>, B extends Component<any>>(ctorA: Class<A>, ctorB: Class<B>): Generator<[Entity, A, B]>;
|
||||
query<A extends Component<any>, B extends Component<any>, C extends Component<any>>(ctorA: Class<A>, ctorB: Class<B>, ctorC: Class<C>): Generator<[Entity, A, B, C]>;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { TextRegion, TextDisplay } from "@common/display/text";
|
||||
import { Position } from "@common/rpg/components/position";
|
||||
import { getViewport } from "@common/rpg/components/render/viewport";
|
||||
import { Viewport } from "@common/rpg/components/render/viewport";
|
||||
import { Hidden, Sprite } from "@common/rpg/components/sprite";
|
||||
import { System, World } from "@common/rpg/core/world";
|
||||
import { Resources } from "@common/rpg/utils/resources";
|
||||
|
|
@ -16,7 +16,7 @@ export class TextDisplaySystem extends System {
|
|||
}
|
||||
|
||||
override update(world: World) {
|
||||
const viewport = getViewport(world);
|
||||
const viewport = world.findComponent(Viewport);
|
||||
const offset = viewport ? viewport.screenToWorld({ x: 0, y: 0 }) : undefined;
|
||||
const viewportClipRect = viewport ? {
|
||||
x: viewport.screenX,
|
||||
|
|
|
|||
|
|
@ -1,16 +1,19 @@
|
|||
import { Color, TextRegion } from "@common/display/text";
|
||||
import { Color, Glyphs, isCorner, isHorizontal, isVertical, TextRegion } from "@common/display/text";
|
||||
import { gameLoop } from "@common/game";
|
||||
import type { Point } from "@common/geometry";
|
||||
import Input from "@common/input";
|
||||
import { BSP } from "@common/level/bsp";
|
||||
import { bresenhamCircleGen } from "@common/navigation/bresenham";
|
||||
import { SeededRandom } from "@common/random";
|
||||
import { Factions } from "@common/rpg/components/faction";
|
||||
import { getPosition, move, Position } from "@common/rpg/components/position";
|
||||
import { createViewport } from "@common/rpg/components/render/viewport";
|
||||
import { Sprite } from "@common/rpg/components/sprite";
|
||||
import { Serialization } from "@common/rpg/core/serialization";
|
||||
import { World } from "@common/rpg/core/world";
|
||||
import { TextDisplaySystem } from "@common/rpg/systems/render/text";
|
||||
import { Resources } from "@common/rpg/utils/resources";
|
||||
import { resolveVariables } from "@common/rpg/utils/variables";
|
||||
|
||||
const WALL = '#';
|
||||
const PLAYER = '@';
|
||||
|
|
@ -20,8 +23,8 @@ function createMap(world: World, random: SeededRandom) {
|
|||
const mapData = new Array(MAP_SIZE * MAP_SIZE).fill(WALL);
|
||||
|
||||
BSP.generateLevel(MAP_SIZE, MAP_SIZE, (x, y) => { mapData[x + y * MAP_SIZE] = '.'; }, {
|
||||
minWidth: 8,
|
||||
minHeight: 4,
|
||||
minWidth: 10,
|
||||
minHeight: 8,
|
||||
depth: 10,
|
||||
random,
|
||||
});
|
||||
|
|
@ -40,18 +43,29 @@ function createMap(world: World, random: SeededRandom) {
|
|||
|
||||
const mask = world.createEntity('mask');
|
||||
mask.add(new Sprite(Resources.add('mask', new TextRegion(maskDataString))));
|
||||
mask.add(new Position(-MAP_SIZE, -MAP_SIZE, 9));
|
||||
mask.add(new Position(-MAP_SIZE, -MAP_SIZE, 100));
|
||||
|
||||
return { map, mask };
|
||||
}
|
||||
|
||||
function createPlayer(world: World, x = 0, y = 0) {
|
||||
function createPlayer(world: World, x: number, y: number) {
|
||||
const player = world.createEntity('player');
|
||||
player.add(new Position(x, y, 10));
|
||||
player.add(new Sprite(Resources.add('player', new TextRegion(PLAYER, Color.YELLOW))));
|
||||
Factions.join(player, 'players');
|
||||
|
||||
return player;
|
||||
}
|
||||
|
||||
function createEnemy(world: World, x: number, y: number, sprite: string, ...factions: string[]) {
|
||||
const enemy = world.createEntity('enemy_*');
|
||||
enemy.add(new Position(x, y, 9));
|
||||
enemy.add(new Sprite(sprite));
|
||||
factions.forEach(faction => Factions.join(enemy, faction));
|
||||
|
||||
return enemy;
|
||||
}
|
||||
|
||||
function handleMovement() {
|
||||
let dx = 0;
|
||||
let dy = 0;
|
||||
|
|
@ -115,6 +129,8 @@ export default gameLoop(() => {
|
|||
const maskData = Resources.get(TextRegion, mask.get(Sprite)!.image)!;
|
||||
const startCell = random.choice(emptyCells);
|
||||
|
||||
const enemyCell = random.choice(emptyCells.filter(c => c !== startCell));
|
||||
|
||||
const viewport = createViewport(world, {
|
||||
width: display.width,
|
||||
height: display.height,
|
||||
|
|
@ -125,6 +141,17 @@ export default gameLoop(() => {
|
|||
});
|
||||
const player = createPlayer(world, startCell.x, startCell.y);
|
||||
|
||||
Resources.add('goblin', new TextRegion('g', Color.DARK_GREEN));
|
||||
const goblins = Factions.getFaction(world, 'goblins');
|
||||
const monsters = Factions.getFaction(world, 'monsters');
|
||||
|
||||
Factions.setReputationOf(goblins.entity, 'players', -100);
|
||||
Factions.setReputationOf(monsters.entity, 'players', -100);
|
||||
|
||||
Factions.setReputationOf(goblins.entity, 'monsters', 100);
|
||||
Factions.setReputationOf(monsters.entity, 'goblins', 100);
|
||||
|
||||
const enemy = createEnemy(world, enemyCell.x, enemyCell.y, 'goblin', 'goblins', 'monsters');
|
||||
const state = {
|
||||
display,
|
||||
world,
|
||||
|
|
@ -133,9 +160,10 @@ export default gameLoop(() => {
|
|||
random,
|
||||
viewport,
|
||||
player,
|
||||
enemy,
|
||||
isWall: (x: number, y: number): boolean => {
|
||||
const [ch] = state.mapData.get(x, y);
|
||||
return ch === WALL;
|
||||
return ch === WALL || isVertical(ch) || isHorizontal(ch) || isCorner(ch);
|
||||
},
|
||||
updateMask: () => {
|
||||
if (!state.maskDirty) return;
|
||||
|
|
@ -158,6 +186,9 @@ export default gameLoop(() => {
|
|||
}
|
||||
};
|
||||
|
||||
Factions.setReputationIn(player, 'monsters', 5);
|
||||
console.log(Factions.getReputation(enemy, player));
|
||||
|
||||
return state;
|
||||
}, (dt, state) => {
|
||||
const {
|
||||
|
|
|
|||
|
|
@ -1,19 +1,49 @@
|
|||
import { describe, it, expect } from 'bun:test';
|
||||
import { World } from '@common/rpg/core/world';
|
||||
import { Reputation, Factions } from '@common/rpg/components/faction';
|
||||
import { resolveVariables } from '@common/rpg/utils/variables';
|
||||
import { Factions } from '@common/rpg/components/faction';
|
||||
|
||||
function world() { return new World(); }
|
||||
|
||||
describe('FactionMember', () => {
|
||||
it('join() adds membership, isMember() returns true', () => {
|
||||
// ── 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('leave() removes membership, isMember() returns false', () => {
|
||||
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');
|
||||
|
|
@ -21,108 +51,423 @@ describe('FactionMember', () => {
|
|||
expect(Factions.isMember(e, 'guards')).toBeFalse();
|
||||
});
|
||||
|
||||
it('isMember() returns false when entity has no FactionMember', () => {
|
||||
it('leave is safe on a non-member', () => {
|
||||
const w = world();
|
||||
const e = w.createEntity();
|
||||
expect(Factions.isMember(e, 'guards')).toBeFalse();
|
||||
expect(() => Factions.leave(e, 'guards')).not.toThrow();
|
||||
});
|
||||
|
||||
it('getFactions() returns all factionIds', () => {
|
||||
it('leave only removes the specified faction', () => {
|
||||
const w = world();
|
||||
const e = w.createEntity();
|
||||
Factions.join(e, 'guards');
|
||||
Factions.join(e, 'merchants');
|
||||
expect(Factions.getFactions(e).sort()).toEqual(['guards', 'merchants']);
|
||||
Factions.leave(e, 'guards');
|
||||
expect(Factions.isMember(e, 'guards')).toBeFalse();
|
||||
expect(Factions.isMember(e, 'merchants')).toBeTrue();
|
||||
});
|
||||
|
||||
it('member variable resolves on world', () => {
|
||||
it('can re-join after leaving', () => {
|
||||
const w = world();
|
||||
const e = w.createEntity('player');
|
||||
const e = w.createEntity();
|
||||
Factions.join(e, 'guards');
|
||||
expect(resolveVariables(w)['player.FactionMember.guards']).toBeTrue();
|
||||
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']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reputation', () => {
|
||||
it('getReputation() returns 0 when no component', () => {
|
||||
// ── getFaction ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Factions.getFaction', () => {
|
||||
it('creates a faction entity on first call', () => {
|
||||
const w = world();
|
||||
const e = w.createEntity();
|
||||
expect(Factions.getReputation(e, 'guards')).toBe(0);
|
||||
const faction = Factions.getFaction(w, 'guards');
|
||||
expect(faction).toBeDefined();
|
||||
});
|
||||
|
||||
it('setReputation() creates component on first call', () => {
|
||||
it('returns the same component for the same factionId', () => {
|
||||
const w = world();
|
||||
const e = w.createEntity();
|
||||
Factions.setReputation(e, 'guards', 50);
|
||||
expect(Factions.getReputation(e, 'guards')).toBe(50);
|
||||
const a = Factions.getFaction(w, 'guards');
|
||||
const b = Factions.getFaction(w, 'guards');
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it('setReputation() updates score on second call without duplicate component', () => {
|
||||
it('returns different components for different factionIds', () => {
|
||||
const w = world();
|
||||
const e = w.createEntity();
|
||||
Factions.setReputation(e, 'guards', 50);
|
||||
Factions.setReputation(e, 'guards', 75);
|
||||
expect(Factions.getReputation(e, 'guards')).toBe(75);
|
||||
expect(e.getAll(Reputation).length).toBe(1);
|
||||
});
|
||||
|
||||
it('adjustReputation() adds delta from zero default', () => {
|
||||
const w = world();
|
||||
const e = w.createEntity();
|
||||
Factions.adjustReputation(e, 'guards', 20);
|
||||
expect(Factions.getReputation(e, 'guards')).toBe(20);
|
||||
});
|
||||
|
||||
it('adjustReputation() accumulates correctly', () => {
|
||||
const w = world();
|
||||
const e = w.createEntity();
|
||||
Factions.adjustReputation(e, 'guards', 20);
|
||||
Factions.adjustReputation(e, 'guards', -5);
|
||||
expect(Factions.getReputation(e, 'guards')).toBe(15);
|
||||
});
|
||||
|
||||
it('Reputation component adjust() mutates score', () => {
|
||||
const w = world();
|
||||
const e = w.createEntity();
|
||||
Factions.setReputation(e, 'guards', 10);
|
||||
const comp = e.get(Reputation, (c) => c.factionId == 'guards')!;
|
||||
comp.adjust(30);
|
||||
expect(comp.score).toBe(40);
|
||||
});
|
||||
|
||||
it('score variable resolves on world', () => {
|
||||
const w = world();
|
||||
const e = w.createEntity('player');
|
||||
Factions.setReputation(e, 'guards', 50);
|
||||
expect(resolveVariables(w)['player.Reputation.guards']).toBe(50);
|
||||
const guards = Factions.getFaction(w, 'guards');
|
||||
const bandits = Factions.getFaction(w, 'bandits');
|
||||
expect(guards).not.toBe(bandits);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Factions.getReputationBetween', () => {
|
||||
it('returns null when target has no factions', () => {
|
||||
// ── 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();
|
||||
const observer = w.createEntity();
|
||||
const target = w.createEntity();
|
||||
expect(Factions.getReputationBetween(observer, target)).toBeNull();
|
||||
expect(Factions.getReputationOf(w.createEntity(), 'guards')).toBe(0);
|
||||
});
|
||||
|
||||
it("returns observer's rep with target's sole faction", () => {
|
||||
it('setReputationOf persists the value', () => {
|
||||
const w = world();
|
||||
const observer = w.createEntity();
|
||||
const target = w.createEntity();
|
||||
Factions.join(target, 'guards');
|
||||
Factions.setReputation(observer, 'guards', 40);
|
||||
expect(Factions.getReputationBetween(observer, target)).toBe(40);
|
||||
const e = w.createEntity();
|
||||
Factions.setReputationOf(e, 'guards', 50);
|
||||
expect(Factions.getReputationOf(e, 'guards')).toBe(50);
|
||||
});
|
||||
|
||||
it('returns the minimum when target belongs to multiple factions', () => {
|
||||
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.setReputation(observer, 'guards', 60);
|
||||
Factions.setReputation(observer, 'bandits', -30);
|
||||
expect(Factions.getReputationBetween(observer, target)).toBe(-30);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue