Compare commits
No commits in common. "43da1388e4877a4c66f96ec1e85da92538cb1994" and "6d5cd0b2cc65d79702511e87d4aae9c57cbda904" have entirely different histories.
43da1388e4
...
6d5cd0b2cc
|
|
@ -1,7 +1,20 @@
|
|||
# RPG Engine — Remaining Work
|
||||
|
||||
## Missing Foundational Components
|
||||
|
||||
### `Faction` / `Relationship` (`components/faction.ts`)
|
||||
Reputation score per faction ID. Drives dialog availability, shop access, hostile
|
||||
aggro thresholds, and quest unlock conditions. A separate world-level faction
|
||||
definition registry (neutral/friendly/hostile thresholds) pairs with this.
|
||||
|
||||
---
|
||||
|
||||
## Deferred Combat Features
|
||||
|
||||
### Effect stacking rules
|
||||
Add `stack` (default) / `unique` / `replace` modes with a tag discriminator to
|
||||
`Effect`. Prevents e.g. multiple poison stacks when the design calls for one.
|
||||
|
||||
### Damage modifiers
|
||||
Stat-scaling, armor penetration, multipliers — a `DamageModifier` component on
|
||||
the attacker/source that CombatSystem folds into the final damage value.
|
||||
|
|
|
|||
|
|
@ -13,56 +13,31 @@ export class Effect extends Component<{
|
|||
remaining: number | null; // countdown in seconds; null for condition-based/permanent
|
||||
condition: string | null; // keep effect while true; remove when it becomes false
|
||||
scope: 'equip' | 'onHit'; // 'equip' = live modifier on owner; 'onHit' = template applied to attack target
|
||||
stacking: 'stack' | 'unique' | 'replace';
|
||||
tag: string | null; // discriminator for stacking; null = no stacking enforcement
|
||||
}> {
|
||||
/** True while the effect's delta is applied to the target stat. */
|
||||
@variable('.') active: boolean = false;
|
||||
|
||||
constructor(opts: {
|
||||
targetStat: string;
|
||||
delta: number;
|
||||
targetField?: 'value' | 'max' | 'min';
|
||||
duration?: number;
|
||||
condition?: string;
|
||||
scope?: 'equip' | 'onHit';
|
||||
stacking?: 'stack' | 'unique' | 'replace';
|
||||
tag?: string;
|
||||
}) {
|
||||
constructor(
|
||||
targetStat: string,
|
||||
delta: number,
|
||||
targetField?: 'value' | 'max' | 'min',
|
||||
duration?: number,
|
||||
condition?: string,
|
||||
scope?: 'equip' | 'onHit',
|
||||
) {
|
||||
super({
|
||||
targetStat: opts.targetStat,
|
||||
targetField: opts.targetField ?? 'value',
|
||||
delta: opts.delta,
|
||||
duration: opts.duration ?? null,
|
||||
remaining: opts.duration ?? null,
|
||||
condition: opts.condition ?? null,
|
||||
scope: opts.scope ?? 'equip',
|
||||
stacking: opts.stacking ?? 'stack',
|
||||
tag: opts.tag ?? null,
|
||||
targetStat,
|
||||
targetField: targetField ?? 'value',
|
||||
delta,
|
||||
duration: duration ?? null,
|
||||
remaining: duration ?? null,
|
||||
condition: condition ?? null,
|
||||
scope: scope ?? 'equip',
|
||||
});
|
||||
}
|
||||
|
||||
override onAdd(): void {
|
||||
if (this.state.scope === 'onHit') return;
|
||||
|
||||
const { stacking, tag } = this.state;
|
||||
|
||||
if (tag != null && stacking !== 'stack') {
|
||||
const siblings = this.entity.getAll(Effect).filter(e => e !== this && e.state.tag === tag);
|
||||
|
||||
if (stacking === 'unique' && siblings.length > 0) {
|
||||
// An effect with this tag is already active — discard the incoming one.
|
||||
this.entity.remove(this.key);
|
||||
return;
|
||||
}
|
||||
|
||||
if (stacking === 'replace') {
|
||||
for (const old of siblings) {
|
||||
this.entity.remove(old.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const stat = this.entity.get(Stat, this.state.targetStat);
|
||||
if (stat) {
|
||||
stat.applyModifier(this.state.delta, this.state.targetField);
|
||||
|
|
@ -71,7 +46,7 @@ export class Effect extends Component<{
|
|||
}
|
||||
|
||||
override onRemove(): void {
|
||||
if (!this.active) return;
|
||||
if (this.state.scope === 'onHit') return;
|
||||
const stat = this.entity.get(Stat, this.state.targetStat);
|
||||
if (stat) {
|
||||
stat.removeModifier(this.state.delta, this.state.targetField);
|
||||
|
|
@ -92,6 +67,7 @@ export class Effect extends Component<{
|
|||
@action
|
||||
clear(): void {
|
||||
this.state.remaining = 0;
|
||||
this.active = false;
|
||||
this.emit('expired');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ interface SlotRecord {
|
|||
}
|
||||
|
||||
interface EquipmentState {
|
||||
slots: Record<string, SlotRecord>;
|
||||
slots: SlotRecord[];
|
||||
}
|
||||
|
||||
export type SlotInput =
|
||||
|
|
@ -46,17 +46,17 @@ export class Equipment extends Component<EquipmentState> {
|
|||
#cachedVars: RPGVariables | null = null;
|
||||
|
||||
constructor(slots: SlotInput[]) {
|
||||
const record: Record<string, SlotRecord> = {};
|
||||
for (const s of slots) {
|
||||
super({
|
||||
slots: slots.map(s => {
|
||||
const slotName = typeof s === 'string' ? s : s.slotName;
|
||||
const type = typeof s === 'object' && s.type ? s.type : null;
|
||||
record[slotName] = { slotName, type, itemId: null, appliedEffectKeys: [] };
|
||||
}
|
||||
super({ slots: record });
|
||||
return { slotName, type, itemId: null, appliedEffectKeys: [] };
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
#slot(slotName: string): SlotRecord | undefined {
|
||||
return this.state.slots[slotName];
|
||||
return this.state.slots.find(s => s.slotName === slotName);
|
||||
}
|
||||
|
||||
/** ItemId in the named slot, or null if empty. */
|
||||
|
|
@ -71,7 +71,7 @@ export class Equipment extends Component<EquipmentState> {
|
|||
*/
|
||||
findCompatibleSlot(slotType: string): string | null {
|
||||
let genericFallback: string | null = null;
|
||||
for (const slot of Object.values(this.state.slots)) {
|
||||
for (const slot of this.state.slots) {
|
||||
if (slot.itemId !== null) continue;
|
||||
if (slot.type === null) { genericFallback ??= slot.slotName; continue; }
|
||||
if (slot.type === slotType) return slot.slotName;
|
||||
|
|
@ -81,7 +81,7 @@ export class Equipment extends Component<EquipmentState> {
|
|||
|
||||
/** All currently equipped `{ slotName, itemId }` pairs. */
|
||||
getEquipped(): { slotName: string; itemId: string }[] {
|
||||
return Object.values(this.state.slots)
|
||||
return this.state.slots
|
||||
.filter((s): s is SlotRecord & { itemId: string } => s.itemId !== null)
|
||||
.map(({ slotName, itemId }) => ({ slotName, itemId }));
|
||||
}
|
||||
|
|
@ -154,7 +154,7 @@ export class Equipment extends Component<EquipmentState> {
|
|||
if (this.#cachedVars) return this.#cachedVars;
|
||||
|
||||
const result: RPGVariables = {};
|
||||
for (const { slotName, itemId } of Object.values(this.state.slots)) {
|
||||
for (const { slotName, itemId } of this.state.slots) {
|
||||
result[slotName] = itemId ?? '';
|
||||
}
|
||||
this.#cachedVars = result;
|
||||
|
|
|
|||
|
|
@ -1,88 +0,0 @@
|
|||
import { component } from "../core/registry";
|
||||
import { Component, type Entity } from "../core/world";
|
||||
import { variable } from "../utils/decorators";
|
||||
|
||||
// ── FactionMember ─────────────────────────────────────────────────────────────
|
||||
|
||||
@component
|
||||
export class FactionMember extends Component<{ factionId: string }> {
|
||||
@variable('.') readonly member: boolean = true;
|
||||
|
||||
constructor(factionId: string) {
|
||||
super({ factionId });
|
||||
}
|
||||
|
||||
get factionId(): string { return this.state.factionId; }
|
||||
}
|
||||
|
||||
// ── Reputation ────────────────────────────────────────────────────────────────
|
||||
|
||||
@component
|
||||
export class Reputation extends Component<{ factionId: string; score: number }> {
|
||||
constructor(factionId: string, score = 0) {
|
||||
super({ factionId, score });
|
||||
}
|
||||
|
||||
@variable('.') get score(): number { return this.state.score; }
|
||||
|
||||
get factionId(): string { return this.state.factionId; }
|
||||
|
||||
adjust(delta: number): void {
|
||||
this.state.score += delta;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Factions namespace ────────────────────────────────────────────────────────
|
||||
|
||||
function memberKey(factionId: string): string { return `faction.${factionId}.member`; }
|
||||
function repKey(factionId: string): string { return `faction.${factionId}.rep`; }
|
||||
|
||||
export namespace Factions {
|
||||
export function join(entity: Entity, factionId: string): void {
|
||||
entity.add(memberKey(factionId), new FactionMember(factionId));
|
||||
}
|
||||
|
||||
export function leave(entity: Entity, factionId: string): void {
|
||||
entity.remove(memberKey(factionId));
|
||||
}
|
||||
|
||||
export function isMember(entity: Entity, factionId: string): boolean {
|
||||
return entity.has(FactionMember, memberKey(factionId));
|
||||
}
|
||||
|
||||
export function getFactions(entity: Entity): string[] {
|
||||
return entity.getAll(FactionMember).map(m => m.factionId);
|
||||
}
|
||||
|
||||
export function getReputation(entity: Entity, factionId: string): number {
|
||||
return entity.get(Reputation, repKey(factionId))?.score ?? 0;
|
||||
}
|
||||
|
||||
export function setReputation(entity: Entity, factionId: string, value: number): void {
|
||||
const key = repKey(factionId);
|
||||
const existing = entity.get(Reputation, key);
|
||||
if (existing) {
|
||||
existing.state.score = value;
|
||||
} else {
|
||||
entity.add(key, new Reputation(factionId, value));
|
||||
}
|
||||
}
|
||||
|
||||
export function adjustReputation(entity: Entity, factionId: string, delta: number): void {
|
||||
const key = repKey(factionId);
|
||||
const existing = entity.get(Reputation, key);
|
||||
if (existing) {
|
||||
existing.adjust(delta);
|
||||
} else {
|
||||
entity.add(key, new Reputation(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)));
|
||||
}
|
||||
}
|
||||
|
|
@ -15,25 +15,29 @@ interface SlotRecord {
|
|||
interface InventoryState {
|
||||
infinite: boolean;
|
||||
nextSlotId: number; // auto-increment counter for infinite mode
|
||||
slots: Record<SlotId, SlotRecord>;
|
||||
slots: SlotRecord[];
|
||||
}
|
||||
|
||||
function buildInventoryState(input?: number | InventorySlotInput[]): InventoryState {
|
||||
if (input === undefined) {
|
||||
return { infinite: true, nextSlotId: 0, slots: {} as Record<SlotId, SlotRecord> };
|
||||
return { infinite: true, nextSlotId: 0, slots: [] };
|
||||
}
|
||||
if (typeof input === 'number') {
|
||||
const slots = {} as Record<SlotId, SlotRecord>;
|
||||
for (let i = 0; i < input; i++) slots[i] = { slotId: i, limit: undefined, contents: null };
|
||||
return { infinite: false, nextSlotId: 0, slots };
|
||||
return {
|
||||
infinite: false,
|
||||
nextSlotId: 0,
|
||||
slots: Array.from({ length: input }, (_, i) => ({ slotId: i, limit: undefined, contents: null })),
|
||||
};
|
||||
}
|
||||
const slots = {} as Record<SlotId, SlotRecord>;
|
||||
for (const def of input) {
|
||||
const slotId = typeof def === 'object' ? def.slotId : def;
|
||||
const limit = typeof def === 'object' ? def.limit : undefined;
|
||||
slots[slotId] = { slotId, limit, contents: null };
|
||||
}
|
||||
return { infinite: false, nextSlotId: 0, slots };
|
||||
return {
|
||||
infinite: false,
|
||||
nextSlotId: 0,
|
||||
slots: input.map(def => ({
|
||||
slotId: typeof def === 'object' ? def.slotId : def,
|
||||
limit: typeof def === 'object' ? def.limit : undefined,
|
||||
contents: null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@component
|
||||
|
|
@ -51,7 +55,7 @@ export class Inventory extends Component<InventoryState> {
|
|||
}
|
||||
|
||||
#slot(slotId: SlotId): SlotRecord | undefined {
|
||||
return this.state.slots[slotId];
|
||||
return this.state.slots.find(s => s.slotId === slotId);
|
||||
}
|
||||
|
||||
#capFor(slot: SlotRecord, itemId: string): number {
|
||||
|
|
@ -90,7 +94,7 @@ export class Inventory extends Component<InventoryState> {
|
|||
// ── Finite inventory: two-phase (check → apply) ───────────────────────
|
||||
if (!this.state.infinite) {
|
||||
let canFit = 0;
|
||||
for (const slot of Object.values(this.state.slots)) {
|
||||
for (const slot of this.state.slots) {
|
||||
if (slot.contents === null || slot.contents.itemId === itemId)
|
||||
canFit += this.#roomFor(slot, itemId);
|
||||
}
|
||||
|
|
@ -98,7 +102,7 @@ export class Inventory extends Component<InventoryState> {
|
|||
|
||||
let remaining = amount;
|
||||
const slotIds: SlotId[] = [];
|
||||
for (const slot of Object.values(this.state.slots)) {
|
||||
for (const slot of this.state.slots) {
|
||||
if (slot.contents?.itemId === itemId && remaining > 0) {
|
||||
const take = Math.min(this.#roomFor(slot, itemId), remaining);
|
||||
slot.contents!.amount += take;
|
||||
|
|
@ -106,7 +110,7 @@ export class Inventory extends Component<InventoryState> {
|
|||
slotIds.push(slot.slotId);
|
||||
}
|
||||
}
|
||||
for (const slot of Object.values(this.state.slots)) {
|
||||
for (const slot of this.state.slots) {
|
||||
if (slot.contents === null && remaining > 0) {
|
||||
const take = Math.min(this.#capFor(slot, itemId), remaining);
|
||||
slot.contents = { itemId, amount: take };
|
||||
|
|
@ -122,7 +126,7 @@ export class Inventory extends Component<InventoryState> {
|
|||
let remaining = amount;
|
||||
const slotIds: SlotId[] = [];
|
||||
|
||||
for (const slot of Object.values(this.state.slots)) {
|
||||
for (const slot of this.state.slots) {
|
||||
if (slot.contents?.itemId === itemId && remaining > 0) {
|
||||
const take = Math.min(this.#roomFor(slot, itemId), remaining);
|
||||
if (take > 0) {
|
||||
|
|
@ -132,7 +136,7 @@ export class Inventory extends Component<InventoryState> {
|
|||
}
|
||||
}
|
||||
}
|
||||
for (const slot of Object.values(this.state.slots)) {
|
||||
for (const slot of this.state.slots) {
|
||||
if (slot.contents === null && remaining > 0) {
|
||||
const take = Math.min(this.#capFor(slot, itemId), remaining);
|
||||
slot.contents = { itemId, amount: take };
|
||||
|
|
@ -142,7 +146,7 @@ export class Inventory extends Component<InventoryState> {
|
|||
}
|
||||
while (remaining > 0) {
|
||||
const newSlot: SlotRecord = { slotId: this.state.nextSlotId++, limit: undefined, contents: null };
|
||||
this.state.slots[newSlot.slotId] = newSlot;
|
||||
this.state.slots.push(newSlot);
|
||||
const take = Math.min(this.#capFor(newSlot, itemId), remaining);
|
||||
newSlot.contents = { itemId, amount: take };
|
||||
remaining -= take;
|
||||
|
|
@ -173,7 +177,7 @@ export class Inventory extends Component<InventoryState> {
|
|||
|
||||
let remaining = amount;
|
||||
const slotIds: SlotId[] = [];
|
||||
for (const slot of Object.values(this.state.slots)) {
|
||||
for (const slot of this.state.slots) {
|
||||
if (slot.contents?.itemId === itemId && remaining > 0) {
|
||||
const take = Math.min(slot.contents.amount, remaining);
|
||||
slot.contents.amount -= take;
|
||||
|
|
@ -233,7 +237,7 @@ export class Inventory extends Component<InventoryState> {
|
|||
* `slotId` specifies which inventory slot to use from (otherwise any slot is used).
|
||||
*/
|
||||
@action
|
||||
use(arg?: string | { itemId?: string; slotId?: SlotId }, ctx?: EvalContext): boolean {
|
||||
async use(arg?: string | { itemId?: string; slotId?: SlotId }, ctx?: EvalContext): Promise<boolean> {
|
||||
const resolved = this.#resolveItem(arg);
|
||||
if (!resolved) return false;
|
||||
const { itemId, slotId } = resolved;
|
||||
|
|
@ -257,7 +261,7 @@ export class Inventory extends Component<InventoryState> {
|
|||
|
||||
if (usable.consumeOnUse) this.remove({ itemId, amount: 1, slotId });
|
||||
|
||||
usable.use(ctx ?? this.context);
|
||||
await usable.use(ctx ?? this.context);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -286,7 +290,7 @@ export class Inventory extends Component<InventoryState> {
|
|||
return slot?.contents?.itemId === itemId ? slot.contents.amount : 0;
|
||||
}
|
||||
let total = 0;
|
||||
for (const slot of Object.values(this.state.slots)) {
|
||||
for (const slot of this.state.slots) {
|
||||
if (slot.contents?.itemId === itemId) total += slot.contents.amount;
|
||||
}
|
||||
return total;
|
||||
|
|
@ -294,7 +298,7 @@ export class Inventory extends Component<InventoryState> {
|
|||
|
||||
getItems(): Map<string, number> {
|
||||
const result = new Map<string, number>();
|
||||
for (const slot of Object.values(this.state.slots)) {
|
||||
for (const slot of this.state.slots) {
|
||||
if (slot.contents) {
|
||||
const { itemId, amount } = slot.contents;
|
||||
result.set(itemId, (result.get(itemId) ?? 0) + amount);
|
||||
|
|
|
|||
|
|
@ -46,11 +46,11 @@ export class Usable extends Component<UsableState> {
|
|||
@variable get consumeOnUse(): boolean { return this.state.consumeOnUse; }
|
||||
|
||||
@action
|
||||
use(arg?: EvalContext, ctx?: EvalContext): void {
|
||||
async use(arg?: EvalContext, ctx?: EvalContext): Promise<void> {
|
||||
ctx = arg ?? ctx ?? this.context;
|
||||
if (!ctx) return;
|
||||
for (const action of this.state.actions) {
|
||||
executeAction(action, ctx);
|
||||
await executeAction(action, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ export abstract class Component<TState = Record<string, unknown>> {
|
|||
export abstract class System {
|
||||
onAdd(_world: World): void { }
|
||||
onRemove(_world: World): void { }
|
||||
update(_world: World, _dt: number): void { };
|
||||
async update(_world: World, _dt: number): Promise<void> { };
|
||||
}
|
||||
|
||||
type ComponentFilter<T> = (component: T) => boolean;
|
||||
|
|
@ -316,10 +316,8 @@ export class World {
|
|||
}
|
||||
}
|
||||
|
||||
update(dt: number) {
|
||||
for (const system of this.#systems) {
|
||||
system.update(this, dt);
|
||||
}
|
||||
async update(dt: number): Promise<void> {
|
||||
for (const system of this.#systems) await system.update(this, dt);
|
||||
}
|
||||
|
||||
emit(entityId: string, event: string, data?: unknown): void {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { System, World } from "../core/world";
|
|||
let hitEffectCounter = 0;
|
||||
|
||||
export class CombatSystem extends System {
|
||||
override update(world: World) {
|
||||
override async update(world: World): Promise<void> {
|
||||
for (const [target] of world.query(Attacked)) {
|
||||
const health = target.get(Health);
|
||||
if (!health) {
|
||||
|
|
@ -55,15 +55,7 @@ export class CombatSystem extends System {
|
|||
const s = component.state;
|
||||
target.add(
|
||||
`__hit_${source.id}_${key}_${hitEffectCounter++}`,
|
||||
new Effect({
|
||||
targetStat: s.targetStat,
|
||||
delta: s.delta,
|
||||
targetField: s.targetField,
|
||||
duration: s.duration ?? undefined,
|
||||
condition: s.condition ?? undefined,
|
||||
stacking: s.stacking,
|
||||
tag: s.tag ?? undefined,
|
||||
}),
|
||||
new Effect(s.targetStat, s.delta, s.targetField, s.duration ?? undefined, s.condition ?? undefined),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { Cooldown } from "../components/cooldown";
|
|||
import { System, type World } from "../core/world";
|
||||
|
||||
export class CooldownSystem extends System {
|
||||
override update(world: World, dt: number) {
|
||||
override async update(world: World, dt: number): Promise<void> {
|
||||
for (const [, , cooldown] of world.query(Cooldown)) {
|
||||
cooldown.update(dt);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@ import { Effect } from "../components/effect";
|
|||
import { System, type Entity, type World } from "../core/world";
|
||||
|
||||
export class EffectSystem extends System {
|
||||
override update(world: World, dt: number) {
|
||||
override async update(world: World, dt: number): Promise<void> {
|
||||
const expired: [Entity, string][] = [];
|
||||
|
||||
for (const [entity, key, effect] of world.query(Effect)) {
|
||||
effect.update(dt, entity.context);
|
||||
if (effect.state.remaining !== null && effect.state.remaining <= 0) {
|
||||
if (!effect.active && effect.state.scope !== 'onHit') {
|
||||
expired.push([entity, key]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,12 +27,12 @@ export class QuestSystem extends System {
|
|||
this.#tracking.clear();
|
||||
}
|
||||
|
||||
override update(world: World) {
|
||||
override async update(world: World, _dt: number): Promise<void> {
|
||||
for (const [entity, key, questLog] of world.query(QuestLog)) {
|
||||
if (!this.#tracking.has(entity.id)) {
|
||||
this.#initTracking(entity, key, questLog);
|
||||
}
|
||||
this.#diffAndCheck(entity, world);
|
||||
await this.#diffAndCheck(entity, world);
|
||||
}
|
||||
|
||||
// Prune tracking for entities that no longer exist
|
||||
|
|
@ -45,9 +45,9 @@ export class QuestSystem extends System {
|
|||
}
|
||||
|
||||
/** Force a full re-evaluation of all active quests. Use as an escape hatch. */
|
||||
triggerCheck(world: World) {
|
||||
async triggerCheck(world: World): Promise<void> {
|
||||
for (const [entity] of world.query(QuestLog)) {
|
||||
this.#checkEntity(entity, world, 'all');
|
||||
await this.#checkEntity(entity, world, 'all');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -68,7 +68,7 @@ export class QuestSystem extends System {
|
|||
}
|
||||
|
||||
// Keep tracking fresh as quest state changes
|
||||
const onStarted = ({ data }: { data?: unknown }) => {
|
||||
const onStarted = async ({ data }: { data?: unknown }) => {
|
||||
const { questId } = data as { questId: string };
|
||||
const quest = questLog.getQuest(questId);
|
||||
const state = questLog.getState(questId);
|
||||
|
|
@ -76,15 +76,15 @@ export class QuestSystem extends System {
|
|||
if (stage) {
|
||||
this.#addQuestVars(tracking, questId, stage);
|
||||
// Evaluate immediately — conditions may already be satisfied at start
|
||||
this.#checkEntity(entity, entity.world, new Set([questId]));
|
||||
await this.#checkEntity(entity, entity.world, new Set([questId]));
|
||||
}
|
||||
};
|
||||
|
||||
const onStage = ({ data }: { data?: unknown }) => {
|
||||
const onStage = async ({ data }: { data?: unknown }) => {
|
||||
const { questId, stage } = data as { questId: string; stage: QuestStage };
|
||||
this.#removeQuestVars(tracking, questId);
|
||||
this.#addQuestVars(tracking, questId, stage);
|
||||
this.#checkEntity(entity, entity.world, new Set([questId]));
|
||||
await this.#checkEntity(entity, entity.world, new Set([questId]));
|
||||
};
|
||||
|
||||
const onDone = ({ data }: { data?: unknown }) => {
|
||||
|
|
@ -128,7 +128,7 @@ export class QuestSystem extends System {
|
|||
}
|
||||
}
|
||||
|
||||
#diffAndCheck(entity: Entity, world: World) {
|
||||
async #diffAndCheck(entity: Entity, world: World): Promise<void> {
|
||||
const tracking = this.#tracking.get(entity.id);
|
||||
if (!tracking || tracking.varToQuests.size === 0) return;
|
||||
|
||||
|
|
@ -145,10 +145,10 @@ export class QuestSystem extends System {
|
|||
}
|
||||
}
|
||||
|
||||
if (dirty.size > 0) this.#checkEntity(entity, world, dirty);
|
||||
if (dirty.size > 0) await this.#checkEntity(entity, world, dirty);
|
||||
}
|
||||
|
||||
#checkEntity(entity: Entity, world: World, filter: Set<string> | 'all') {
|
||||
async #checkEntity(entity: Entity, world: World, filter: Set<string> | 'all'): Promise<void> {
|
||||
const questLog = entity.get(QuestLog);
|
||||
if (!questLog) return;
|
||||
|
||||
|
|
@ -171,7 +171,7 @@ export class QuestSystem extends System {
|
|||
|
||||
if (!stage.objectives.every(o => evaluateCondition(o.condition, ctx))) continue;
|
||||
|
||||
for (const action of stage.actions) executeAction(action, ctx);
|
||||
for (const action of stage.actions) await executeAction(action, ctx);
|
||||
questLog._advance(questId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ interface Contextable {
|
|||
readonly context: EvalContext;
|
||||
}
|
||||
|
||||
export function executeAction(action: RPGAction, ctx: EvalContext | Contextable): unknown {
|
||||
export async function executeAction(action: RPGAction, ctx: EvalContext | Contextable): Promise<unknown> {
|
||||
if (typeof action === 'string') {
|
||||
action = { type: action };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ function world() {
|
|||
}
|
||||
|
||||
describe('CombatSystem — damage', () => {
|
||||
it('reduces target health by damage value', () => {
|
||||
it('reduces target health by damage value', async () => {
|
||||
const w = world();
|
||||
const sword = w.createEntity('sword');
|
||||
sword.add('dmg', new Damage({ value: 20, damageType: 'physical' }));
|
||||
|
|
@ -22,11 +22,11 @@ describe('CombatSystem — damage', () => {
|
|||
const target = w.createEntity('target');
|
||||
target.add('health', new Health({ value: 100, min: 0 }));
|
||||
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' }));
|
||||
w.update(1);
|
||||
await w.update(1);
|
||||
expect(target.get(Health)!.value).toBe(80);
|
||||
});
|
||||
|
||||
it('defense on target reduces damage', () => {
|
||||
it('defense on target reduces damage', async () => {
|
||||
const w = world();
|
||||
const sword = w.createEntity('sword');
|
||||
sword.add('dmg', new Damage({ value: 20, damageType: 'physical' }));
|
||||
|
|
@ -35,11 +35,11 @@ describe('CombatSystem — damage', () => {
|
|||
target.add('health', new Health({ value: 100, min: 0 }));
|
||||
target.add('armor', new Defense({ value: 8, damageType: 'physical' }));
|
||||
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' }));
|
||||
w.update(1);
|
||||
await w.update(1);
|
||||
expect(target.get(Health)!.value).toBe(88);
|
||||
});
|
||||
|
||||
it('defense only applies to matching damage type', () => {
|
||||
it('defense only applies to matching damage type', async () => {
|
||||
const w = world();
|
||||
const spell = w.createEntity('spell');
|
||||
spell.add('dmg', new Damage({ value: 20, damageType: 'fire' }));
|
||||
|
|
@ -48,11 +48,11 @@ describe('CombatSystem — damage', () => {
|
|||
target.add('health', new Health({ value: 100, min: 0 }));
|
||||
target.add('armor', new Defense({ value: 8, damageType: 'physical' }));
|
||||
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'spell' }));
|
||||
w.update(1);
|
||||
await w.update(1);
|
||||
expect(target.get(Health)!.value).toBe(80);
|
||||
});
|
||||
|
||||
it('minDamage is enforced when defense exceeds damage', () => {
|
||||
it('minDamage is enforced when defense exceeds damage', async () => {
|
||||
const w = world();
|
||||
const sword = w.createEntity('sword');
|
||||
sword.add('dmg', new Damage({ value: 5, damageType: 'physical', minDamage: 3 }));
|
||||
|
|
@ -61,22 +61,22 @@ describe('CombatSystem — damage', () => {
|
|||
target.add('health', new Health({ value: 100, min: 0 }));
|
||||
target.add('armor', new Defense({ value: 10, damageType: 'physical' }));
|
||||
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' }));
|
||||
w.update(1);
|
||||
await w.update(1);
|
||||
expect(target.get(Health)!.value).toBe(97);
|
||||
});
|
||||
|
||||
it('null sourceId falls back to Damage on attacker', () => {
|
||||
it('null sourceId falls back to Damage on attacker', async () => {
|
||||
const w = world();
|
||||
const attacker = w.createEntity('attacker');
|
||||
attacker.add('dmg', new Damage({ value: 15, damageType: 'physical' }));
|
||||
const target = w.createEntity('target');
|
||||
target.add('health', new Health({ value: 100, min: 0 }));
|
||||
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: null }));
|
||||
w.update(1);
|
||||
await w.update(1);
|
||||
expect(target.get(Health)!.value).toBe(85);
|
||||
});
|
||||
|
||||
it('multiple attacks in one tick accumulate', () => {
|
||||
it('multiple attacks in one tick accumulate', async () => {
|
||||
const w = world();
|
||||
const sword = w.createEntity('sword');
|
||||
sword.add('dmg', new Damage({ value: 10, damageType: 'physical' }));
|
||||
|
|
@ -85,11 +85,11 @@ describe('CombatSystem — damage', () => {
|
|||
target.add('health', new Health({ value: 100, min: 0 }));
|
||||
target.add('atk1', new Attacked({ attackerId: 'a', sourceId: 'sword' }));
|
||||
target.add('atk2', new Attacked({ attackerId: 'a', sourceId: 'sword' }));
|
||||
w.update(1);
|
||||
await w.update(1);
|
||||
expect(target.get(Health)!.value).toBe(80);
|
||||
});
|
||||
|
||||
it('Attacked components are removed after processing', () => {
|
||||
it('Attacked components are removed after processing', async () => {
|
||||
const w = world();
|
||||
const sword = w.createEntity('sword');
|
||||
sword.add('dmg', new Damage({ value: 5, damageType: 'physical' }));
|
||||
|
|
@ -97,62 +97,62 @@ describe('CombatSystem — damage', () => {
|
|||
const target = w.createEntity('target');
|
||||
target.add('health', new Health({ value: 100, min: 0 }));
|
||||
target.add('atk', new Attacked({ attackerId: 'a', sourceId: 'sword' }));
|
||||
w.update(1);
|
||||
await w.update(1);
|
||||
expect(target.has(Attacked)).toBeFalse();
|
||||
});
|
||||
|
||||
it('missing attacker entity skips attack gracefully', () => {
|
||||
it('missing attacker entity skips attack gracefully', async () => {
|
||||
const w = world();
|
||||
const target = w.createEntity('target');
|
||||
target.add('health', new Health({ value: 100, min: 0 }));
|
||||
target.add('atk', new Attacked({ attackerId: 'ghost', sourceId: null }));
|
||||
w.update(1);
|
||||
await w.update(1);
|
||||
expect(target.get(Health)!.value).toBe(100);
|
||||
});
|
||||
|
||||
it('missing source entity skips attack gracefully', () => {
|
||||
it('missing source entity skips attack gracefully', async () => {
|
||||
const w = world();
|
||||
w.createEntity('attacker');
|
||||
const target = w.createEntity('target');
|
||||
target.add('health', new Health({ value: 100, min: 0 }));
|
||||
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'gone' }));
|
||||
w.update(1);
|
||||
await w.update(1);
|
||||
expect(target.get(Health)!.value).toBe(100);
|
||||
});
|
||||
|
||||
it('source with no Damage component skips attack gracefully', () => {
|
||||
it('source with no Damage component skips attack gracefully', async () => {
|
||||
const w = world();
|
||||
w.createEntity('attacker');
|
||||
w.createEntity('empty_source');
|
||||
const target = w.createEntity('target');
|
||||
target.add('health', new Health({ value: 100, min: 0 }));
|
||||
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'empty_source' }));
|
||||
w.update(1);
|
||||
await w.update(1);
|
||||
expect(target.get(Health)!.value).toBe(100);
|
||||
});
|
||||
|
||||
it('target with no Health component is skipped gracefully', () => {
|
||||
it('target with no Health component is skipped gracefully', async () => {
|
||||
const w = world();
|
||||
const sword = w.createEntity('sword');
|
||||
sword.add('dmg', new Damage({ value: 10, damageType: 'physical' }));
|
||||
w.createEntity('attacker');
|
||||
const target = w.createEntity('target');
|
||||
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' }));
|
||||
w.update(1); // should not throw
|
||||
await w.update(1); // should not throw
|
||||
});
|
||||
});
|
||||
|
||||
describe('CombatSystem — on-hit effects', () => {
|
||||
it('onHit effect is applied to target on hit', () => {
|
||||
it('onHit effect is applied to target on hit', async () => {
|
||||
const w = world();
|
||||
const sword = w.createEntity('sword');
|
||||
sword.add('dmg', new Damage({ value: 10, damageType: 'physical' }));
|
||||
sword.add('burn', new Effect({ targetStat: 'health', delta: -5, targetField: 'value', duration: 10, scope: 'onHit' }));
|
||||
sword.add('burn', new Effect('health', -5, 'value', 10, undefined, 'onHit'));
|
||||
w.createEntity('attacker');
|
||||
const target = w.createEntity('target');
|
||||
target.add('health', new Health({ value: 100, min: 0 }));
|
||||
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' }));
|
||||
w.update(1);
|
||||
await w.update(1);
|
||||
expect(target.get(Health)!.value).toBe(85); // 100 - 10 dmg - 5 burn modifier
|
||||
expect(target.getAll(Effect).length).toBe(1);
|
||||
});
|
||||
|
|
@ -162,97 +162,29 @@ describe('CombatSystem — on-hit effects', () => {
|
|||
const sword = w.createEntity('sword');
|
||||
sword.add('dmg', new Damage({ value: 10, damageType: 'physical' }));
|
||||
sword.add('str', new Stat({ value: 50 }));
|
||||
sword.add('drain', new Effect({ targetStat: 'str', delta: -99, targetField: 'value', scope: 'onHit' }));
|
||||
sword.add('drain', new Effect('str', -99, 'value', undefined, undefined, 'onHit'));
|
||||
expect(sword.get(Stat, 'str')!.value).toBe(50);
|
||||
});
|
||||
|
||||
it('onHit effect expires on target after duration', () => {
|
||||
it('onHit effect expires on target after duration', async () => {
|
||||
const w = world();
|
||||
const sword = w.createEntity('sword');
|
||||
sword.add('dmg', new Damage({ value: 5, damageType: 'physical' }));
|
||||
sword.add('burn', new Effect({ targetStat: 'health', delta: -10, targetField: 'value', duration: 2, scope: 'onHit' }));
|
||||
sword.add('burn', new Effect('health', -10, 'value', 2, undefined, 'onHit'));
|
||||
w.createEntity('attacker');
|
||||
const target = w.createEntity('target');
|
||||
target.add('health', new Health({ value: 100, min: 0 }));
|
||||
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' }));
|
||||
w.update(1);
|
||||
await w.update(1);
|
||||
const afterHit = target.get(Health)!.value; // 85
|
||||
w.update(2); // burn expires
|
||||
await w.update(2); // burn expires
|
||||
expect(target.getAll(Effect).length).toBe(0);
|
||||
expect(target.get(Health)!.value).toBe(afterHit + 10); // modifier reverted
|
||||
});
|
||||
});
|
||||
|
||||
describe('Effect stacking', () => {
|
||||
it('stack: allows multiple effects with the same tag', () => {
|
||||
const w = world();
|
||||
const target = w.createEntity('target');
|
||||
target.add('health', new Health({ value: 100, min: 0 }));
|
||||
target.add('e1', new Effect({ targetStat: 'health', delta: -5, stacking: 'stack', tag: 'poison' }));
|
||||
target.add('e2', new Effect({ targetStat: 'health', delta: -5, stacking: 'stack', tag: 'poison' }));
|
||||
expect(target.getAll(Effect).length).toBe(2);
|
||||
expect(target.get(Health)!.value).toBe(90);
|
||||
});
|
||||
|
||||
it('unique: second effect with same tag is discarded', () => {
|
||||
const w = world();
|
||||
const target = w.createEntity('target');
|
||||
target.add('health', new Health({ value: 100, min: 0 }));
|
||||
target.add('e1', new Effect({ targetStat: 'health', delta: -5, stacking: 'unique', tag: 'poison' }));
|
||||
target.add('e2', new Effect({ targetStat: 'health', delta: -5, stacking: 'unique', tag: 'poison' }));
|
||||
expect(target.getAll(Effect).length).toBe(1);
|
||||
expect(target.get(Health)!.value).toBe(95);
|
||||
});
|
||||
|
||||
it('unique: effects with different tags both apply', () => {
|
||||
const w = world();
|
||||
const target = w.createEntity('target');
|
||||
target.add('health', new Health({ value: 100, min: 0 }));
|
||||
target.add('e1', new Effect({ targetStat: 'health', delta: -5, stacking: 'unique', tag: 'poison' }));
|
||||
target.add('e2', new Effect({ targetStat: 'health', delta: -3, stacking: 'unique', tag: 'burn' }));
|
||||
expect(target.getAll(Effect).length).toBe(2);
|
||||
expect(target.get(Health)!.value).toBe(92);
|
||||
});
|
||||
|
||||
it('replace: removes existing effect and applies new one', () => {
|
||||
const w = world();
|
||||
const target = w.createEntity('target');
|
||||
target.add('health', new Health({ value: 100, min: 0 }));
|
||||
target.add('e1', new Effect({ targetStat: 'health', delta: -5, stacking: 'replace', tag: 'poison' }));
|
||||
expect(target.get(Health)!.value).toBe(95);
|
||||
target.add('e2', new Effect({ targetStat: 'health', delta: -10, stacking: 'replace', tag: 'poison' }));
|
||||
expect(target.getAll(Effect).length).toBe(1);
|
||||
expect(target.get(Health)!.value).toBe(90); // old -5 reversed, new -10 applied
|
||||
});
|
||||
|
||||
it('no tag: unique mode does not enforce limits', () => {
|
||||
const w = world();
|
||||
const target = w.createEntity('target');
|
||||
target.add('health', new Health({ value: 100, min: 0 }));
|
||||
target.add('e1', new Effect({ targetStat: 'health', delta: -5, stacking: 'unique' }));
|
||||
target.add('e2', new Effect({ targetStat: 'health', delta: -5, stacking: 'unique' }));
|
||||
expect(target.getAll(Effect).length).toBe(2);
|
||||
expect(target.get(Health)!.value).toBe(90);
|
||||
});
|
||||
|
||||
it('unique onHit effect is discarded when same tag already on target', () => {
|
||||
const w = world();
|
||||
const sword = w.createEntity('sword');
|
||||
sword.add('dmg', new Damage({ value: 0, damageType: 'physical' }));
|
||||
sword.add('burn', new Effect({ targetStat: 'health', delta: -5, duration: 10, scope: 'onHit', stacking: 'unique', tag: 'burn' }));
|
||||
w.createEntity('attacker');
|
||||
const target = w.createEntity('target');
|
||||
target.add('health', new Health({ value: 100, min: 0 }));
|
||||
target.add('pre', new Effect({ targetStat: 'health', delta: -5, stacking: 'unique', tag: 'burn' }));
|
||||
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' }));
|
||||
w.update(1);
|
||||
expect(target.getAll(Effect).length).toBe(1); // onHit copy discarded
|
||||
expect(target.get(Health)!.value).toBe(95);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CombatSystem — events', () => {
|
||||
it("emits 'hit' on target with attack info", () => {
|
||||
it("emits 'hit' on target with attack info", async () => {
|
||||
const w = world();
|
||||
const sword = w.createEntity('sword');
|
||||
sword.add('dmg', new Damage({ value: 10, damageType: 'fire' }));
|
||||
|
|
@ -262,7 +194,7 @@ describe('CombatSystem — events', () => {
|
|||
const hits: unknown[] = [];
|
||||
target.on('hit', ({ data }) => hits.push(data));
|
||||
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' }));
|
||||
w.update(1);
|
||||
await w.update(1);
|
||||
expect(hits.length).toBe(1);
|
||||
expect((hits[0] as any).damageType).toBe('fire');
|
||||
expect((hits[0] as any).amount).toBe(10);
|
||||
|
|
@ -270,7 +202,7 @@ describe('CombatSystem — events', () => {
|
|||
expect((hits[0] as any).sourceId).toBe('sword');
|
||||
});
|
||||
|
||||
it("emits 'kill' when target health reaches zero", () => {
|
||||
it("emits 'kill' when target health reaches zero", async () => {
|
||||
const w = world();
|
||||
const sword = w.createEntity('sword');
|
||||
sword.add('dmg', new Damage({ value: 999, damageType: 'physical' }));
|
||||
|
|
@ -280,11 +212,11 @@ describe('CombatSystem — events', () => {
|
|||
const kills: unknown[] = [];
|
||||
target.on('kill', ({ data }) => kills.push(data));
|
||||
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' }));
|
||||
w.update(1);
|
||||
await w.update(1);
|
||||
expect(kills.length).toBe(1);
|
||||
});
|
||||
|
||||
it("does not emit 'kill' when target survives", () => {
|
||||
it("does not emit 'kill' when target survives", async () => {
|
||||
const w = world();
|
||||
const sword = w.createEntity('sword');
|
||||
sword.add('dmg', new Damage({ value: 5, damageType: 'physical' }));
|
||||
|
|
@ -294,11 +226,11 @@ describe('CombatSystem — events', () => {
|
|||
const kills: unknown[] = [];
|
||||
target.on('kill', ({ data }) => kills.push(data));
|
||||
target.add('atk', new Attacked({ attackerId: 'attacker', sourceId: 'sword' }));
|
||||
w.update(1);
|
||||
await w.update(1);
|
||||
expect(kills.length).toBe(0);
|
||||
});
|
||||
|
||||
it("emits 'hit' per attack when multiple attacks land", () => {
|
||||
it("emits 'hit' per attack when multiple attacks land", async () => {
|
||||
const w = world();
|
||||
const sword = w.createEntity('sword');
|
||||
sword.add('dmg', new Damage({ value: 5, damageType: 'physical' }));
|
||||
|
|
@ -309,7 +241,7 @@ describe('CombatSystem — events', () => {
|
|||
target.on('hit', ({ data }) => hits.push(data));
|
||||
target.add('atk1', new Attacked({ attackerId: 'a', sourceId: 'sword' }));
|
||||
target.add('atk2', new Attacked({ attackerId: 'a', sourceId: 'sword' }));
|
||||
w.update(1);
|
||||
await w.update(1);
|
||||
expect(hits.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -136,33 +136,33 @@ describe('Cooldown — update(dt)', () => {
|
|||
});
|
||||
|
||||
describe('CooldownSystem', () => {
|
||||
it('drives all cooldowns each tick', () => {
|
||||
it('drives all cooldowns each tick', async () => {
|
||||
const w = world();
|
||||
const e = w.createEntity();
|
||||
e.add('cd', new Cooldown(2));
|
||||
w.update(1);
|
||||
await w.update(1);
|
||||
expect(e.get(Cooldown, 'cd')!.state.remaining).toBe(1);
|
||||
});
|
||||
|
||||
it('marks cooldown ready after enough ticks', () => {
|
||||
it('marks cooldown ready after enough ticks', async () => {
|
||||
const w = world();
|
||||
const e = w.createEntity();
|
||||
e.add('cd', new Cooldown(2));
|
||||
const events: unknown[] = [];
|
||||
e.on('cd.ready', () => events.push(true));
|
||||
w.update(1);
|
||||
w.update(1);
|
||||
await w.update(1);
|
||||
await w.update(1);
|
||||
expect(events.length).toBe(1);
|
||||
expect(e.get(Cooldown, 'cd')!.ready).toBeTrue();
|
||||
});
|
||||
|
||||
it('handles multiple cooldowns on different entities', () => {
|
||||
it('handles multiple cooldowns on different entities', async () => {
|
||||
const w = world();
|
||||
const a = w.createEntity('a');
|
||||
const b = w.createEntity('b');
|
||||
a.add('cd', new Cooldown(1));
|
||||
b.add('cd', new Cooldown(3));
|
||||
w.update(2);
|
||||
await w.update(2);
|
||||
expect(a.get(Cooldown, 'cd')!.ready).toBeTrue();
|
||||
expect(b.get(Cooldown, 'cd')!.ready).toBeFalse();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -20,13 +20,13 @@ function withStat(value = 10, min?: number, max?: number) {
|
|||
describe('Effect — onAdd / onRemove', () => {
|
||||
it('applies delta to stat on add', () => {
|
||||
const { e, stat } = withStat(10);
|
||||
e.add('fx', new Effect({ targetStat: 'str', delta: 5 }));
|
||||
e.add('fx', new Effect('str', 5));
|
||||
expect(stat.value).toBe(15);
|
||||
});
|
||||
|
||||
it('reverts delta on remove', () => {
|
||||
const { e, stat } = withStat(10);
|
||||
e.add('fx', new Effect({ targetStat: 'str', delta: 5 }));
|
||||
e.add('fx', new Effect('str', 5));
|
||||
e.remove('fx');
|
||||
expect(stat.value).toBe(10);
|
||||
});
|
||||
|
|
@ -34,7 +34,7 @@ describe('Effect — onAdd / onRemove', () => {
|
|||
it('applies delta to max field', () => {
|
||||
const { e } = withStat(10, undefined, 20);
|
||||
const s = e.get(Stat, 'str')!;
|
||||
e.add('fx', new Effect({ targetStat: 'str', delta: 10, targetField: 'max' }));
|
||||
e.add('fx', new Effect('str', 10, 'max'));
|
||||
expect(s.max).toBe(30);
|
||||
e.remove('fx');
|
||||
expect(s.max).toBe(20);
|
||||
|
|
@ -42,73 +42,73 @@ describe('Effect — onAdd / onRemove', () => {
|
|||
|
||||
it('active is true after add', () => {
|
||||
const { e } = withStat(10);
|
||||
e.add('fx', new Effect({ targetStat: 'str', delta: 1 }));
|
||||
e.add('fx', new Effect('str', 1));
|
||||
expect(e.get(Effect, 'fx')!.active).toBeTrue();
|
||||
});
|
||||
|
||||
it('no-op if target stat is missing', () => {
|
||||
const w = world();
|
||||
const e = w.createEntity();
|
||||
expect(() => e.add('fx', new Effect({ targetStat: 'str', delta: 5 }))).not.toThrow();
|
||||
expect(() => e.add('fx', new Effect('str', 5))).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Effect — duration', () => {
|
||||
it('expires after duration ticks', () => {
|
||||
it('expires after duration ticks', async () => {
|
||||
const { w, e, stat } = withStat(10);
|
||||
e.add('fx', new Effect({ targetStat: 'str', delta: 5, duration: 2 }));
|
||||
e.add('fx', new Effect('str', 5, 'value', 2));
|
||||
expect(stat.value).toBe(15);
|
||||
|
||||
w.update(1);
|
||||
await w.update(1);
|
||||
expect(e.has(Effect)).toBeTrue();
|
||||
|
||||
w.update(1);
|
||||
await w.update(1);
|
||||
expect(e.has(Effect)).toBeFalse();
|
||||
expect(stat.value).toBe(10);
|
||||
});
|
||||
|
||||
it('emits expired before removal', () => {
|
||||
it('emits expired before removal', async () => {
|
||||
const { w, e } = withStat(10);
|
||||
e.add('fx', new Effect({ targetStat: 'str', delta: 1, duration: 1 }));
|
||||
e.add('fx', new Effect('str', 1, 'value', 1));
|
||||
const events: string[] = [];
|
||||
e.on('fx.expired', () => events.push('expired'));
|
||||
w.update(1);
|
||||
await w.update(1);
|
||||
expect(events).toEqual(['expired']);
|
||||
});
|
||||
|
||||
it('reset() restarts timer', () => {
|
||||
it('reset() restarts timer', async () => {
|
||||
const { w, e, stat } = withStat(10);
|
||||
e.add('fx', new Effect({ targetStat: 'str', delta: 5, duration: 2 }));
|
||||
w.update(1.5);
|
||||
e.add('fx', new Effect('str', 5, 'value', 2));
|
||||
await w.update(1.5);
|
||||
e.get(Effect, 'fx')!.reset();
|
||||
w.update(1.5); // would have expired without reset
|
||||
await w.update(1.5); // would have expired without reset
|
||||
expect(e.has(Effect)).toBeTrue();
|
||||
expect(stat.value).toBe(15);
|
||||
});
|
||||
|
||||
it('reset(duration) changes duration', () => {
|
||||
it('reset(duration) changes duration', async () => {
|
||||
const { w, e } = withStat(10);
|
||||
e.add('fx', new Effect({ targetStat: 'str', delta: 5, duration: 1 }));
|
||||
e.add('fx', new Effect('str', 5, 'value', 1));
|
||||
e.get(Effect, 'fx')!.reset(10);
|
||||
w.update(5);
|
||||
await w.update(5);
|
||||
expect(e.has(Effect)).toBeTrue();
|
||||
});
|
||||
|
||||
it('clear() immediately expires effect', () => {
|
||||
it('clear() immediately expires effect', async () => {
|
||||
const { w, e, stat } = withStat(10);
|
||||
e.add('fx', new Effect({ targetStat: 'str', delta: 5, duration: 100 }));
|
||||
e.add('fx', new Effect('str', 5, 'value', 100));
|
||||
e.get(Effect, 'fx')!.clear();
|
||||
w.update(0.01);
|
||||
await w.update(0.01);
|
||||
expect(e.has(Effect)).toBeFalse();
|
||||
expect(stat.value).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Effect — permanent', () => {
|
||||
it('permanent effect is never removed by EffectSystem', () => {
|
||||
it('permanent effect is never removed by EffectSystem', async () => {
|
||||
const { w, e, stat } = withStat(10);
|
||||
e.add('fx', new Effect({ targetStat: 'str', delta: 5 }));
|
||||
w.update(100);
|
||||
e.add('fx', new Effect('str', 5));
|
||||
await w.update(100);
|
||||
expect(e.has(Effect)).toBeTrue();
|
||||
expect(stat.value).toBe(15);
|
||||
});
|
||||
|
|
@ -117,27 +117,27 @@ describe('Effect — permanent', () => {
|
|||
describe('Effect — scope: onHit', () => {
|
||||
it('onAdd is a no-op for onHit scope', () => {
|
||||
const { e, stat } = withStat(10);
|
||||
e.add('fx', new Effect({ targetStat: 'str', delta: 99, scope: 'onHit' }));
|
||||
e.add('fx', new Effect('str', 99, 'value', undefined, undefined, 'onHit'));
|
||||
expect(stat.value).toBe(10);
|
||||
});
|
||||
|
||||
it('onRemove is a no-op for onHit scope', () => {
|
||||
const { e, stat } = withStat(10);
|
||||
e.add('fx', new Effect({ targetStat: 'str', delta: 99, scope: 'onHit' }));
|
||||
e.add('fx', new Effect('str', 99, 'value', undefined, undefined, 'onHit'));
|
||||
e.remove('fx');
|
||||
expect(stat.value).toBe(10);
|
||||
});
|
||||
|
||||
it('EffectSystem does not tick or remove onHit effects', () => {
|
||||
it('EffectSystem does not tick or remove onHit effects', async () => {
|
||||
const { w, e } = withStat(10);
|
||||
e.add('fx', new Effect({ targetStat: 'str', delta: 5, duration: 1, scope: 'onHit' }));
|
||||
w.update(10);
|
||||
e.add('fx', new Effect('str', 5, 'value', 1, undefined, 'onHit'));
|
||||
await w.update(10);
|
||||
expect(e.has(Effect)).toBeTrue();
|
||||
});
|
||||
|
||||
it('active stays false for onHit scope', () => {
|
||||
const { e } = withStat(10);
|
||||
e.add('fx', new Effect({ targetStat: 'str', delta: 5, scope: 'onHit' }));
|
||||
e.add('fx', new Effect('str', 5, 'value', undefined, undefined, 'onHit'));
|
||||
expect(e.get(Effect, 'fx')!.active).toBeFalse();
|
||||
});
|
||||
});
|
||||
|
|
@ -145,15 +145,15 @@ describe('Effect — scope: onHit', () => {
|
|||
describe('Effect — multiple effects on same stat', () => {
|
||||
it('multiple effects stack additively', () => {
|
||||
const { e, stat } = withStat(10);
|
||||
e.add('a', new Effect({ targetStat: 'str', delta: 3 }));
|
||||
e.add('b', new Effect({ targetStat: 'str', delta: 7 }));
|
||||
e.add('a', new Effect('str', 3));
|
||||
e.add('b', new Effect('str', 7));
|
||||
expect(stat.value).toBe(20);
|
||||
});
|
||||
|
||||
it('removing one effect reverts only that delta', () => {
|
||||
const { e, stat } = withStat(10);
|
||||
e.add('a', new Effect({ targetStat: 'str', delta: 3 }));
|
||||
e.add('b', new Effect({ targetStat: 'str', delta: 7 }));
|
||||
e.add('a', new Effect('str', 3));
|
||||
e.add('b', new Effect('str', 7));
|
||||
e.remove('a');
|
||||
expect(stat.value).toBe(17);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ describe('Equipment — equip-scope effects', () => {
|
|||
it('clones equip-scope Effect onto owner on equip', () => {
|
||||
const w = world();
|
||||
const sword = makeSword(w);
|
||||
sword.add('bonus', new Effect({ targetStat: 'str', delta: 5 })); // scope: equip (default)
|
||||
sword.add('bonus', new Effect('str', 5)); // scope: equip (default)
|
||||
const player = makePlayer(w);
|
||||
player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' });
|
||||
expect(player.get(Stat, 'str')!.value).toBe(15);
|
||||
|
|
@ -100,7 +100,7 @@ describe('Equipment — equip-scope effects', () => {
|
|||
it('does NOT clone onHit-scope Effect onto owner on equip', () => {
|
||||
const w = world();
|
||||
const sword = makeSword(w);
|
||||
sword.add('burn', new Effect({ targetStat: 'str', delta: 99, scope: 'onHit' }));
|
||||
sword.add('burn', new Effect('str', 99, 'value', undefined, undefined, 'onHit'));
|
||||
const player = makePlayer(w);
|
||||
player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' });
|
||||
expect(player.get(Stat, 'str')!.value).toBe(10); // unaffected
|
||||
|
|
@ -110,8 +110,8 @@ describe('Equipment — equip-scope effects', () => {
|
|||
it('clones multiple equip effects', () => {
|
||||
const w = world();
|
||||
const sword = makeSword(w);
|
||||
sword.add('a', new Effect({ targetStat: 'str', delta: 3 }));
|
||||
sword.add('b', new Effect({ targetStat: 'str', delta: 7 }));
|
||||
sword.add('a', new Effect('str', 3));
|
||||
sword.add('b', new Effect('str', 7));
|
||||
const player = makePlayer(w);
|
||||
player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' });
|
||||
expect(player.get(Stat, 'str')!.value).toBe(20);
|
||||
|
|
@ -120,8 +120,8 @@ describe('Equipment — equip-scope effects', () => {
|
|||
it('equip + onHit: only equip effects reach owner', () => {
|
||||
const w = world();
|
||||
const sword = makeSword(w);
|
||||
sword.add('passive', new Effect({ targetStat: 'str', delta: 5 }));
|
||||
sword.add('burn', new Effect({ targetStat: 'str', delta: 99, scope: 'onHit' }));
|
||||
sword.add('passive', new Effect('str', 5));
|
||||
sword.add('burn', new Effect('str', 99, 'value', undefined, undefined, 'onHit'));
|
||||
const player = makePlayer(w);
|
||||
player.get(Equipment)!.equip({ slotName: 'weapon', itemId: 'sword' });
|
||||
expect(player.get(Stat, 'str')!.value).toBe(15);
|
||||
|
|
@ -133,7 +133,7 @@ describe('Equipment — unequip', () => {
|
|||
it('unequip reverts cloned effects', () => {
|
||||
const w = world();
|
||||
const sword = makeSword(w);
|
||||
sword.add('bonus', new Effect({ targetStat: 'str', delta: 5 }));
|
||||
sword.add('bonus', new Effect('str', 5));
|
||||
const player = makePlayer(w);
|
||||
const eq = player.get(Equipment)!;
|
||||
eq.equip({ slotName: 'weapon', itemId: 'sword' });
|
||||
|
|
|
|||
|
|
@ -1,128 +0,0 @@
|
|||
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';
|
||||
|
||||
function world() { return new World(); }
|
||||
|
||||
describe('FactionMember', () => {
|
||||
it('join() adds membership, isMember() returns 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', () => {
|
||||
const w = world();
|
||||
const e = w.createEntity();
|
||||
Factions.join(e, 'guards');
|
||||
Factions.leave(e, 'guards');
|
||||
expect(Factions.isMember(e, 'guards')).toBeFalse();
|
||||
});
|
||||
|
||||
it('isMember() returns false when entity has no FactionMember', () => {
|
||||
const w = world();
|
||||
const e = w.createEntity();
|
||||
expect(Factions.isMember(e, 'guards')).toBeFalse();
|
||||
});
|
||||
|
||||
it('getFactions() returns all factionIds', () => {
|
||||
const w = world();
|
||||
const e = w.createEntity();
|
||||
Factions.join(e, 'guards');
|
||||
Factions.join(e, 'merchants');
|
||||
expect(Factions.getFactions(e).sort()).toEqual(['guards', 'merchants']);
|
||||
});
|
||||
|
||||
it('member variable resolves on world', () => {
|
||||
const w = world();
|
||||
const e = w.createEntity('player');
|
||||
Factions.join(e, 'guards');
|
||||
expect(resolveVariables(w)['player.faction.guards.member']).toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reputation', () => {
|
||||
it('getReputation() returns 0 when no component', () => {
|
||||
const w = world();
|
||||
const e = w.createEntity();
|
||||
expect(Factions.getReputation(e, 'guards')).toBe(0);
|
||||
});
|
||||
|
||||
it('setReputation() creates component on first call', () => {
|
||||
const w = world();
|
||||
const e = w.createEntity();
|
||||
Factions.setReputation(e, 'guards', 50);
|
||||
expect(Factions.getReputation(e, 'guards')).toBe(50);
|
||||
});
|
||||
|
||||
it('setReputation() updates score on second call without duplicate component', () => {
|
||||
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, 'faction.guards.rep')!;
|
||||
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.faction.guards.rep']).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Factions.getReputationBetween', () => {
|
||||
it('returns null when target has no factions', () => {
|
||||
const w = world();
|
||||
const observer = 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.setReputation(observer, 'guards', 40);
|
||||
expect(Factions.getReputationBetween(observer, target)).toBe(40);
|
||||
});
|
||||
|
||||
it('returns the minimum when target belongs to multiple factions', () => {
|
||||
const w = world();
|
||||
const observer = w.createEntity();
|
||||
const target = w.createEntity();
|
||||
Factions.join(target, 'guards');
|
||||
Factions.join(target, 'bandits');
|
||||
Factions.setReputation(observer, 'guards', 60);
|
||||
Factions.setReputation(observer, 'bandits', -30);
|
||||
expect(Factions.getReputationBetween(observer, target)).toBe(-30);
|
||||
});
|
||||
});
|
||||
|
|
@ -235,7 +235,7 @@ describe('Inventory — equip', () => {
|
|||
});
|
||||
|
||||
describe('Inventory — use', () => {
|
||||
it('executes Usable actions', () => {
|
||||
it('executes Usable actions', async () => {
|
||||
const w = world();
|
||||
const player = w.createEntity('player');
|
||||
player.add('str', new Stat({ value: 10 }));
|
||||
|
|
@ -244,11 +244,11 @@ describe('Inventory — use', () => {
|
|||
usable: { actions: [{ type: 'str.update', arg: 5 }], consumeOnUse: false },
|
||||
});
|
||||
player.get(Inventory)!.add({ itemId: 'potion', amount: 1 });
|
||||
player.get(Inventory)!.use({ itemId: 'potion' });
|
||||
await player.get(Inventory)!.use({ itemId: 'potion' });
|
||||
expect(player.get(Stat, 'str')!.value).toBe(15);
|
||||
});
|
||||
|
||||
it('consumes item when consumeOnUse is true', () => {
|
||||
it('consumes item when consumeOnUse is true', async () => {
|
||||
const w = world();
|
||||
const player = w.createEntity('player');
|
||||
player.add('inv', new Inventory());
|
||||
|
|
@ -257,7 +257,7 @@ describe('Inventory — use', () => {
|
|||
usable: { actions: [], consumeOnUse: true },
|
||||
});
|
||||
player.get(Inventory)!.add({ itemId: 'herb', amount: 3 });
|
||||
player.get(Inventory)!.use({ itemId: 'herb' });
|
||||
await player.get(Inventory)!.use({ itemId: 'herb' });
|
||||
expect(player.get(Inventory)!.getAmount('herb')).toBe(2);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -371,38 +371,38 @@ describe('Quests.validate', () => {
|
|||
// ── QuestSystem — objective completion ────────────────────────────────────────
|
||||
|
||||
describe('QuestSystem — objective completion', () => {
|
||||
it('completes single-stage quest when objective is satisfied', () => {
|
||||
it('completes single-stage quest when objective is satisfied', async () => {
|
||||
const w = world();
|
||||
const { vars, questLog } = makePlayer(w, [simpleQuest()]);
|
||||
|
||||
questLog.start('q1');
|
||||
vars.set({ key: 'done', value: true });
|
||||
w.update(1);
|
||||
await w.update(1);
|
||||
|
||||
expect(questLog.getState('q1')?.status).toBe('completed');
|
||||
});
|
||||
|
||||
it('does not complete quest while objective is unsatisfied', () => {
|
||||
it('does not complete quest while objective is unsatisfied', async () => {
|
||||
const w = world();
|
||||
const { questLog } = makePlayer(w, [simpleQuest()]);
|
||||
|
||||
questLog.start('q1');
|
||||
w.update(1);
|
||||
await w.update(1);
|
||||
|
||||
expect(questLog.getState('q1')?.status).toBe('active');
|
||||
});
|
||||
|
||||
it('does not process inactive quests', () => {
|
||||
it('does not process inactive quests', async () => {
|
||||
const w = world();
|
||||
const { vars, questLog } = makePlayer(w, [simpleQuest()]);
|
||||
|
||||
vars.set({ key: 'done', value: true });
|
||||
w.update(1);
|
||||
await w.update(1);
|
||||
|
||||
expect(questLog.getState('q1')?.status).toBe('inactive');
|
||||
});
|
||||
|
||||
it('runs stage actions before advancing', () => {
|
||||
it('runs stage actions before advancing', async () => {
|
||||
const w = world();
|
||||
// action sets vars.reward = true on the player entity
|
||||
const quest = simpleQuest('q1', [{ type: 'vars.set', arg: { key: 'reward', value: true } }]);
|
||||
|
|
@ -410,7 +410,7 @@ describe('QuestSystem — objective completion', () => {
|
|||
|
||||
questLog.start('q1');
|
||||
vars.set({ key: 'done', value: true });
|
||||
w.update(1);
|
||||
await w.update(1);
|
||||
|
||||
expect(questLog.getState('q1')?.status).toBe('completed');
|
||||
expect(vars.state.vars['reward']).toBe(true);
|
||||
|
|
@ -418,7 +418,7 @@ describe('QuestSystem — objective completion', () => {
|
|||
});
|
||||
|
||||
describe('QuestSystem — multi-stage progression', () => {
|
||||
it('advances through stages as objectives are satisfied', () => {
|
||||
it('advances through stages as objectives are satisfied', async () => {
|
||||
const w = world();
|
||||
const { vars, questLog } = makePlayer(w, [twoStageQuest()]);
|
||||
|
||||
|
|
@ -426,34 +426,34 @@ describe('QuestSystem — multi-stage progression', () => {
|
|||
|
||||
// satisfy stage 0
|
||||
vars.set({ key: 'step', value: 1 });
|
||||
w.update(1);
|
||||
await w.update(1);
|
||||
expect(questLog.getState('q2')?.stageIndex).toBe(1);
|
||||
expect(questLog.getState('q2')?.status).toBe('active');
|
||||
|
||||
// satisfy stage 1
|
||||
vars.set({ key: 'step', value: 2 });
|
||||
w.update(1);
|
||||
await w.update(1);
|
||||
expect(questLog.getState('q2')?.status).toBe('completed');
|
||||
});
|
||||
|
||||
it('does not skip stages', () => {
|
||||
it('does not skip stages', async () => {
|
||||
const w = world();
|
||||
const { vars, questLog } = makePlayer(w, [twoStageQuest()]);
|
||||
|
||||
questLog.start('q2');
|
||||
vars.set({ key: 'step', value: 2 }); // would satisfy both stages
|
||||
w.update(1);
|
||||
await w.update(1);
|
||||
|
||||
// only advances one stage per tick
|
||||
expect(questLog.getState('q2')?.stageIndex).toBe(1);
|
||||
|
||||
w.update(1); // second tick completes it
|
||||
await w.update(1); // second tick completes it
|
||||
expect(questLog.getState('q2')?.status).toBe('completed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('QuestSystem — fail conditions', () => {
|
||||
it('fails quest when failCondition is met', () => {
|
||||
it('fails quest when failCondition is met', async () => {
|
||||
const w = world();
|
||||
const quest: Quest = {
|
||||
id: 'q1',
|
||||
|
|
@ -471,12 +471,12 @@ describe('QuestSystem — fail conditions', () => {
|
|||
|
||||
questLog.start('q1');
|
||||
vars.set({ key: 'failed', value: true });
|
||||
w.update(1);
|
||||
await w.update(1);
|
||||
|
||||
expect(questLog.getState('q1')?.status).toBe('failed');
|
||||
});
|
||||
|
||||
it('fail condition takes priority over objective completion', () => {
|
||||
it('fail condition takes priority over objective completion', async () => {
|
||||
const w = world();
|
||||
const quest: Quest = {
|
||||
id: 'q1',
|
||||
|
|
@ -494,14 +494,14 @@ describe('QuestSystem — fail conditions', () => {
|
|||
|
||||
questLog.start('q1');
|
||||
vars.set({ key: 'done', value: true });
|
||||
w.update(1);
|
||||
await w.update(1);
|
||||
|
||||
expect(questLog.getState('q1')?.status).toBe('failed'); // fail checked first
|
||||
});
|
||||
});
|
||||
|
||||
describe('QuestSystem — multiple quests', () => {
|
||||
it('tracks multiple quests independently', () => {
|
||||
it('tracks multiple quests independently', async () => {
|
||||
const w = world();
|
||||
const player = w.createEntity('player');
|
||||
const vars = player.add('vars', new Variables());
|
||||
|
|
@ -520,7 +520,7 @@ describe('QuestSystem — multiple quests', () => {
|
|||
log.start('q2');
|
||||
|
||||
vars.set({ key: 'done1', value: true }); // only q1's condition satisfied
|
||||
w.update(1);
|
||||
await w.update(1);
|
||||
|
||||
expect(log.getState('q1')?.status).toBe('completed');
|
||||
expect(log.getState('q2')?.status).toBe('active');
|
||||
|
|
|
|||
Loading…
Reference in New Issue