1
0
Fork 0

Rework faction system

This commit is contained in:
Pabloader 2026-05-06 16:28:19 +00:00
parent 2a82c13fb4
commit 4b2a16c28f
7 changed files with 761 additions and 139 deletions

View File

@ -57,18 +57,93 @@ export enum Color {
export const DEFAULT_FG = Color.WHITE; export const DEFAULT_FG = Color.WHITE;
export const DEFAULT_BG = Color.BLACK; export const DEFAULT_BG = Color.BLACK;
export enum Direction { export enum Glyphs {
NORTH = 0, // Pure single-line box drawing
EAST = 1, SINGLE_HORIZONTAL = '─',
SOUTH = 2, SINGLE_VERTICAL = '│',
WEST = 3, SINGLE_TOP_LEFT = '┌',
UP = 4, SINGLE_TOP_RIGHT = '┐',
DOWN = 5, 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 isVertical = (char: string) => char === Glyphs.SINGLE_VERTICAL || char === Glyphs.DOUBLE_VERTICAL;
export const isHorizontal = (char: string) => char === '─'; export const isHorizontal = (char: string) => char === Glyphs.SINGLE_HORIZONTAL || char === Glyphs.DOUBLE_HORIZONTAL;
export const isCorner = (char: string) => '┌┐└┘'.includes(char); 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 { interface BoxOptions {
vertical?: string; 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)); 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; x = x | 0; y1 = y1 | 0; y2 = y2 | 0;
if (y2 < y1) { const t = y2; y2 = y1; y1 = t; } 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; x1 = x1 | 0; x2 = x2 | 0; y = y | 0;
if (x2 < x1) { const t = x2; x2 = x1; x1 = t; } if (x2 < x1) { const t = x2; x2 = x1; x1 = t; }
x1 = Math.max(this.clipLeft, x1); x1 = Math.max(this.clipLeft, x1);
@ -343,12 +418,12 @@ export class TextDisplay {
drawBox(x: number, y: number, width: number, height: number, options: BoxOptions = {}) { drawBox(x: number, y: number, width: number, height: number, options: BoxOptions = {}) {
x = x | 0; y = y | 0; x = x | 0; y = y | 0;
const { const {
vertical = '│', vertical = Glyphs.SINGLE_VERTICAL,
horizontal = '─', horizontal = Glyphs.SINGLE_HORIZONTAL,
topLeft = '┌', topLeft = Glyphs.SINGLE_TOP_LEFT,
topRight = '┐', topRight = Glyphs.SINGLE_TOP_RIGHT,
bottomLeft = '└', bottomLeft = Glyphs.SINGLE_BOTTOM_LEFT,
bottomRight = '┘', bottomRight = Glyphs.SINGLE_BOTTOM_RIGHT,
fg = DEFAULT_FG, fg = DEFAULT_FG,
bg = DEFAULT_BG, bg = DEFAULT_BG,
fill, fill,
@ -389,14 +464,14 @@ export class TextDisplay {
this.drawString(text, x + 1, y + 1, fg, bg); 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; x = x | 0; y = y | 0;
for (let i = y; i < y + height; i++) { for (let i = y; i < y + height; i++) {
this.drawHLine(x, x + width - 1, i, char); 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 [ch, fg, bg] = parseChar(char);
const options: BresenhamLineOptions = { const options: BresenhamLineOptions = {
minX: this.clipLeft, 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); 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); this.#circle(cx, cy, radius, char, true);
} }

View File

@ -1,11 +1,22 @@
import { Component, type Entity } from "../core/world"; import { Component, Entity, World } from "../core/world";
import type { RPGVariables } from "../types"; import type { RPGVariables } from "../types";
import { action, component } from "../utils/decorators"; 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 ───────────────────────────────────────────────────────────── // ── FactionMember ─────────────────────────────────────────────────────────────
@component @component
export class FactionMember extends Component<{ factionId: string }> { class FactionMember extends Component<{ factionId: string }> {
constructor(factionId: string) { constructor(factionId: string) {
super({ factionId }); super({ factionId });
} }
@ -19,10 +30,35 @@ export class FactionMember extends Component<{ factionId: string }> {
} }
} }
// ── Reputation ──────────────────────────────────────────────────────────────── // ── ReputationOf ──────────────────────────────────────────────────────────────
// Observer's opinion of a faction.
@component @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) { constructor(factionId: string, score = 0) {
super({ factionId, score }); super({ factionId, score });
} }
@ -45,7 +81,7 @@ export class Reputation extends Component<{ factionId: string; score: number }>
//── FactionManager ──────────────────────────────────────────────────────────── //── FactionManager ────────────────────────────────────────────────────────────
@component @component
export class FactionManager extends Component<{}> { class FactionManager extends Component<{}> {
constructor() { super({}); } constructor() { super({}); }
@action @action
@ -59,13 +95,23 @@ export class FactionManager extends Component<{}> {
} }
@action @action
setReputation({ factionId, value }: { factionId: string, value: number }): void { setReputationOf({ factionId, value }: { factionId: string, value: number }): void {
Factions.setReputation(this.entity, factionId, value); Factions.setReputationOf(this.entity, factionId, value);
} }
@action @action
adjustReputation({ factionId, value }: { factionId: string, value: number }): void { adjustReputationOf({ factionId, value }: { factionId: string, value: number }): void {
Factions.adjustReputation(this.entity, factionId, value); 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()); 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 { export function join(entity: Entity, factionId: string): void {
addFactionManager(entity); addFactionManager(entity);
const existing = entity.get(FactionMember, (c) => c.factionId === factionId); if (!entity.has(FactionMember, (c) => c.factionId === factionId)) {
if (!existing) { getFaction(entity.world, factionId);
entity.add(new FactionMember(factionId)); entity.add(new FactionMember(factionId));
} }
} }
@ -95,38 +153,152 @@ export namespace Factions {
} }
export function getFactions(entity: Entity): string[] { 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 { // How observer sees a faction. Aggregates cross-faction scores when observer
return entity.get(Reputation, (c) => c.factionId === factionId)?.score ?? 0; // 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 { const crossFaction = getFactions(observer)
addFactionManager(entity); .map(myFactionId => getFaction(observer.world, myFactionId).entity.get(ReputationOf, (c) => c.factionId === factionId)?.score ?? 0)
const existing = entity.get(Reputation, (c) => c.factionId === factionId); .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) { if (existing) {
existing.state.score = value; existing.state.score = value;
} else { } else {
entity.add(new Reputation(factionId, value)); observer.add(new ReputationOf(factionId, value));
} }
} }
export function adjustReputation(entity: Entity, factionId: string, delta: number): void { export function adjustReputationOf(observer: Entity | Component<any>, factionId: string, delta: number): void {
addFactionManager(entity); if (!(observer instanceof Entity)) {
const existing = entity.get(Reputation, (c) => c.factionId === factionId); observer = observer.entity;
}
addFactionManager(observer);
getFaction(observer.world, factionId);
const existing = observer.get(ReputationOf, (c) => c.factionId === factionId);
if (existing) { if (existing) {
existing.adjust(delta); existing.adjust(delta);
} else { } 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. export function setReputationIn(target: Entity | Component<any>, factionId: string, value: number): void {
* Returns null if target has no FactionMember components. */ if (!(target instanceof Entity)) {
export function getReputationBetween(observer: Entity, target: Entity): number | null { target = target.entity;
const factions = getFactions(target); }
if (factions.length === 0) return null; const targetFaction = target.get(Faction);
return Math.min(...factions.map(f => getReputation(observer, f))); 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));
}
} }
} }

View File

@ -62,11 +62,3 @@ export const createViewport = (world: World, viewportData: ViewportData) => {
return viewport; return viewport;
} }
export const getViewport = (world: World): Viewport | null => {
for (const [, , viewport] of world.query(Viewport)) {
return viewport;
}
return null;
}

View File

@ -344,6 +344,13 @@ export class World {
return target; 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<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>>(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]>; 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]>;

View File

@ -1,6 +1,6 @@
import { TextRegion, TextDisplay } from "@common/display/text"; import { TextRegion, TextDisplay } from "@common/display/text";
import { Position } from "@common/rpg/components/position"; 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 { Hidden, Sprite } from "@common/rpg/components/sprite";
import { System, World } from "@common/rpg/core/world"; import { System, World } from "@common/rpg/core/world";
import { Resources } from "@common/rpg/utils/resources"; import { Resources } from "@common/rpg/utils/resources";
@ -16,7 +16,7 @@ export class TextDisplaySystem extends System {
} }
override update(world: World) { override update(world: World) {
const viewport = getViewport(world); const viewport = world.findComponent(Viewport);
const offset = viewport ? viewport.screenToWorld({ x: 0, y: 0 }) : undefined; const offset = viewport ? viewport.screenToWorld({ x: 0, y: 0 }) : undefined;
const viewportClipRect = viewport ? { const viewportClipRect = viewport ? {
x: viewport.screenX, x: viewport.screenX,

View File

@ -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 { gameLoop } from "@common/game";
import type { Point } from "@common/geometry"; import type { Point } from "@common/geometry";
import Input from "@common/input"; import Input from "@common/input";
import { BSP } from "@common/level/bsp"; import { BSP } from "@common/level/bsp";
import { bresenhamCircleGen } from "@common/navigation/bresenham"; import { bresenhamCircleGen } from "@common/navigation/bresenham";
import { SeededRandom } from "@common/random"; import { SeededRandom } from "@common/random";
import { Factions } from "@common/rpg/components/faction";
import { getPosition, move, Position } from "@common/rpg/components/position"; import { getPosition, move, Position } from "@common/rpg/components/position";
import { createViewport } from "@common/rpg/components/render/viewport"; import { createViewport } from "@common/rpg/components/render/viewport";
import { Sprite } from "@common/rpg/components/sprite"; import { Sprite } from "@common/rpg/components/sprite";
import { Serialization } from "@common/rpg/core/serialization";
import { World } from "@common/rpg/core/world"; import { World } from "@common/rpg/core/world";
import { TextDisplaySystem } from "@common/rpg/systems/render/text"; import { TextDisplaySystem } from "@common/rpg/systems/render/text";
import { Resources } from "@common/rpg/utils/resources"; import { Resources } from "@common/rpg/utils/resources";
import { resolveVariables } from "@common/rpg/utils/variables";
const WALL = '#'; const WALL = '#';
const PLAYER = '@'; const PLAYER = '@';
@ -20,8 +23,8 @@ function createMap(world: World, random: SeededRandom) {
const mapData = new Array(MAP_SIZE * MAP_SIZE).fill(WALL); const mapData = new Array(MAP_SIZE * MAP_SIZE).fill(WALL);
BSP.generateLevel(MAP_SIZE, MAP_SIZE, (x, y) => { mapData[x + y * MAP_SIZE] = '.'; }, { BSP.generateLevel(MAP_SIZE, MAP_SIZE, (x, y) => { mapData[x + y * MAP_SIZE] = '.'; }, {
minWidth: 8, minWidth: 10,
minHeight: 4, minHeight: 8,
depth: 10, depth: 10,
random, random,
}); });
@ -40,18 +43,29 @@ function createMap(world: World, random: SeededRandom) {
const mask = world.createEntity('mask'); const mask = world.createEntity('mask');
mask.add(new Sprite(Resources.add('mask', new TextRegion(maskDataString)))); 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 }; return { map, mask };
} }
function createPlayer(world: World, x = 0, y = 0) { function createPlayer(world: World, x: number, y: number) {
const player = world.createEntity('player'); const player = world.createEntity('player');
player.add(new Position(x, y, 10)); player.add(new Position(x, y, 10));
player.add(new Sprite(Resources.add('player', new TextRegion(PLAYER, Color.YELLOW)))); player.add(new Sprite(Resources.add('player', new TextRegion(PLAYER, Color.YELLOW))));
Factions.join(player, 'players');
return player; 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() { function handleMovement() {
let dx = 0; let dx = 0;
let dy = 0; let dy = 0;
@ -115,6 +129,8 @@ export default gameLoop(() => {
const maskData = Resources.get(TextRegion, mask.get(Sprite)!.image)!; const maskData = Resources.get(TextRegion, mask.get(Sprite)!.image)!;
const startCell = random.choice(emptyCells); const startCell = random.choice(emptyCells);
const enemyCell = random.choice(emptyCells.filter(c => c !== startCell));
const viewport = createViewport(world, { const viewport = createViewport(world, {
width: display.width, width: display.width,
height: display.height, height: display.height,
@ -125,6 +141,17 @@ export default gameLoop(() => {
}); });
const player = createPlayer(world, startCell.x, startCell.y); 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 = { const state = {
display, display,
world, world,
@ -133,9 +160,10 @@ export default gameLoop(() => {
random, random,
viewport, viewport,
player, player,
enemy,
isWall: (x: number, y: number): boolean => { isWall: (x: number, y: number): boolean => {
const [ch] = state.mapData.get(x, y); const [ch] = state.mapData.get(x, y);
return ch === WALL; return ch === WALL || isVertical(ch) || isHorizontal(ch) || isCorner(ch);
}, },
updateMask: () => { updateMask: () => {
if (!state.maskDirty) return; if (!state.maskDirty) return;
@ -158,6 +186,9 @@ export default gameLoop(() => {
} }
}; };
Factions.setReputationIn(player, 'monsters', 5);
console.log(Factions.getReputation(enemy, player));
return state; return state;
}, (dt, state) => { }, (dt, state) => {
const { const {

View File

@ -1,19 +1,49 @@
import { describe, it, expect } from 'bun:test'; import { describe, it, expect } from 'bun:test';
import { World } from '@common/rpg/core/world'; import { World } from '@common/rpg/core/world';
import { Reputation, Factions } from '@common/rpg/components/faction'; import { Factions } from '@common/rpg/components/faction';
import { resolveVariables } from '@common/rpg/utils/variables';
function world() { return new World(); } function world() { return new World(); }
describe('FactionMember', () => { // ── Membership ────────────────────────────────────────────────────────────────
it('join() adds membership, isMember() returns true', () => {
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 w = world();
const e = w.createEntity(); const e = w.createEntity();
Factions.join(e, 'guards'); Factions.join(e, 'guards');
expect(Factions.isMember(e, 'guards')).toBeTrue(); 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 w = world();
const e = w.createEntity(); const e = w.createEntity();
Factions.join(e, 'guards'); Factions.join(e, 'guards');
@ -21,108 +51,423 @@ describe('FactionMember', () => {
expect(Factions.isMember(e, 'guards')).toBeFalse(); 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 w = world();
const e = w.createEntity(); 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 w = world();
const e = w.createEntity(); const e = w.createEntity();
Factions.join(e, 'guards'); Factions.join(e, 'guards');
Factions.join(e, 'merchants'); 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 w = world();
const e = w.createEntity('player'); const e = w.createEntity();
Factions.join(e, 'guards'); 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();
}); });
describe('Reputation', () => { it('getFactions returns empty array for new entity', () => {
it('getReputation() returns 0 when no component', () => { const w = world();
expect(Factions.getFactions(w.createEntity())).toEqual([]);
});
it('getFactions reflects leave', () => {
const w = world(); const w = world();
const e = w.createEntity(); const e = w.createEntity();
expect(Factions.getReputation(e, 'guards')).toBe(0); Factions.join(e, 'guards');
Factions.join(e, 'merchants');
Factions.leave(e, 'merchants');
expect(Factions.getFactions(e)).toEqual(['guards']);
});
}); });
it('setReputation() creates component on first call', () => { // ── 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 w = world();
const e = w.createEntity(); const e = w.createEntity();
Factions.setReputation(e, 'guards', 50); Factions.setReputationOf(e, 'guards', 50);
expect(Factions.getReputation(e, 'guards')).toBe(50); expect(Factions.getReputationOf(e, 'guards')).toBe(50);
}); });
it('setReputation() updates score on second call without duplicate component', () => { it('setReputationOf overwrites the previous value without duplication', () => {
const w = world(); const w = world();
const e = w.createEntity(); const e = w.createEntity();
Factions.setReputation(e, 'guards', 50); Factions.setReputationOf(e, 'guards', 50);
Factions.setReputation(e, 'guards', 75); Factions.setReputationOf(e, 'guards', 75);
expect(Factions.getReputation(e, 'guards')).toBe(75); expect(Factions.getReputationOf(e, 'guards')).toBe(75);
expect(e.getAll(Reputation).length).toBe(1);
}); });
it('adjustReputation() adds delta from zero default', () => { it('setReputationOf allows negative values', () => {
const w = world(); const w = world();
const e = w.createEntity(); const e = w.createEntity();
Factions.adjustReputation(e, 'guards', 20); Factions.setReputationOf(e, 'bandits', -100);
expect(Factions.getReputation(e, 'guards')).toBe(20); expect(Factions.getReputationOf(e, 'bandits')).toBe(-100);
}); });
it('adjustReputation() accumulates correctly', () => { it('reputations are independent per faction', () => {
const w = world(); const w = world();
const e = w.createEntity(); const e = w.createEntity();
Factions.adjustReputation(e, 'guards', 20); Factions.setReputationOf(e, 'guards', 80);
Factions.adjustReputation(e, 'guards', -5); Factions.setReputationOf(e, 'bandits', -20);
expect(Factions.getReputation(e, 'guards')).toBe(15); expect(Factions.getReputationOf(e, 'guards')).toBe(80);
expect(Factions.getReputationOf(e, 'bandits')).toBe(-20);
}); });
it('Reputation component adjust() mutates score', () => { 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 w = world();
const e = w.createEntity(); const e = w.createEntity();
Factions.setReputation(e, 'guards', 10); Factions.adjustReputationOf(e, 'guards', 20);
const comp = e.get(Reputation, (c) => c.factionId == 'guards')!; expect(Factions.getReputationOf(e, 'guards')).toBe(20);
comp.adjust(30);
expect(comp.score).toBe(40);
}); });
it('score variable resolves on world', () => { it('accumulates positive adjustments', () => {
const w = world(); const w = world();
const e = w.createEntity('player'); const e = w.createEntity();
Factions.setReputation(e, 'guards', 50); Factions.adjustReputationOf(e, 'guards', 20);
expect(resolveVariables(w)['player.Reputation.guards']).toBe(50); 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);
}); });
}); });
describe('Factions.getReputationBetween', () => { // Cross-faction reputation aggregation: observer inherits their own faction's opinion.
it('returns null when target has no factions', () => {
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 w = world();
const observer = w.createEntity(); const observer = w.createEntity();
const target = w.createEntity(); const target = w.createEntity();
expect(Factions.getReputationBetween(observer, target)).toBeNull();
});
it("returns observer's rep with target's sole faction", () => {
const w = world();
const observer = w.createEntity();
const target = w.createEntity();
Factions.join(target, 'guards'); Factions.join(target, 'guards');
Factions.setReputation(observer, 'guards', 40); Factions.join(target, 'merchants');
expect(Factions.getReputationBetween(observer, target)).toBe(40); 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('returns the minimum when target belongs to multiple factions', () => { it("observer's faction membership contributes toward target's faction opinion", () => {
const w = world(); const w = world();
const observer = w.createEntity(); const observer = w.createEntity();
const target = w.createEntity(); const target = w.createEntity();
Factions.join(target, 'guards'); const guardsFaction = Factions.getFaction(w, 'guards').entity;
Factions.join(observer, 'guards');
Factions.join(target, 'bandits'); Factions.join(target, 'bandits');
Factions.setReputation(observer, 'guards', 60); Factions.setReputationOf(guardsFaction, 'bandits', -40);
Factions.setReputation(observer, 'bandits', -30);
expect(Factions.getReputationBetween(observer, target)).toBe(-30); // 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);
}); });
}); });