diff --git a/src/common/random.ts b/src/common/random.ts index 779610e..21540b3 100644 --- a/src/common/random.ts +++ b/src/common/random.ts @@ -304,10 +304,59 @@ export class SeededRandom { */ clone(): SeededRandom { const child = new SeededRandom(this.getState()); - this.next(); // diverge parent so the two streams never overlap + this.jump(); // diverge parent so the two streams never overlap return child; } + /** + * Advance the state by 2^64 steps (equivalent to 2^64 calls to next()). + * Use this to generate 2^64 non-overlapping subsequences for parallel work. + */ + jump(): void { + const JUMP = [0x8764000b, 0xf542d2d3, 0x6fa035c3, 0x77f2db5b]; + let s0 = 0, s1 = 0, s2 = 0, s3 = 0; + for (const j of JUMP) { + for (let b = 0; b < 32; b++) { + if ((j >>> b) & 1) { + s0 ^= this.s[0]; + s1 ^= this.s[1]; + s2 ^= this.s[2]; + s3 ^= this.s[3]; + } + this.next(); + } + } + this.s[0] = s0 >>> 0; + this.s[1] = s1 >>> 0; + this.s[2] = s2 >>> 0; + this.s[3] = s3 >>> 0; + } + + /** + * Advance the state by 2^96 steps (equivalent to 2^96 calls to next()). + * Generates 2^32 starting points; each jump() from those gives 2^32 + * non-overlapping subsequences — useful for distributed computation. + */ + longJump(): void { + const LONG_JUMP = [0xb523952e, 0x0b6f099f, 0xccf5a0ef, 0x1c580662]; + let s0 = 0, s1 = 0, s2 = 0, s3 = 0; + for (const j of LONG_JUMP) { + for (let b = 0; b < 32; b++) { + if ((j >>> b) & 1) { + s0 ^= this.s[0]; + s1 ^= this.s[1]; + s2 ^= this.s[2]; + s3 ^= this.s[3]; + } + this.next(); + } + } + this.s[0] = s0 >>> 0; + this.s[1] = s1 >>> 0; + this.s[2] = s2 >>> 0; + this.s[3] = s3 >>> 0; + } + // ------------------------------------------------------------------------- // Serialisation // ------------------------------------------------------------------------- diff --git a/src/common/rpg/components/effect.ts b/src/common/rpg/components/effect.ts index 69d038b..8c1363c 100644 --- a/src/common/rpg/components/effect.ts +++ b/src/common/rpg/components/effect.ts @@ -28,7 +28,6 @@ abstract class BaseEffect extends Component<{ condition?: string; stacking?: 'stack' | 'unique' | 'replace'; tag?: string; - perSecond?: boolean; }) { super({ target: typeof opts.target === 'string' @@ -45,6 +44,18 @@ abstract class BaseEffect extends Component<{ stacking: opts.stacking ?? 'stack', tag: opts.tag ?? null, }); + + if (opts.gradual && opts.duration == null) { + throw new Error('Effect cannot be gradual without a duration'); + } + + if (opts.duration != null && opts.duration <= 0) { + throw new Error('Effect duration must be greater than zero'); + } + + if (opts.permanent && opts.duration != null) { + throw new Error('Effect cannot be both permanent and have a duration'); + } } } @@ -57,6 +68,13 @@ export class Effect extends BaseEffect { override onAdd(): void { const { stacking, tag } = this.state; + if (this.state.permanent && this.state.condition) { + if (!evaluateCondition(this.state.condition, this.entity)) { + this.entity.remove(this); + return; + } + } + if (tag != null && stacking !== 'stack') { const siblings = this.entity.getAll(Effect).filter(e => e !== this && e.state.tag === tag); @@ -79,6 +97,10 @@ export class Effect extends BaseEffect { this.applyDelta(this.state.delta); } + if (this.state.permanent) { + this.entity.remove(this); // permanent effects are removed immediately + } + if (this.state.remaining || this.state.condition) { this.ensureEffectSystem(); } @@ -112,7 +134,7 @@ export class Effect extends BaseEffect { this.emit('expired'); } - update(dt: number, ctx: EvalContext): void { + update(dt: number): void { if (this.state.remaining != null) { if (this.state.remaining > 0) { this.state.remaining -= dt; @@ -126,7 +148,7 @@ export class Effect extends BaseEffect { } } } else if (this.state.condition != null) { - if (!evaluateCondition(this.state.condition, ctx)) { + if (!evaluateCondition(this.state.condition, this.entity)) { this.clear(); } } diff --git a/src/common/rpg/components/inventory.ts b/src/common/rpg/components/inventory.ts index 810c9dc..4205daf 100644 --- a/src/common/rpg/components/inventory.ts +++ b/src/common/rpg/components/inventory.ts @@ -233,7 +233,7 @@ export class Inventory extends Component { * `slotId` specifies which inventory slot to use from (otherwise any slot is used). */ @action - use(arg?: string | { itemId?: string; slotId?: SlotId }, ctx?: EvalContext): boolean { + use(arg?: string | { itemId?: string; slotId?: SlotId }): boolean { const resolved = this.#resolveItem(arg); if (!resolved) return false; const { itemId, slotId } = resolved; @@ -257,7 +257,7 @@ export class Inventory extends Component { if (usable.consumeOnUse) this.remove({ itemId, amount: 1, slotId }); - usable.use(ctx ?? this.context); + usable.use(this.entity); return true; } diff --git a/src/common/rpg/components/item.ts b/src/common/rpg/components/item.ts index 4543b8f..ff989e6 100644 --- a/src/common/rpg/components/item.ts +++ b/src/common/rpg/components/item.ts @@ -46,11 +46,9 @@ export class Usable extends Component { @variable get consumeOnUse(): boolean { return this.state.consumeOnUse; } @action - use(arg?: EvalContext, ctx?: EvalContext): void { - ctx = arg ?? ctx ?? this.context; - if (!ctx) return; + use(user: EvalContext): void { for (const action of this.state.actions) { - executeAction(action, ctx); + executeAction(action, user); } } } diff --git a/src/common/rpg/core/world.ts b/src/common/rpg/core/world.ts index 7a2552b..6ca6daa 100644 --- a/src/common/rpg/core/world.ts +++ b/src/common/rpg/core/world.ts @@ -14,10 +14,7 @@ interface WorldEvent { type EntityEventHandler = (event: EntityEvent) => void; type WorldEventHandler = (event: WorldEvent) => void; -export interface EvalContext { - self: Entity | World; - world: World; -} +export type EvalContext = Entity | World; /** Symbol used by Serialization to access World's entity counter. */ export const WORLD_ENTITY_COUNTER = Symbol('rpg.world.entityCounter'); @@ -96,10 +93,6 @@ export abstract class Component> { } return actions; } - - get context(): EvalContext { - return this.entity.context; - } } export abstract class System { @@ -118,10 +111,6 @@ export class Entity { readonly world: World, ) { } - get context(): EvalContext { - return { self: this, world: this.world }; - } - add>(component: T, k?: string): T { const key = k ?? Symbol(); @@ -291,10 +280,7 @@ export class World { set [WORLD_ENTITY_COUNTER](n: number) { this.#entityCounter = n; } get id() { return 'world'; } - - get context(): EvalContext { - return { self: this, world: this }; - } + get world() { return this; } /** * Create a new entity and add it to the world. @@ -460,16 +446,6 @@ export class World { } } -export function isEvalContext(v: unknown): v is EvalContext { - return typeof v === 'object' && v != null - && ( - (v as EvalContext).self instanceof Entity - || (v as EvalContext).self instanceof World - ) - && (v as EvalContext).world instanceof World; -} - -/** Narrows an {@link EvalContext} to one where `self` is an `Entity`, not a `World`. */ -export function isEntityContext(ctx: EvalContext): ctx is { self: Entity; world: World } { - return ctx.self instanceof Entity; -} +export const isEvalContext = (x: unknown): x is EvalContext => ( + x instanceof Entity || x instanceof World +); \ No newline at end of file diff --git a/src/common/rpg/dialog.ts b/src/common/rpg/dialog.ts index b86af28..e534e91 100644 --- a/src/common/rpg/dialog.ts +++ b/src/common/rpg/dialog.ts @@ -231,7 +231,7 @@ export class DialogEngine { } private getVariables(): RPGVariables { - if (isEvalContext(this.options)) return resolveVariables(this.options.self); + if (isEvalContext(this.options)) return resolveVariables(this.options); return this.options.variables; } diff --git a/src/common/rpg/systems/combat.ts b/src/common/rpg/systems/combat.ts index 07418c6..d542eba 100644 --- a/src/common/rpg/systems/combat.ts +++ b/src/common/rpg/systems/combat.ts @@ -51,7 +51,11 @@ export class CombatSystem extends System { if (!random) { random = getWorldRandom(world); } - damageAmount += random.use(r => r.nextInt(-variance, variance)); + const variedDamage = random.use(r => r.nextInt(-variance, variance)); + + if (variedDamage > 0) { + damageAmount += variedDamage; + } } const crit = source.get(Crit); @@ -69,9 +73,11 @@ export class CombatSystem extends System { const defense = target.get(Defense, (c) => c.state.damageType === damageType); if (defense) { - damageAmount = Math.max(minDamage, damageAmount - defense.value); + damageAmount -= defense.value; } + damageAmount = Math.max(0, minDamage, damageAmount); + // Apply on-hit effects from source onto target for (const component of source.getAll(EffectTemplate)) { const s = component.state; @@ -82,6 +88,8 @@ export class CombatSystem extends System { delta: s.delta, targetField: s.targetField, duration: s.duration ?? undefined, + permanent: s.permanent, + gradual: s.gradual, condition: s.condition ?? undefined, stacking: s.stacking, tag: s.tag ?? undefined, diff --git a/src/common/rpg/systems/effect.ts b/src/common/rpg/systems/effect.ts index 8f767e9..bb3edbc 100644 --- a/src/common/rpg/systems/effect.ts +++ b/src/common/rpg/systems/effect.ts @@ -6,7 +6,7 @@ export class EffectSystem extends System { const expired: [Entity, Component][] = []; for (const [entity, , effect] of world.query(Effect)) { - effect.update(dt, entity.context); + effect.update(dt); if (effect.state.remaining !== null && effect.state.remaining <= 0) { expired.push([entity, effect]); } diff --git a/src/common/rpg/systems/quest.ts b/src/common/rpg/systems/quest.ts index 4518842..dfa252d 100644 --- a/src/common/rpg/systems/quest.ts +++ b/src/common/rpg/systems/quest.ts @@ -132,11 +132,10 @@ export class QuestSystem extends System { const tracking = this.#tracking.get(entity.id); if (!tracking || tracking.varToQuests.size === 0) return; - const ctx: EvalContext = { self: entity, world }; const dirty = new Set(); for (const [varName, questIds] of tracking.varToQuests) { - const curr = resolveVariable(varName, ctx); + const curr = resolveVariable(varName, entity); const prev = tracking.snapshot.get(varName); // Treat "not yet in snapshot" as dirty — catches conditions already met at init if (!tracking.snapshot.has(varName) || curr !== prev) { @@ -152,8 +151,6 @@ export class QuestSystem extends System { const questLog = entity.get(QuestLog); if (!questLog) return; - const ctx: EvalContext = { self: entity, world }; - for (const [questId, { quest, state }] of questLog.entries()) { if (state.status !== 'active') continue; if (filter !== 'all' && !filter.has(questId)) continue; @@ -164,14 +161,14 @@ export class QuestSystem extends System { continue; } - if (stage.failConditions?.some(c => evaluateCondition(c, ctx))) { + if (stage.failConditions?.some(c => evaluateCondition(c, entity))) { questLog.fail(questId); continue; } - if (!stage.objectives.every(o => evaluateCondition(o.condition, ctx))) continue; + if (!stage.objectives.every(o => evaluateCondition(o.condition, entity))) continue; - for (const action of stage.actions) executeAction(action, ctx); + for (const action of stage.actions) executeAction(action, entity); questLog._advance(questId); } } diff --git a/src/common/rpg/utils/decorators.ts b/src/common/rpg/utils/decorators.ts index 9dee4d8..5a338c1 100644 --- a/src/common/rpg/utils/decorators.ts +++ b/src/common/rpg/utils/decorators.ts @@ -8,15 +8,12 @@ export const ACTION_KEYS = Symbol('rpg.actions'); export const VARIABLE_KEYS = Symbol('rpg.variables'); export const STATE_KEYS = Symbol('rpg.state_variables'); -export function action unknown>( - target: T, +export function action unknown>( + _target: T, context: ClassMethodDecoratorContext -): T { +): void { const prev = context.metadata[ACTION_KEYS] as Set | undefined; context.metadata[ACTION_KEYS] = new Set(prev).add(context.name); - return function (this: any, arg?: any, ctx?: any) { - return target.call(this, arg, ctx ?? this.context); - } as unknown as T; } type VariableContext = diff --git a/src/common/rpg/utils/variables.ts b/src/common/rpg/utils/variables.ts index d6af5b1..49bc016 100644 --- a/src/common/rpg/utils/variables.ts +++ b/src/common/rpg/utils/variables.ts @@ -51,7 +51,7 @@ export function resolveVariable(name: string, ctx: EvalContext): RPGVariables[st return resolveVariables(entity)[varName]; } // bare name → self entity - return resolveVariables(ctx.self)[name]; + return resolveVariables(ctx)[name]; } export function resolveActions(target: Entity | World): RPGActions { const result: RPGActions = {}; @@ -76,21 +76,14 @@ export function resolveActions(target: Entity | World): RPGActions { return result; } -interface Contextable { - readonly context: EvalContext; -} - -export function executeAction(action: RPGAction, ctx: EvalContext | Contextable): unknown { +export function executeAction(action: RPGAction, ctx: EvalContext): unknown { if (typeof action === 'string') { action = { type: action }; } - if ('context' in ctx) { - ctx = ctx.context; - } if (!action.type) { throw new TypeError(`[executeAction] action object is missing a 'type' property`); } - let entity = ctx.self; + let entity = ctx; let actionType = action.type; // @entityId.component.action → dispatch to another entity diff --git a/test/common/rpg/quest.test.ts b/test/common/rpg/quest.test.ts index d1721db..bb3df04 100644 --- a/test/common/rpg/quest.test.ts +++ b/test/common/rpg/quest.test.ts @@ -227,19 +227,19 @@ describe('QuestLog — stage access', () => { const { player, vars, questLog } = makePlayer(w, [simpleQuest()]); questLog.start('q1'); - const before = questLog.getObjectiveProgress('q1', player.context); + const before = questLog.getObjectiveProgress('q1', player); expect(before).toHaveLength(1); expect(before![0].done).toBeFalse(); vars.set({ key: 'done', value: true }); - const after = questLog.getObjectiveProgress('q1', player.context); + const after = questLog.getObjectiveProgress('q1', player); expect(after![0].done).toBeTrue(); }); it('getObjectiveProgress returns undefined when not active', () => { const w = world(); const { player, questLog } = makePlayer(w, [simpleQuest()]); - expect(questLog.getObjectiveProgress('q1', player.context)).toBeUndefined(); + expect(questLog.getObjectiveProgress('q1', player)).toBeUndefined(); }); }); @@ -249,14 +249,14 @@ describe('QuestLog — availability', () => { it('quest with no conditions is always available', () => { const w = world(); const { player, questLog } = makePlayer(w, [simpleQuest()]); - expect(questLog.isAvailable('q1', player.context)).toBeTrue(); + expect(questLog.isAvailable('q1', player)).toBeTrue(); }); it('quest with unsatisfied condition is not available', () => { const w = world(); const quest: Quest = { ...simpleQuest(), conditions: ['Variables.unlocked == true'] }; const { player, questLog } = makePlayer(w, [quest]); - expect(questLog.isAvailable('q1', player.context)).toBeFalse(); + expect(questLog.isAvailable('q1', player)).toBeFalse(); }); it('quest with satisfied condition is available', () => { @@ -264,7 +264,7 @@ describe('QuestLog — availability', () => { const quest: Quest = { ...simpleQuest(), conditions: ['Variables.unlocked == true'] }; const { player, vars, questLog } = makePlayer(w, [quest]); vars.set({ key: 'unlocked', value: true }); - expect(questLog.isAvailable('q1', player.context)).toBeTrue(); + expect(questLog.isAvailable('q1', player)).toBeTrue(); }); });