From 877ab6f45389656df468fae185973c636f6d5ec1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Olivera?= Date: Mon, 16 Mar 2026 17:07:42 -0300 Subject: [PATCH 01/39] feat(abilities): add Ability, Action, and Sense base classes Introduce abstract base classes for the class-based ability architecture. Ability provides unit reference, abstract description/meta, and a static .with() factory for deferred config. Action and Sense extend it with void and any return types respectively. Co-Authored-By: Claude Opus 4.6 (1M context) --- libs/abilities/src/Ability.ts | 18 ++++++++++++++++++ libs/abilities/src/Action.ts | 7 +++++++ libs/abilities/src/Sense.ts | 7 +++++++ libs/abilities/src/index.ts | 6 +++++- 4 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 libs/abilities/src/Ability.ts create mode 100644 libs/abilities/src/Action.ts create mode 100644 libs/abilities/src/Sense.ts diff --git a/libs/abilities/src/Ability.ts b/libs/abilities/src/Ability.ts new file mode 100644 index 00000000..c023e22c --- /dev/null +++ b/libs/abilities/src/Ability.ts @@ -0,0 +1,18 @@ +import type { AbilityMeta, Unit } from './types.js'; + +export type AbilityBinding = [typeof Ability, Record]; + +abstract class Ability { + protected unit: Unit; + + abstract readonly description: string; + abstract readonly meta: AbilityMeta; + + constructor(unit: Unit, _config?: Record) { + this.unit = unit; + } + + abstract perform(...args: unknown[]): unknown; +} + +export default Ability; diff --git a/libs/abilities/src/Action.ts b/libs/abilities/src/Action.ts new file mode 100644 index 00000000..51a4a901 --- /dev/null +++ b/libs/abilities/src/Action.ts @@ -0,0 +1,7 @@ +import Ability from './Ability.js'; + +abstract class Action extends Ability { + abstract perform(...args: unknown[]): void; +} + +export default Action; diff --git a/libs/abilities/src/Sense.ts b/libs/abilities/src/Sense.ts new file mode 100644 index 00000000..ae70d0ff --- /dev/null +++ b/libs/abilities/src/Sense.ts @@ -0,0 +1,7 @@ +import Ability from './Ability.js'; + +abstract class Sense extends Ability { + abstract perform(...args: unknown[]): unknown; +} + +export default Sense; diff --git a/libs/abilities/src/index.ts b/libs/abilities/src/index.ts index f7467bee..4375e845 100644 --- a/libs/abilities/src/index.ts +++ b/libs/abilities/src/index.ts @@ -1,3 +1,6 @@ +export type { AbilityBinding } from './Ability.js'; +export { default as Ability } from './Ability.js'; +export { default as Action } from './Action.js'; export { default as attack } from './attack.js'; export { default as bind } from './bind.js'; export { default as detonate } from './detonate.js'; @@ -12,7 +15,8 @@ export { default as maxHealth } from './maxHealth.js'; export { default as pivot } from './pivot.js'; export { default as rescue } from './rescue.js'; export { default as rest } from './rest.js'; +export { default as Sense } from './Sense.js'; export { default as shoot } from './shoot.js'; export { default as think } from './think.js'; -export type { Ability, AbilityCreator, SensedSpace, Space, Unit } from './types.js'; +export type { AbilityCreator, SensedSpace, Space, Unit } from './types.js'; export { default as walk } from './walk.js'; From de40962ea257e664dd04887e47dc99c516847098 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Olivera?= Date: Mon, 16 Mar 2026 17:33:04 -0300 Subject: [PATCH 02/39] refactor(abilities): convert action abilities to classes Convert walk, attack, shoot, bind, rescue, pivot, rest, and detonate from curried factory functions to classes extending Action. Abilities with config params define static .with() methods returning AbilityBindings. Co-Authored-By: Claude Opus 4.6 (1M context) --- libs/abilities/src/attack.test.ts | 16 ++++-- libs/abilities/src/attack.ts | 59 +++++++++++++-------- libs/abilities/src/bind.test.ts | 11 ++-- libs/abilities/src/bind.ts | 40 +++++++------- libs/abilities/src/detonate.test.ts | 18 ++++--- libs/abilities/src/detonate.ts | 81 +++++++++++++++++------------ libs/abilities/src/pivot.test.ts | 11 ++-- libs/abilities/src/pivot.ts | 29 +++++------ libs/abilities/src/rescue.test.ts | 11 ++-- libs/abilities/src/rescue.ts | 40 +++++++------- libs/abilities/src/rest.test.ts | 16 ++++-- libs/abilities/src/rest.ts | 57 ++++++++++++-------- libs/abilities/src/shoot.test.ts | 16 ++++-- libs/abilities/src/shoot.ts | 64 +++++++++++++++-------- libs/abilities/src/walk.test.ts | 13 ++--- libs/abilities/src/walk.ts | 40 +++++++------- 16 files changed, 305 insertions(+), 217 deletions(-) diff --git a/libs/abilities/src/attack.test.ts b/libs/abilities/src/attack.test.ts index 109db294..38ca755f 100644 --- a/libs/abilities/src/attack.test.ts +++ b/libs/abilities/src/attack.test.ts @@ -1,10 +1,11 @@ import { BACKWARD, FORWARD, LEFT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; -import attackCreator from './attack.js'; +import Action from './Action.js'; +import Attack from './attack.js'; -describe('attack', () => { - let attack: ReturnType>; +describe('Attack', () => { + let attack: Attack; let unit: any; beforeEach(() => { @@ -12,11 +13,11 @@ describe('attack', () => { damage: vi.fn(), log: vi.fn(), }; - attack = attackCreator({ power: 3 })(unit); + attack = new Attack(unit, { power: 3 }); }); test('is an action', () => { - expect(attack.action).toBe(true); + expect(attack).toBeInstanceOf(Action); }); test('has a description', () => { @@ -32,6 +33,11 @@ describe('attack', () => { }); }); + test('.with() returns an AbilityBinding', () => { + const binding = Attack.with({ power: 5 }); + expect(binding).toEqual([Attack, { power: 5 }]); + }); + describe('performing', () => { test('attacks forward by default', () => { unit.getSpaceAt = vi.fn(() => ({ getUnit: () => null })); diff --git a/libs/abilities/src/attack.ts b/libs/abilities/src/attack.ts index c5d8022c..387d0c8c 100644 --- a/libs/abilities/src/attack.ts +++ b/libs/abilities/src/attack.ts @@ -1,29 +1,44 @@ import { BACKWARD, FORWARD, type RelativeDirection } from '@warriorjs/spatial'; -import type { Unit } from './types.js'; +import type { AbilityBinding } from './Ability.js'; +import Action from './Action.js'; +import type { AbilityMeta, Unit } from './types.js'; const defaultDirection = FORWARD; -function attack({ power }: { power: number }) { - return (unit: Unit) => ({ - action: true as const, - description: `Attacks a unit in the given direction (\`'${defaultDirection}'\` by default), dealing ${power} HP of damage.`, - perform(direction: RelativeDirection = defaultDirection) { - const receiver = unit.getSpaceAt(direction).getUnit(); - if (receiver) { - unit.log(`attacks ${direction} and hits ${receiver}`); - const attackingBackward = direction === BACKWARD; - const amount = attackingBackward ? Math.ceil(power / 2.0) : power; - unit.damage(receiver, amount); - } else { - unit.log(`attacks ${direction} and hits nothing`); - } - }, - meta: { - params: [{ name: 'direction', type: 'Direction' as const, optional: true }], - returns: 'void' as const, - }, - }); +interface AttackConfig { + power: number; } -export default attack; +class Attack extends Action { + private power: number; + readonly description: string; + readonly meta: AbilityMeta = { + params: [{ name: 'direction', type: 'Direction', optional: true }], + returns: 'void', + }; + + constructor(unit: Unit, { power }: AttackConfig) { + super(unit); + this.power = power; + this.description = `Attacks a unit in the given direction (\`'${defaultDirection}'\` by default), dealing ${power} HP of damage.`; + } + + perform(direction: RelativeDirection = defaultDirection): void { + const receiver = this.unit.getSpaceAt(direction).getUnit(); + if (receiver) { + this.unit.log(`attacks ${direction} and hits ${receiver}`); + const attackingBackward = direction === BACKWARD; + const amount = attackingBackward ? Math.ceil(this.power / 2.0) : this.power; + this.unit.damage(receiver, amount); + } else { + this.unit.log(`attacks ${direction} and hits nothing`); + } + } + + static with(config: AttackConfig): AbilityBinding { + return [Attack, config as Record]; + } +} + +export default Attack; diff --git a/libs/abilities/src/bind.test.ts b/libs/abilities/src/bind.test.ts index 3d30419f..6dcac3ef 100644 --- a/libs/abilities/src/bind.test.ts +++ b/libs/abilities/src/bind.test.ts @@ -1,19 +1,20 @@ import { FORWARD, LEFT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; -import bindCreator from './bind.js'; +import Action from './Action.js'; +import Bind from './bind.js'; -describe('bind', () => { - let bind: ReturnType>; +describe('Bind', () => { + let bind: Bind; let unit: any; beforeEach(() => { unit = { log: vi.fn() }; - bind = bindCreator()(unit); + bind = new Bind(unit); }); test('is an action', () => { - expect(bind.action).toBe(true); + expect(bind).toBeInstanceOf(Action); }); test('has a description', () => { diff --git a/libs/abilities/src/bind.ts b/libs/abilities/src/bind.ts index f71a42ac..b61d5a05 100644 --- a/libs/abilities/src/bind.ts +++ b/libs/abilities/src/bind.ts @@ -1,27 +1,27 @@ import { FORWARD, type RelativeDirection } from '@warriorjs/spatial'; -import type { Unit } from './types.js'; +import Action from './Action.js'; +import type { AbilityMeta } from './types.js'; const defaultDirection = FORWARD; -function bind() { - return (unit: Unit) => ({ - action: true as const, - description: `Binds a unit in the given direction (\`'${defaultDirection}'\` by default) to keep them from moving.`, - perform(direction: RelativeDirection = defaultDirection) { - const receiver = unit.getSpaceAt(direction).getUnit(); - if (receiver) { - unit.log(`binds ${direction} and restricts ${receiver}`); - receiver.bind(); - } else { - unit.log(`binds ${direction} and restricts nothing`); - } - }, - meta: { - params: [{ name: 'direction', type: 'Direction' as const, optional: true }], - returns: 'void' as const, - }, - }); +class Bind extends Action { + readonly description = + `Binds a unit in the given direction (\`'${defaultDirection}'\` by default) to keep them from moving.`; + readonly meta: AbilityMeta = { + params: [{ name: 'direction', type: 'Direction', optional: true }], + returns: 'void', + }; + + perform(direction: RelativeDirection = defaultDirection): void { + const receiver = this.unit.getSpaceAt(direction).getUnit(); + if (receiver) { + this.unit.log(`binds ${direction} and restricts ${receiver}`); + receiver.bind(); + } else { + this.unit.log(`binds ${direction} and restricts nothing`); + } + } } -export default bind; +export default Bind; diff --git a/libs/abilities/src/detonate.test.ts b/libs/abilities/src/detonate.test.ts index 9919a759..7b59d819 100644 --- a/libs/abilities/src/detonate.test.ts +++ b/libs/abilities/src/detonate.test.ts @@ -1,10 +1,11 @@ import { FORWARD, LEFT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; -import detonateCreator from './detonate.js'; +import Action from './Action.js'; +import Detonate from './detonate.js'; -describe('detonate', () => { - let detonate: ReturnType>; +describe('Detonate', () => { + let detonate: Detonate; let unit: any; beforeEach(() => { @@ -13,11 +14,11 @@ describe('detonate', () => { isUnderEffect: () => false, log: vi.fn(), }; - detonate = detonateCreator({ targetPower: 4, surroundingPower: 2 })(unit); + detonate = new Detonate(unit, { targetPower: 4, surroundingPower: 2 }); }); test('is an action', () => { - expect(detonate.action).toBe(true); + expect(detonate).toBeInstanceOf(Action); }); test('has a description', () => { @@ -33,6 +34,11 @@ describe('detonate', () => { }); }); + test('.with() returns an AbilityBinding', () => { + const binding = Detonate.with({ targetPower: 4, surroundingPower: 2 }); + expect(binding).toEqual([Detonate, { targetPower: 4, surroundingPower: 2 }]); + }); + describe('performing', () => { test('detonates forward by default', () => { unit.getSpaceAt = vi.fn(() => ({ getUnit: () => null })); @@ -70,7 +76,7 @@ describe('detonate', () => { expect(unit.damage).toHaveBeenCalledWith(unit, 2); }); - test('damages receivers depending on their position', () => { + test('triggers ticking effect on receivers under effect', () => { const receiver = { isUnderEffect: () => true, triggerEffect: vi.fn(), diff --git a/libs/abilities/src/detonate.ts b/libs/abilities/src/detonate.ts index be9fd04f..fe325788 100644 --- a/libs/abilities/src/detonate.ts +++ b/libs/abilities/src/detonate.ts @@ -1,6 +1,8 @@ import { FORWARD, type RelativeDirection } from '@warriorjs/spatial'; -import type { Space, Unit } from './types.js'; +import type { AbilityBinding } from './Ability.js'; +import Action from './Action.js'; +import type { AbilityMeta, Space, Unit } from './types.js'; const defaultDirection = FORWARD; const surroundingOffsets: [number, number][] = [ @@ -10,41 +12,52 @@ const surroundingOffsets: [number, number][] = [ [0, 0], ]; -function detonate({ - targetPower, - surroundingPower, -}: { +interface DetonateConfig { targetPower: number; surroundingPower: number; -}) { - return (unit: Unit) => ({ - action: true as const, - description: `Detonates a bomb in a given direction (\`'${defaultDirection}'\` by default), dealing ${targetPower} HP of damage to that space and ${surroundingPower} HP of damage to surrounding 4 spaces (including yourself).`, - perform(direction: RelativeDirection = defaultDirection) { - unit.log(`detonates a bomb ${direction} launching a deadly explosion`); - const targetSpace = unit.getSpaceAt(direction); - this.bomb(targetSpace, targetPower); - surroundingOffsets - .map(([forward, right]) => unit.getSpaceAt(direction, forward, right)) - .forEach((surroundingSpace) => { - this.bomb(surroundingSpace, surroundingPower); - }); - }, - bomb(space: Space, power: number) { - const receiver = space.getUnit(); - if (receiver) { - unit.damage(receiver, power); - if (receiver.isUnderEffect('ticking')) { - receiver.log('caught in the blast, detonating the ticking explosive'); - receiver.triggerEffect('ticking'); - } +} + +class Detonate extends Action { + private targetPower: number; + private surroundingPower: number; + readonly description: string; + readonly meta: AbilityMeta = { + params: [{ name: 'direction', type: 'Direction', optional: true }], + returns: 'void', + }; + + constructor(unit: Unit, { targetPower, surroundingPower }: DetonateConfig) { + super(unit); + this.targetPower = targetPower; + this.surroundingPower = surroundingPower; + this.description = `Detonates a bomb in a given direction (\`'${defaultDirection}'\` by default), dealing ${targetPower} HP of damage to that space and ${surroundingPower} HP of damage to surrounding 4 spaces (including yourself).`; + } + + perform(direction: RelativeDirection = defaultDirection): void { + this.unit.log(`detonates a bomb ${direction} launching a deadly explosion`); + const targetSpace = this.unit.getSpaceAt(direction); + this.bomb(targetSpace, this.targetPower); + surroundingOffsets + .map(([forward, right]) => this.unit.getSpaceAt(direction, forward, right)) + .forEach((surroundingSpace) => { + this.bomb(surroundingSpace, this.surroundingPower); + }); + } + + private bomb(space: Space, power: number): void { + const receiver = space.getUnit(); + if (receiver) { + this.unit.damage(receiver, power); + if (receiver.isUnderEffect('ticking')) { + receiver.log('caught in the blast, detonating the ticking explosive'); + receiver.triggerEffect('ticking'); } - }, - meta: { - params: [{ name: 'direction', type: 'Direction' as const, optional: true }], - returns: 'void' as const, - }, - }); + } + } + + static with(config: DetonateConfig): AbilityBinding { + return [Detonate, config as Record]; + } } -export default detonate; +export default Detonate; diff --git a/libs/abilities/src/pivot.test.ts b/libs/abilities/src/pivot.test.ts index caeb388e..07af2e61 100644 --- a/libs/abilities/src/pivot.test.ts +++ b/libs/abilities/src/pivot.test.ts @@ -1,10 +1,11 @@ import { BACKWARD, RIGHT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; -import pivotCreator from './pivot.js'; +import Action from './Action.js'; +import Pivot from './pivot.js'; -describe('pivot', () => { - let pivot: ReturnType>; +describe('Pivot', () => { + let pivot: Pivot; let unit: any; beforeEach(() => { @@ -12,11 +13,11 @@ describe('pivot', () => { rotate: vi.fn(), log: vi.fn(), }; - pivot = pivotCreator()(unit); + pivot = new Pivot(unit); }); test('is an action', () => { - expect(pivot.action).toBe(true); + expect(pivot).toBeInstanceOf(Action); }); test('has a description', () => { diff --git a/libs/abilities/src/pivot.ts b/libs/abilities/src/pivot.ts index 270ffffc..cb1eeab3 100644 --- a/libs/abilities/src/pivot.ts +++ b/libs/abilities/src/pivot.ts @@ -1,22 +1,21 @@ import { BACKWARD, type RelativeDirection } from '@warriorjs/spatial'; -import type { Unit } from './types.js'; +import Action from './Action.js'; +import type { AbilityMeta } from './types.js'; const defaultDirection = BACKWARD; -function pivot() { - return (unit: Unit) => ({ - action: true as const, - description: `Rotates in the given direction (\`'${defaultDirection}'\` by default).`, - perform(direction: RelativeDirection = defaultDirection) { - unit.rotate(direction); - unit.log(`pivots ${direction}`); - }, - meta: { - params: [{ name: 'direction', type: 'Direction' as const, optional: true }], - returns: 'void' as const, - }, - }); +class Pivot extends Action { + readonly description = `Rotates in the given direction (\`'${defaultDirection}'\` by default).`; + readonly meta: AbilityMeta = { + params: [{ name: 'direction', type: 'Direction', optional: true }], + returns: 'void', + }; + + perform(direction: RelativeDirection = defaultDirection): void { + this.unit.rotate(direction); + this.unit.log(`pivots ${direction}`); + } } -export default pivot; +export default Pivot; diff --git a/libs/abilities/src/rescue.test.ts b/libs/abilities/src/rescue.test.ts index ab907bc6..22918bb7 100644 --- a/libs/abilities/src/rescue.test.ts +++ b/libs/abilities/src/rescue.test.ts @@ -1,10 +1,11 @@ import { FORWARD, RIGHT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; -import rescueCreator from './rescue.js'; +import Action from './Action.js'; +import Rescue from './rescue.js'; -describe('rescue', () => { - let rescue: ReturnType>; +describe('Rescue', () => { + let rescue: Rescue; let unit: any; beforeEach(() => { @@ -12,11 +13,11 @@ describe('rescue', () => { release: vi.fn(), log: vi.fn(), }; - rescue = rescueCreator()(unit); + rescue = new Rescue(unit); }); test('is an action', () => { - expect(rescue.action).toBe(true); + expect(rescue).toBeInstanceOf(Action); }); test('has a description', () => { diff --git a/libs/abilities/src/rescue.ts b/libs/abilities/src/rescue.ts index ea57f805..e9ea3209 100644 --- a/libs/abilities/src/rescue.ts +++ b/libs/abilities/src/rescue.ts @@ -1,27 +1,27 @@ import { FORWARD, type RelativeDirection } from '@warriorjs/spatial'; -import type { Unit } from './types.js'; +import Action from './Action.js'; +import type { AbilityMeta } from './types.js'; const defaultDirection = FORWARD; -function rescue() { - return (unit: Unit) => ({ - action: true as const, - description: `Releases a unit from their chains in the given direction (\`'${defaultDirection}'\` by default).`, - perform(direction: RelativeDirection = defaultDirection) { - const receiver = unit.getSpaceAt(direction).getUnit(); - if (receiver?.isBound()) { - unit.log(`unbinds ${direction} and rescues ${receiver}`); - unit.release(receiver); - } else { - unit.log(`unbinds ${direction} and rescues nothing`); - } - }, - meta: { - params: [{ name: 'direction', type: 'Direction' as const, optional: true }], - returns: 'void' as const, - }, - }); +class Rescue extends Action { + readonly description = + `Releases a unit from their chains in the given direction (\`'${defaultDirection}'\` by default).`; + readonly meta: AbilityMeta = { + params: [{ name: 'direction', type: 'Direction', optional: true }], + returns: 'void', + }; + + perform(direction: RelativeDirection = defaultDirection): void { + const receiver = this.unit.getSpaceAt(direction).getUnit(); + if (receiver?.isBound()) { + this.unit.log(`unbinds ${direction} and rescues ${receiver}`); + this.unit.release(receiver); + } else { + this.unit.log(`unbinds ${direction} and rescues nothing`); + } + } } -export default rescue; +export default Rescue; diff --git a/libs/abilities/src/rest.test.ts b/libs/abilities/src/rest.test.ts index 5a575f23..91495ec6 100644 --- a/libs/abilities/src/rest.test.ts +++ b/libs/abilities/src/rest.test.ts @@ -1,9 +1,10 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; -import restCreator from './rest.js'; +import Action from './Action.js'; +import Rest from './rest.js'; -describe('rest', () => { - let rest: ReturnType>; +describe('Rest', () => { + let rest: Rest; let unit: any; beforeEach(() => { @@ -13,11 +14,11 @@ describe('rest', () => { heal: vi.fn(), log: vi.fn(), }; - rest = restCreator({ healthGain: 0.1 })(unit); + rest = new Rest(unit, { healthGain: 0.1 }); }); test('is an action', () => { - expect(rest.action).toBe(true); + expect(rest).toBeInstanceOf(Action); }); test('has a description', () => { @@ -31,6 +32,11 @@ describe('rest', () => { }); }); + test('.with() returns an AbilityBinding', () => { + const binding = Rest.with({ healthGain: 0.1 }); + expect(binding).toEqual([Rest, { healthGain: 0.1 }]); + }); + describe('performing', () => { test('gives health back', () => { rest.perform(); diff --git a/libs/abilities/src/rest.ts b/libs/abilities/src/rest.ts index 25779260..0b8a33ab 100644 --- a/libs/abilities/src/rest.ts +++ b/libs/abilities/src/rest.ts @@ -1,24 +1,39 @@ -import type { Unit } from './types.js'; +import type { AbilityBinding } from './Ability.js'; +import Action from './Action.js'; +import type { AbilityMeta, Unit } from './types.js'; -function rest({ healthGain }: { healthGain: number }) { - const healthGainPercentage = healthGain * 100; - return (unit: Unit) => ({ - action: true as const, - description: `Gains ${healthGainPercentage}% of max health back, but does nothing more.`, - perform() { - if (unit.health < unit.maxHealth) { - unit.log('rests'); - const amount = Math.round(unit.maxHealth * healthGain); - unit.heal(amount); - } else { - unit.log('has nothing to heal'); - } - }, - meta: { - params: [], - returns: 'void' as const, - }, - }); +interface RestConfig { + healthGain: number; } -export default rest; +class Rest extends Action { + private healthGain: number; + readonly description: string; + readonly meta: AbilityMeta = { + params: [], + returns: 'void', + }; + + constructor(unit: Unit, { healthGain }: RestConfig) { + super(unit); + this.healthGain = healthGain; + const healthGainPercentage = healthGain * 100; + this.description = `Gains ${healthGainPercentage}% of max health back, but does nothing more.`; + } + + perform(): void { + if (this.unit.health < this.unit.maxHealth) { + this.unit.log('rests'); + const amount = Math.round(this.unit.maxHealth * this.healthGain); + this.unit.heal(amount); + } else { + this.unit.log('has nothing to heal'); + } + } + + static with(config: RestConfig): AbilityBinding { + return [Rest, config as Record]; + } +} + +export default Rest; diff --git a/libs/abilities/src/shoot.test.ts b/libs/abilities/src/shoot.test.ts index f542544c..60eeab6b 100644 --- a/libs/abilities/src/shoot.test.ts +++ b/libs/abilities/src/shoot.test.ts @@ -1,10 +1,11 @@ import { FORWARD, LEFT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; -import shootCreator from './shoot.js'; +import Action from './Action.js'; +import Shoot from './shoot.js'; -describe('shoot', () => { - let shoot: ReturnType>; +describe('Shoot', () => { + let shoot: Shoot; let unit: any; beforeEach(() => { @@ -12,11 +13,11 @@ describe('shoot', () => { damage: vi.fn(), log: vi.fn(), }; - shoot = shootCreator({ power: 3, range: 3 })(unit); + shoot = new Shoot(unit, { power: 3, range: 3 }); }); test('is an action', () => { - expect(shoot.action).toBe(true); + expect(shoot).toBeInstanceOf(Action); }); test('has a description', () => { @@ -32,6 +33,11 @@ describe('shoot', () => { }); }); + test('.with() returns an AbilityBinding', () => { + const binding = Shoot.with({ power: 3, range: 3 }); + expect(binding).toEqual([Shoot, { power: 3, range: 3 }]); + }); + describe('performing', () => { test('shoots forward by default', () => { unit.getSpaceAt = vi.fn(() => ({ getUnit: () => null })); diff --git a/libs/abilities/src/shoot.ts b/libs/abilities/src/shoot.ts index aaa6cba7..e029752e 100644 --- a/libs/abilities/src/shoot.ts +++ b/libs/abilities/src/shoot.ts @@ -1,30 +1,48 @@ import { FORWARD, type RelativeDirection } from '@warriorjs/spatial'; -import type { Unit } from './types.js'; +import type { AbilityBinding } from './Ability.js'; +import Action from './Action.js'; +import type { AbilityMeta, Unit } from './types.js'; const defaultDirection = FORWARD; -function shoot({ power, range }: { power: number; range: number }) { - return (unit: Unit) => ({ - action: true as const, - description: `Shoots the bow & arrow in the given direction (\`'${defaultDirection}'\` by default), dealing ${power} HP of damage to the first unit in a range of ${range} spaces.`, - perform(direction: RelativeDirection = defaultDirection) { - const offsets = Array.from(new Array(range), (_, index) => index + 1); - const receiver = offsets - .map((offset) => unit.getSpaceAt(direction, offset).getUnit()) - .find((unitInRange) => unitInRange); - if (receiver) { - unit.log(`shoots ${direction} and hits ${receiver}`); - unit.damage(receiver, power); - } else { - unit.log(`shoots ${direction} and hits nothing`); - } - }, - meta: { - params: [{ name: 'direction', type: 'Direction' as const, optional: true }], - returns: 'void' as const, - }, - }); +interface ShootConfig { + power: number; + range: number; } -export default shoot; +class Shoot extends Action { + private power: number; + private range: number; + readonly description: string; + readonly meta: AbilityMeta = { + params: [{ name: 'direction', type: 'Direction', optional: true }], + returns: 'void', + }; + + constructor(unit: Unit, { power, range }: ShootConfig) { + super(unit); + this.power = power; + this.range = range; + this.description = `Shoots the bow & arrow in the given direction (\`'${defaultDirection}'\` by default), dealing ${power} HP of damage to the first unit in a range of ${range} spaces.`; + } + + perform(direction: RelativeDirection = defaultDirection): void { + const offsets = Array.from(new Array(this.range), (_, index) => index + 1); + const receiver = offsets + .map((offset) => this.unit.getSpaceAt(direction, offset).getUnit()) + .find((unitInRange) => unitInRange); + if (receiver) { + this.unit.log(`shoots ${direction} and hits ${receiver}`); + this.unit.damage(receiver, this.power); + } else { + this.unit.log(`shoots ${direction} and hits nothing`); + } + } + + static with(config: ShootConfig): AbilityBinding { + return [Shoot, config as Record]; + } +} + +export default Shoot; diff --git a/libs/abilities/src/walk.test.ts b/libs/abilities/src/walk.test.ts index 5fd477df..3227e106 100644 --- a/libs/abilities/src/walk.test.ts +++ b/libs/abilities/src/walk.test.ts @@ -1,10 +1,11 @@ import { FORWARD, RIGHT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; -import walkCreator from './walk.js'; +import Action from './Action.js'; +import Walk from './walk.js'; -describe('walk', () => { - let walk: ReturnType>; +describe('Walk', () => { + let walk: Walk; let unit: any; beforeEach(() => { @@ -12,11 +13,11 @@ describe('walk', () => { move: vi.fn(), log: vi.fn(), }; - walk = walkCreator()(unit); + walk = new Walk(unit); }); test('is an action', () => { - expect(walk.action).toBe(true); + expect(walk).toBeInstanceOf(Action); }); test('has a description', () => { @@ -55,7 +56,7 @@ describe('walk', () => { expect(unit.move).not.toHaveBeenCalled(); }); - test('moves in specified direction if space if empty', () => { + test('moves in specified direction if space is empty', () => { unit.getSpaceAt = () => ({ isEmpty: () => true }); walk.perform(RIGHT); expect(unit.log).toHaveBeenCalledWith(`walks ${RIGHT}`); diff --git a/libs/abilities/src/walk.ts b/libs/abilities/src/walk.ts index df8254e1..fc3e8165 100644 --- a/libs/abilities/src/walk.ts +++ b/libs/abilities/src/walk.ts @@ -1,27 +1,27 @@ import { FORWARD, type RelativeDirection } from '@warriorjs/spatial'; -import type { Unit } from './types.js'; +import Action from './Action.js'; +import type { AbilityMeta } from './types.js'; const defaultDirection = FORWARD; -function walk() { - return (unit: Unit) => ({ - action: true as const, - description: `Moves one space in the given direction (\`'${defaultDirection}'\` by default).`, - perform(direction: RelativeDirection = defaultDirection) { - const space = unit.getSpaceAt(direction); - if (space.isEmpty()) { - unit.move(direction); - unit.log(`walks ${direction}`); - } else { - unit.log(`walks ${direction} and bumps into ${space}`); - } - }, - meta: { - params: [{ name: 'direction', type: 'Direction' as const, optional: true }], - returns: 'void' as const, - }, - }); +class Walk extends Action { + readonly description = + `Moves one space in the given direction (\`'${defaultDirection}'\` by default).`; + readonly meta: AbilityMeta = { + params: [{ name: 'direction', type: 'Direction', optional: true }], + returns: 'void', + }; + + perform(direction: RelativeDirection = defaultDirection): void { + const space = this.unit.getSpaceAt(direction); + if (space.isEmpty()) { + this.unit.move(direction); + this.unit.log(`walks ${direction}`); + } else { + this.unit.log(`walks ${direction} and bumps into ${space}`); + } + } } -export default walk; +export default Walk; From 19da9dc3de94155f012a171c01f798ead4050b80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Olivera?= Date: Mon, 16 Mar 2026 17:40:58 -0300 Subject: [PATCH 03/39] refactor(abilities): convert sense abilities to classes Convert feel, look, listen, health, maxHealth, think, directionOf, directionOfStairs, and distanceOf from curried factory functions to classes extending Sense. Co-Authored-By: Claude Opus 4.6 (1M context) --- libs/abilities/src/directionOf.test.ts | 14 +++--- libs/abilities/src/directionOf.ts | 27 +++++------ libs/abilities/src/directionOfStairs.test.ts | 14 +++--- libs/abilities/src/directionOfStairs.ts | 27 +++++------ libs/abilities/src/distanceOf.test.ts | 14 +++--- libs/abilities/src/distanceOf.ts | 26 +++++------ libs/abilities/src/feel.test.ts | 14 +++--- libs/abilities/src/feel.ts | 27 +++++------ libs/abilities/src/health.test.ts | 14 +++--- libs/abilities/src/health.ts | 26 +++++------ libs/abilities/src/listen.test.ts | 14 +++--- libs/abilities/src/listen.ts | 45 +++++++++--------- libs/abilities/src/look.test.ts | 19 +++++--- libs/abilities/src/look.ts | 48 +++++++++++++------- libs/abilities/src/maxHealth.test.ts | 14 +++--- libs/abilities/src/maxHealth.ts | 26 +++++------ libs/abilities/src/think.test.ts | 13 +++--- libs/abilities/src/think.ts | 28 ++++++------ 18 files changed, 218 insertions(+), 192 deletions(-) diff --git a/libs/abilities/src/directionOf.test.ts b/libs/abilities/src/directionOf.test.ts index 495b98f3..c622a294 100644 --- a/libs/abilities/src/directionOf.test.ts +++ b/libs/abilities/src/directionOf.test.ts @@ -1,19 +1,19 @@ import { BACKWARD, FORWARD, LEFT, RIGHT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; +import DirectionOf from './directionOf.js'; +import Sense from './Sense.js'; -import directionOfCreator from './directionOf.js'; - -describe('directionOf', () => { - let directionOf: ReturnType>; +describe('DirectionOf', () => { + let directionOf: DirectionOf; let unit: any; beforeEach(() => { unit = { getDirectionOf: vi.fn() }; - directionOf = directionOfCreator()(unit); + directionOf = new DirectionOf(unit); }); - test('is not an action', () => { - expect(directionOf.action).toBeUndefined(); + test('is a sense', () => { + expect(directionOf).toBeInstanceOf(Sense); }); test('has a description', () => { diff --git a/libs/abilities/src/directionOf.ts b/libs/abilities/src/directionOf.ts index a84ce7d6..643cd712 100644 --- a/libs/abilities/src/directionOf.ts +++ b/libs/abilities/src/directionOf.ts @@ -1,18 +1,19 @@ import { BACKWARD, FORWARD, LEFT, RIGHT } from '@warriorjs/spatial'; -import type { Unit } from './types.js'; +import Sense from './Sense.js'; +import type { AbilityMeta } from './types.js'; -function directionOf() { - return (unit: Unit) => ({ - description: `Returns the direction (${FORWARD}, ${RIGHT}, ${BACKWARD} or ${LEFT}) to the given space.`, - perform(space: unknown) { - return unit.getDirectionOf(space); - }, - meta: { - params: [{ name: 'space', type: 'Space' as const }], - returns: 'Direction' as const, - }, - }); +class DirectionOf extends Sense { + readonly description = + `Returns the direction (${FORWARD}, ${RIGHT}, ${BACKWARD} or ${LEFT}) to the given space.`; + readonly meta: AbilityMeta = { + params: [{ name: 'space', type: 'Space' }], + returns: 'Direction', + }; + + perform(space: unknown) { + return this.unit.getDirectionOf(space); + } } -export default directionOf; +export default DirectionOf; diff --git a/libs/abilities/src/directionOfStairs.test.ts b/libs/abilities/src/directionOfStairs.test.ts index 34f25a6c..29e43431 100644 --- a/libs/abilities/src/directionOfStairs.test.ts +++ b/libs/abilities/src/directionOfStairs.test.ts @@ -1,19 +1,19 @@ import { BACKWARD, FORWARD, LEFT, RIGHT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; +import DirectionOfStairs from './directionOfStairs.js'; +import Sense from './Sense.js'; -import directionOfStairsCreator from './directionOfStairs.js'; - -describe('directionOfStairs', () => { - let directionOfStairs: ReturnType>; +describe('DirectionOfStairs', () => { + let directionOfStairs: DirectionOfStairs; let unit: any; beforeEach(() => { unit = { getDirectionOfStairs: vi.fn() }; - directionOfStairs = directionOfStairsCreator()(unit); + directionOfStairs = new DirectionOfStairs(unit); }); - test('is not an action', () => { - expect(directionOfStairs.action).toBeUndefined(); + test('is a sense', () => { + expect(directionOfStairs).toBeInstanceOf(Sense); }); test('has a description', () => { diff --git a/libs/abilities/src/directionOfStairs.ts b/libs/abilities/src/directionOfStairs.ts index c02e4972..adc5a070 100644 --- a/libs/abilities/src/directionOfStairs.ts +++ b/libs/abilities/src/directionOfStairs.ts @@ -1,18 +1,19 @@ import { BACKWARD, FORWARD, LEFT, RIGHT } from '@warriorjs/spatial'; -import type { Unit } from './types.js'; +import Sense from './Sense.js'; +import type { AbilityMeta } from './types.js'; -function directionOfStairs() { - return (unit: Unit) => ({ - description: `Returns the direction (${FORWARD}, ${RIGHT}, ${BACKWARD} or ${LEFT}) the stairs are from your location.`, - perform() { - return unit.getDirectionOfStairs(); - }, - meta: { - params: [], - returns: 'Direction' as const, - }, - }); +class DirectionOfStairs extends Sense { + readonly description = + `Returns the direction (${FORWARD}, ${RIGHT}, ${BACKWARD} or ${LEFT}) the stairs are from your location.`; + readonly meta: AbilityMeta = { + params: [], + returns: 'Direction', + }; + + perform() { + return this.unit.getDirectionOfStairs(); + } } -export default directionOfStairs; +export default DirectionOfStairs; diff --git a/libs/abilities/src/distanceOf.test.ts b/libs/abilities/src/distanceOf.test.ts index 1668c66e..445a2be3 100644 --- a/libs/abilities/src/distanceOf.test.ts +++ b/libs/abilities/src/distanceOf.test.ts @@ -1,18 +1,18 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; +import DistanceOf from './distanceOf.js'; +import Sense from './Sense.js'; -import distanceOfCreator from './distanceOf.js'; - -describe('distanceOf', () => { - let distanceOf: ReturnType>; +describe('DistanceOf', () => { + let distanceOf: DistanceOf; let unit: any; beforeEach(() => { unit = { getDistanceOf: vi.fn() }; - distanceOf = distanceOfCreator()(unit); + distanceOf = new DistanceOf(unit); }); - test('is not an action', () => { - expect(distanceOf.action).toBeUndefined(); + test('is a sense', () => { + expect(distanceOf).toBeInstanceOf(Sense); }); test('has a description', () => { diff --git a/libs/abilities/src/distanceOf.ts b/libs/abilities/src/distanceOf.ts index 5f97c45c..d53fb072 100644 --- a/libs/abilities/src/distanceOf.ts +++ b/libs/abilities/src/distanceOf.ts @@ -1,16 +1,16 @@ -import type { Unit } from './types.js'; +import Sense from './Sense.js'; +import type { AbilityMeta } from './types.js'; -function distanceOf() { - return (unit: Unit) => ({ - description: 'Returns an integer representing the distance to the given space.', - perform(space: unknown) { - return unit.getDistanceOf(space); - }, - meta: { - params: [{ name: 'space', type: 'Space' as const }], - returns: 'number' as const, - }, - }); +class DistanceOf extends Sense { + readonly description = 'Returns an integer representing the distance to the given space.'; + readonly meta: AbilityMeta = { + params: [{ name: 'space', type: 'Space' }], + returns: 'number', + }; + + perform(space: unknown) { + return this.unit.getDistanceOf(space); + } } -export default distanceOf; +export default DistanceOf; diff --git a/libs/abilities/src/feel.test.ts b/libs/abilities/src/feel.test.ts index 7b435f25..cf7d2e35 100644 --- a/libs/abilities/src/feel.test.ts +++ b/libs/abilities/src/feel.test.ts @@ -1,19 +1,19 @@ import { FORWARD, LEFT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; +import Feel from './feel.js'; +import Sense from './Sense.js'; -import feelCreator from './feel.js'; - -describe('feel', () => { - let feel: ReturnType>; +describe('Feel', () => { + let feel: Feel; let unit: any; beforeEach(() => { unit = { getSensedSpaceAt: vi.fn() }; - feel = feelCreator()(unit); + feel = new Feel(unit); }); - test('is not an action', () => { - expect(feel.action).toBeUndefined(); + test('is a sense', () => { + expect(feel).toBeInstanceOf(Sense); }); test('has a description', () => { diff --git a/libs/abilities/src/feel.ts b/libs/abilities/src/feel.ts index 57d277bb..3cf5d550 100644 --- a/libs/abilities/src/feel.ts +++ b/libs/abilities/src/feel.ts @@ -1,20 +1,21 @@ import { FORWARD, type RelativeDirection } from '@warriorjs/spatial'; -import type { Unit } from './types.js'; +import Sense from './Sense.js'; +import type { AbilityMeta } from './types.js'; const defaultDirection = FORWARD; -function feel() { - return (unit: Unit) => ({ - description: `Returns the adjacent space in the given direction (\`'${defaultDirection}'\` by default).`, - perform(direction: RelativeDirection = defaultDirection) { - return unit.getSensedSpaceAt(direction); - }, - meta: { - params: [{ name: 'direction', type: 'Direction' as const, optional: true }], - returns: 'Space' as const, - }, - }); +class Feel extends Sense { + readonly description = + `Returns the adjacent space in the given direction (\`'${defaultDirection}'\` by default).`; + readonly meta: AbilityMeta = { + params: [{ name: 'direction', type: 'Direction', optional: true }], + returns: 'Space', + }; + + perform(direction: RelativeDirection = defaultDirection) { + return this.unit.getSensedSpaceAt(direction); + } } -export default feel; +export default Feel; diff --git a/libs/abilities/src/health.test.ts b/libs/abilities/src/health.test.ts index 9affe4ee..d0d0ccc5 100644 --- a/libs/abilities/src/health.test.ts +++ b/libs/abilities/src/health.test.ts @@ -1,18 +1,18 @@ import { beforeEach, describe, expect, test } from 'vitest'; +import Health from './health.js'; +import Sense from './Sense.js'; -import healthCreator from './health.js'; - -describe('health', () => { - let health: ReturnType>; +describe('Health', () => { + let health: Health; let unit: any; beforeEach(() => { unit = { health: 10 }; - health = healthCreator()(unit); + health = new Health(unit); }); - test('is not an action', () => { - expect(health.action).toBeUndefined(); + test('is a sense', () => { + expect(health).toBeInstanceOf(Sense); }); test('has a description', () => { diff --git a/libs/abilities/src/health.ts b/libs/abilities/src/health.ts index 543553b4..69260b76 100644 --- a/libs/abilities/src/health.ts +++ b/libs/abilities/src/health.ts @@ -1,16 +1,16 @@ -import type { Unit } from './types.js'; +import Sense from './Sense.js'; +import type { AbilityMeta } from './types.js'; -function health() { - return (unit: Unit) => ({ - description: 'Returns an integer representing your health.', - perform() { - return unit.health; - }, - meta: { - params: [], - returns: 'number' as const, - }, - }); +class Health extends Sense { + readonly description = 'Returns an integer representing your health.'; + readonly meta: AbilityMeta = { + params: [], + returns: 'number', + }; + + perform() { + return this.unit.health; + } } -export default health; +export default Health; diff --git a/libs/abilities/src/listen.test.ts b/libs/abilities/src/listen.test.ts index f0c2b3b5..977ef7ad 100644 --- a/libs/abilities/src/listen.test.ts +++ b/libs/abilities/src/listen.test.ts @@ -1,10 +1,10 @@ import { FORWARD, NORTH } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; +import Listen from './listen.js'; +import Sense from './Sense.js'; -import listenCreator from './listen.js'; - -describe('listen', () => { - let listen: ReturnType>; +describe('Listen', () => { + let listen: Listen; let unit: any; beforeEach(() => { @@ -19,11 +19,11 @@ describe('listen', () => { ], getSensedSpaceAt: vi.fn(), }; - listen = listenCreator()(unit); + listen = new Listen(unit); }); - test('is not an action', () => { - expect(listen.action).toBeUndefined(); + test('is a sense', () => { + expect(listen).toBeInstanceOf(Sense); }); test('has a description', () => { diff --git a/libs/abilities/src/listen.ts b/libs/abilities/src/listen.ts index 7e20d75a..b7237b21 100644 --- a/libs/abilities/src/listen.ts +++ b/libs/abilities/src/listen.ts @@ -1,27 +1,28 @@ import { FORWARD, getRelativeOffset } from '@warriorjs/spatial'; -import type { Unit } from './types.js'; +import Sense from './Sense.js'; +import type { AbilityMeta } from './types.js'; -function listen() { - return (unit: Unit) => ({ - description: 'Returns an array of all spaces which have units in them (excluding yourself).', - perform() { - return unit - .getOtherUnits() - .map((anotherUnit) => - getRelativeOffset( - anotherUnit.getSpace().location, - unit.position.location, - unit.position.orientation, - ), - ) - .map(([forward, right]) => unit.getSensedSpaceAt(FORWARD, forward, right)); - }, - meta: { - params: [], - returns: 'Space[]' as const, - }, - }); +class Listen extends Sense { + readonly description = + 'Returns an array of all spaces which have units in them (excluding yourself).'; + readonly meta: AbilityMeta = { + params: [], + returns: 'Space[]', + }; + + perform() { + return this.unit + .getOtherUnits() + .map((anotherUnit) => + getRelativeOffset( + anotherUnit.getSpace().location, + this.unit.position.location, + this.unit.position.orientation, + ), + ) + .map(([forward, right]) => this.unit.getSensedSpaceAt(FORWARD, forward, right)); + } } -export default listen; +export default Listen; diff --git a/libs/abilities/src/look.test.ts b/libs/abilities/src/look.test.ts index 2ac7fd20..b6b4af05 100644 --- a/libs/abilities/src/look.test.ts +++ b/libs/abilities/src/look.test.ts @@ -1,19 +1,19 @@ import { FORWARD, LEFT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; +import Look from './look.js'; +import Sense from './Sense.js'; -import lookCreator from './look.js'; - -describe('look', () => { - let look: ReturnType>; +describe('Look', () => { + let look: Look; let unit: any; beforeEach(() => { unit = { getSensedSpaceAt: vi.fn() }; - look = lookCreator({ range: 3 })(unit); + look = new Look(unit, { range: 3 }); }); - test('is not an action', () => { - expect(look.action).toBeUndefined(); + test('is a sense', () => { + expect(look).toBeInstanceOf(Sense); }); test('has a description', () => { @@ -29,6 +29,11 @@ describe('look', () => { }); }); + test('.with() returns an AbilityBinding', () => { + const binding = Look.with({ range: 3 }); + expect(binding).toEqual([Look, { range: 3 }]); + }); + describe('performing', () => { test('looks forward by default', () => { look.perform(); diff --git a/libs/abilities/src/look.ts b/libs/abilities/src/look.ts index fc6f78d0..c7f45b9f 100644 --- a/libs/abilities/src/look.ts +++ b/libs/abilities/src/look.ts @@ -1,23 +1,39 @@ import { FORWARD, type RelativeDirection } from '@warriorjs/spatial'; -import type { Unit } from './types.js'; +import type { AbilityBinding } from './Ability.js'; +import Sense from './Sense.js'; +import type { AbilityMeta, Unit } from './types.js'; const defaultDirection = FORWARD; -function look({ range }: { range: number }) { - return (unit: Unit) => ({ - description: `Returns an array of up to ${range} spaces in the given direction (\`'${defaultDirection}'\` by default).`, - perform(direction: RelativeDirection = defaultDirection) { - const offsets = Array.from(new Array(range), (_, index) => index + 1); - const spaces = offsets.map((offset) => unit.getSensedSpaceAt(direction, offset)); - const firstWallIndex = spaces.findIndex((space) => space?.isWall()); - return firstWallIndex === -1 ? spaces : spaces.slice(0, firstWallIndex + 1); - }, - meta: { - params: [{ name: 'direction', type: 'Direction' as const, optional: true }], - returns: 'Space[]' as const, - }, - }); +interface LookConfig { + range: number; } -export default look; +class Look extends Sense { + private range: number; + readonly description: string; + readonly meta: AbilityMeta = { + params: [{ name: 'direction', type: 'Direction', optional: true }], + returns: 'Space[]', + }; + + constructor(unit: Unit, { range }: LookConfig) { + super(unit); + this.range = range; + this.description = `Returns an array of up to ${range} spaces in the given direction (\`'${defaultDirection}'\` by default).`; + } + + perform(direction: RelativeDirection = defaultDirection) { + const offsets = Array.from(new Array(this.range), (_, index) => index + 1); + const spaces = offsets.map((offset) => this.unit.getSensedSpaceAt(direction, offset)); + const firstWallIndex = spaces.findIndex((space) => space?.isWall()); + return firstWallIndex === -1 ? spaces : spaces.slice(0, firstWallIndex + 1); + } + + static with(config: LookConfig): AbilityBinding { + return [Look, config as Record]; + } +} + +export default Look; diff --git a/libs/abilities/src/maxHealth.test.ts b/libs/abilities/src/maxHealth.test.ts index 456c7ff8..039c2865 100644 --- a/libs/abilities/src/maxHealth.test.ts +++ b/libs/abilities/src/maxHealth.test.ts @@ -1,18 +1,18 @@ import { beforeEach, describe, expect, test } from 'vitest'; +import MaxHealth from './maxHealth.js'; +import Sense from './Sense.js'; -import maxHealthCreator from './maxHealth.js'; - -describe('maxHealth', () => { - let maxHealth: ReturnType>; +describe('MaxHealth', () => { + let maxHealth: MaxHealth; let unit: any; beforeEach(() => { unit = { maxHealth: 10 }; - maxHealth = maxHealthCreator()(unit); + maxHealth = new MaxHealth(unit); }); - test('is not an action', () => { - expect(maxHealth.action).toBeUndefined(); + test('is a sense', () => { + expect(maxHealth).toBeInstanceOf(Sense); }); test('has a description', () => { diff --git a/libs/abilities/src/maxHealth.ts b/libs/abilities/src/maxHealth.ts index 985db0dc..aa448fc6 100644 --- a/libs/abilities/src/maxHealth.ts +++ b/libs/abilities/src/maxHealth.ts @@ -1,16 +1,16 @@ -import type { Unit } from './types.js'; +import Sense from './Sense.js'; +import type { AbilityMeta } from './types.js'; -function maxHealth() { - return (unit: Unit) => ({ - description: 'Returns an integer representing your maximum health.', - perform() { - return unit.maxHealth; - }, - meta: { - params: [], - returns: 'number' as const, - }, - }); +class MaxHealth extends Sense { + readonly description = 'Returns an integer representing your maximum health.'; + readonly meta: AbilityMeta = { + params: [], + returns: 'number', + }; + + perform() { + return this.unit.maxHealth; + } } -export default maxHealth; +export default MaxHealth; diff --git a/libs/abilities/src/think.test.ts b/libs/abilities/src/think.test.ts index 0317bee1..165d5c61 100644 --- a/libs/abilities/src/think.test.ts +++ b/libs/abilities/src/think.test.ts @@ -1,18 +1,19 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; -import thinkCreator from './think.js'; +import Sense from './Sense.js'; +import Think from './think.js'; -describe('think', () => { - let think: ReturnType>; +describe('Think', () => { + let think: Think; let unit: any; beforeEach(() => { unit = { log: vi.fn() }; - think = thinkCreator()(unit); + think = new Think(unit); }); - test('is not an action', () => { - expect(think.action).toBeUndefined(); + test('is a sense', () => { + expect(think).toBeInstanceOf(Sense); }); test('has a description', () => { diff --git a/libs/abilities/src/think.ts b/libs/abilities/src/think.ts index c513ff51..f8657bf8 100644 --- a/libs/abilities/src/think.ts +++ b/libs/abilities/src/think.ts @@ -1,19 +1,19 @@ import util from 'node:util'; -import type { Unit } from './types.js'; +import Sense from './Sense.js'; +import type { AbilityMeta } from './types.js'; -function think() { - return (unit: Unit) => ({ - description: 'Thinks out loud (`console.log` replacement).', - perform(...args: unknown[]) { - const thought = args.length > 0 ? util.format(...args) : 'nothing'; - unit.log(`thinks ${thought}`); - }, - meta: { - params: [{ name: 'args', type: 'any' as const, rest: true }], - returns: 'void' as const, - }, - }); +class Think extends Sense { + readonly description = 'Thinks out loud (`console.log` replacement).'; + readonly meta: AbilityMeta = { + params: [{ name: 'args', type: 'any', rest: true }], + returns: 'void', + }; + + perform(...args: unknown[]) { + const thought = args.length > 0 ? util.format(...args) : 'nothing'; + this.unit.log(`thinks ${thought}`); + } } -export default think; +export default Think; From ba187d1c9032b65ec1ccb54b9307822a81c6448b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Olivera?= Date: Mon, 16 Mar 2026 17:44:07 -0300 Subject: [PATCH 04/39] refactor(abilities): remove legacy types and fix AbilityBinding Remove old Ability interface and AbilityCreator type. Fix AbilityBinding to use a concrete constructor signature compatible with subclass constructors. Export AbilityMeta instead of removed types. Co-Authored-By: Claude Opus 4.6 (1M context) --- libs/abilities/src/Ability.ts | 2 +- libs/abilities/src/attack.ts | 2 +- libs/abilities/src/detonate.ts | 2 +- libs/abilities/src/index.ts | 2 +- libs/abilities/src/look.ts | 2 +- libs/abilities/src/rest.ts | 2 +- libs/abilities/src/shoot.ts | 2 +- libs/abilities/src/types.ts | 9 --------- 8 files changed, 7 insertions(+), 16 deletions(-) diff --git a/libs/abilities/src/Ability.ts b/libs/abilities/src/Ability.ts index c023e22c..7303d588 100644 --- a/libs/abilities/src/Ability.ts +++ b/libs/abilities/src/Ability.ts @@ -1,6 +1,6 @@ import type { AbilityMeta, Unit } from './types.js'; -export type AbilityBinding = [typeof Ability, Record]; +export type AbilityBinding = [new (unit: any, config: any) => Ability, object]; abstract class Ability { protected unit: Unit; diff --git a/libs/abilities/src/attack.ts b/libs/abilities/src/attack.ts index 387d0c8c..a3f5494e 100644 --- a/libs/abilities/src/attack.ts +++ b/libs/abilities/src/attack.ts @@ -37,7 +37,7 @@ class Attack extends Action { } static with(config: AttackConfig): AbilityBinding { - return [Attack, config as Record]; + return [Attack, config]; } } diff --git a/libs/abilities/src/detonate.ts b/libs/abilities/src/detonate.ts index fe325788..e67b5590 100644 --- a/libs/abilities/src/detonate.ts +++ b/libs/abilities/src/detonate.ts @@ -56,7 +56,7 @@ class Detonate extends Action { } static with(config: DetonateConfig): AbilityBinding { - return [Detonate, config as Record]; + return [Detonate, config]; } } diff --git a/libs/abilities/src/index.ts b/libs/abilities/src/index.ts index 4375e845..f9cd9cfd 100644 --- a/libs/abilities/src/index.ts +++ b/libs/abilities/src/index.ts @@ -18,5 +18,5 @@ export { default as rest } from './rest.js'; export { default as Sense } from './Sense.js'; export { default as shoot } from './shoot.js'; export { default as think } from './think.js'; -export type { AbilityCreator, SensedSpace, Space, Unit } from './types.js'; +export type { AbilityMeta, SensedSpace, Space, Unit } from './types.js'; export { default as walk } from './walk.js'; diff --git a/libs/abilities/src/look.ts b/libs/abilities/src/look.ts index c7f45b9f..fa393c28 100644 --- a/libs/abilities/src/look.ts +++ b/libs/abilities/src/look.ts @@ -32,7 +32,7 @@ class Look extends Sense { } static with(config: LookConfig): AbilityBinding { - return [Look, config as Record]; + return [Look, config]; } } diff --git a/libs/abilities/src/rest.ts b/libs/abilities/src/rest.ts index 0b8a33ab..3d1ab993 100644 --- a/libs/abilities/src/rest.ts +++ b/libs/abilities/src/rest.ts @@ -32,7 +32,7 @@ class Rest extends Action { } static with(config: RestConfig): AbilityBinding { - return [Rest, config as Record]; + return [Rest, config]; } } diff --git a/libs/abilities/src/shoot.ts b/libs/abilities/src/shoot.ts index e029752e..114312ae 100644 --- a/libs/abilities/src/shoot.ts +++ b/libs/abilities/src/shoot.ts @@ -41,7 +41,7 @@ class Shoot extends Action { } static with(config: ShootConfig): AbilityBinding { - return [Shoot, config as Record]; + return [Shoot, config]; } } diff --git a/libs/abilities/src/types.ts b/libs/abilities/src/types.ts index a75f178b..7caec3aa 100644 --- a/libs/abilities/src/types.ts +++ b/libs/abilities/src/types.ts @@ -50,12 +50,3 @@ export interface AbilityMeta { params: AbilityParam[]; returns: 'void' | 'number' | 'string' | 'Direction' | 'Space' | 'Space[]'; } - -export interface Ability { - action?: boolean; - description: string; - perform(...args: unknown[]): unknown; - meta?: AbilityMeta; -} - -export type AbilityCreator = (unit: Unit) => Ability; From b1001facfffd10a23674222844481adea93ca10c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Olivera?= Date: Mon, 16 Mar 2026 17:52:03 -0300 Subject: [PATCH 05/39] refactor(core): use instanceof Action for ability type checks Replace ability.action boolean with instanceof Action in getNextTurn and getAbilities. Add @warriorjs/abilities as core dependency. Update loadLevel to handle AbilityBinding, bare classes, and legacy factories. Update tests to use class-based abilities. Co-Authored-By: Claude Opus 4.6 (1M context) --- libs/core/package.json | 1 + libs/core/src/Unit.test.ts | 24 ++++++++++---- libs/core/src/Unit.ts | 5 +-- libs/core/src/Warrior.test.ts | 25 +++++++++++++-- libs/core/src/Warrior.ts | 22 +++++++------ libs/core/src/getLevel.test.ts | 26 ++++----------- libs/core/src/loadLevel.ts | 28 ++++++++++++---- libs/core/src/runLevel.test.ts | 58 ++++------------------------------ pnpm-lock.yaml | 3 ++ 9 files changed, 94 insertions(+), 98 deletions(-) diff --git a/libs/core/package.json b/libs/core/package.json index 31e313a1..65072033 100644 --- a/libs/core/package.json +++ b/libs/core/package.json @@ -45,6 +45,7 @@ "build": "tsc -p tsconfig.json" }, "dependencies": { + "@warriorjs/abilities": "workspace:^", "@warriorjs/spatial": "workspace:^", "esbuild": "^0.27.3" } diff --git a/libs/core/src/Unit.test.ts b/libs/core/src/Unit.test.ts index 722e3f42..ec4992ba 100644 --- a/libs/core/src/Unit.test.ts +++ b/libs/core/src/Unit.test.ts @@ -1,9 +1,22 @@ +import { Action, Sense } from '@warriorjs/abilities'; import { BACKWARD, FORWARD, LEFT, NORTH, RIGHT, SOUTH } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import Floor from './Floor.js'; import Unit from './Unit.js'; +class MockAction extends Action { + readonly description = 'mock action'; + readonly meta = { params: [], returns: 'void' as const }; + perform = vi.fn(); +} + +class MockSense extends Sense { + readonly description = 'mock sense'; + readonly meta = { params: [], returns: 'void' as const }; + perform = vi.fn(); +} + describe('Unit', () => { let unit: Unit; let floor: Floor; @@ -83,15 +96,12 @@ describe('Unit', () => { describe('next turn', () => { let turn: any; - let feel: any; - let walk: any; + let feel: MockSense; + let walk: MockAction; beforeEach(() => { - feel = { perform: vi.fn() }; - walk = { - action: true, - perform: vi.fn(), - }; + feel = new MockSense(unit); + walk = new MockAction(unit); unit.addAbility('feel', feel); unit.addAbility('walk', walk); turn = unit.getNextTurn(); diff --git a/libs/core/src/Unit.ts b/libs/core/src/Unit.ts index a56d36ed..ae8452f2 100644 --- a/libs/core/src/Unit.ts +++ b/libs/core/src/Unit.ts @@ -1,10 +1,11 @@ +import { Action } from '@warriorjs/abilities'; + import Logger from './Logger.js'; import type Position from './Position.js'; import type { SensedSpace, SensedUnit } from './Space.js'; import Space from './Space.js'; interface Ability { - action?: boolean; description?: string; perform(...args: any[]): any; } @@ -64,7 +65,7 @@ class Unit { getNextTurn(): Turn { const turn: Turn = { action: null }; this.abilities.forEach((ability, name) => { - if (ability.action) { + if (ability instanceof Action) { Object.defineProperty(turn, name, { value: (...args: any[]) => { if (turn.action) { diff --git a/libs/core/src/Warrior.test.ts b/libs/core/src/Warrior.test.ts index d2f63f9a..d322dae6 100644 --- a/libs/core/src/Warrior.test.ts +++ b/libs/core/src/Warrior.test.ts @@ -1,14 +1,35 @@ +import { Action, Sense } from '@warriorjs/abilities'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import Warrior from './Warrior.js'; +class MockAction extends Action { + readonly description: string; + readonly meta = { params: [], returns: 'void' as const }; + constructor(unit: any, description: string) { + super(unit); + this.description = description; + } + perform = vi.fn(); +} + +class MockSense extends Sense { + readonly description: string; + readonly meta = { params: [], returns: 'void' as const }; + constructor(unit: any, description: string) { + super(unit); + this.description = description; + } + perform = vi.fn(); +} + describe('Warrior', () => { let warrior: Warrior; beforeEach(() => { warrior = new Warrior('Joe', '@', '#8fbcbb', 20); - warrior.addAbility('feel', { description: 'a description' } as any); - warrior.addAbility('walk', { action: true, description: 'a description' } as any); + warrior.addAbility('feel', new MockSense(warrior, 'a description')); + warrior.addAbility('walk', new MockAction(warrior, 'a description')); warrior.log = vi.fn(); }); diff --git a/libs/core/src/Warrior.ts b/libs/core/src/Warrior.ts index a94bccfa..9d0ad6c6 100644 --- a/libs/core/src/Warrior.ts +++ b/libs/core/src/Warrior.ts @@ -1,8 +1,10 @@ +import { Action } from '@warriorjs/abilities'; + import Unit from './Unit.js'; interface AbilityInfo { name: string; - action?: boolean; + isAction: boolean; description?: string; } @@ -31,21 +33,21 @@ class Warrior extends Unit { } getAbilities(): { - actions: Omit[]; - senses: Omit[]; + actions: Omit[]; + senses: Omit[]; } { - const abilities: AbilityInfo[] = [...this.abilities].map(([name, { action, description }]) => ({ + const abilities: AbilityInfo[] = [...this.abilities].map(([name, ability]) => ({ name, - action, - description, + isAction: ability instanceof Action, + description: ability.description, })); const sortedAbilities = abilities.sort((a, b) => (a.name > b.name ? 1 : -1)); const actions = sortedAbilities - .filter((ability) => ability.action) - .map(({ action, ...rest }) => rest); + .filter((ability) => ability.isAction) + .map(({ isAction, ...rest }) => rest); const senses = sortedAbilities - .filter((ability) => !ability.action) - .map(({ action, ...rest }) => rest); + .filter((ability) => !ability.isAction) + .map(({ isAction, ...rest }) => rest); return { actions, senses, diff --git a/libs/core/src/getLevel.test.ts b/libs/core/src/getLevel.test.ts index 7cb1654a..1a6a3813 100644 --- a/libs/core/src/getLevel.test.ts +++ b/libs/core/src/getLevel.test.ts @@ -1,4 +1,5 @@ -import { EAST, FORWARD, RELATIVE_DIRECTIONS, WEST } from '@warriorjs/spatial'; +import { attack, feel, walk } from '@warriorjs/abilities'; +import { EAST, RELATIVE_DIRECTIONS, WEST } from '@warriorjs/spatial'; import { expect, test } from 'vitest'; import getLevel from './getLevel.js'; @@ -23,17 +24,9 @@ const levelConfig = { color: '#8fbcbb', maxHealth: 20, abilities: { - walk: () => ({ - action: true, - description: `Moves one space in the given direction (\`'${FORWARD}'\` by default).`, - }), - attack: () => ({ - action: true, - description: `Attacks a unit in the given direction (\`'${FORWARD}'\` by default), dealing 5 HP of damage.`, - }), - feel: () => ({ - description: `Returns the adjacent space in the given direction (\`'${FORWARD}'\` by default).`, - }), + walk: walk, + attack: attack.with({ power: 5 }), + feel: feel, }, position: { x: 0, @@ -48,13 +41,8 @@ const levelConfig = { color: '#d08770', maxHealth: 12, abilities: { - attack: () => ({ - action: true, - description: `Attacks a unit in the given direction (\`'${FORWARD}'\` by default), dealing 3 HP of damage.`, - }), - feel: () => ({ - description: `Returns the adjacent space in the given direction (\`'${FORWARD}'\` by default).`, - }), + attack: attack.with({ power: 3 }), + feel: feel, }, playTurn(sludge: any) { const playerDirection = RELATIVE_DIRECTIONS.find((direction) => { diff --git a/libs/core/src/loadLevel.ts b/libs/core/src/loadLevel.ts index 0683181d..9e3cd5d0 100644 --- a/libs/core/src/loadLevel.ts +++ b/libs/core/src/loadLevel.ts @@ -1,3 +1,5 @@ +import type { AbilityBinding, Ability as AbilityInstance } from '@warriorjs/abilities'; + import Floor from './Floor.js'; import Level from './Level.js'; import loadPlayer from './loadPlayer.js'; @@ -5,18 +7,30 @@ import type { LevelConfig, UnitConfig } from './types.js'; import Unit from './Unit.js'; import Warrior from './Warrior.js'; -function loadAbilities(unit: Unit, abilities: Record any> = {}): void { - Object.entries(abilities).forEach(([abilityName, abilityCreator]) => { - const ability = abilityCreator(unit); - unit.addAbility(abilityName, ability); - }); +type AbilityEntry = AbilityBinding | (new (unit: any) => AbilityInstance) | ((unit: Unit) => any); + +function loadAbilities(unit: Unit, abilities: Record = {}): void { + for (const [name, entry] of Object.entries(abilities)) { + if (Array.isArray(entry)) { + // AbilityBinding: [Class, config] + const [AbilityClass, config] = entry; + unit.addAbility(name, new AbilityClass(unit, config)); + } else if (typeof entry === 'function' && entry.prototype?.perform) { + // Bare ability class (no config) + unit.addAbility(name, new (entry as new (unit: any) => AbilityInstance)(unit)); + } else { + // Legacy factory function: (unit) => ability + const ability = (entry as (unit: Unit) => any)(unit); + unit.addAbility(name, ability); + } + } } function loadEffects(unit: Unit, effects: Record any> = {}): void { - Object.entries(effects).forEach(([effectName, effectCreator]) => { + for (const [effectName, effectCreator] of Object.entries(effects)) { const effect = effectCreator(unit); unit.addEffect(effectName, effect); - }); + } } function loadWarrior( diff --git a/libs/core/src/runLevel.test.ts b/libs/core/src/runLevel.test.ts index 6ce3db2d..ba1f3907 100644 --- a/libs/core/src/runLevel.test.ts +++ b/libs/core/src/runLevel.test.ts @@ -1,4 +1,5 @@ -import { BACKWARD, EAST, FORWARD, RELATIVE_DIRECTIONS, WEST } from '@warriorjs/spatial'; +import { attack, feel, walk } from '@warriorjs/abilities'; +import { EAST, RELATIVE_DIRECTIONS, WEST } from '@warriorjs/spatial'; import { expect, test } from 'vitest'; import runLevel from './runLevel.js'; @@ -18,37 +19,9 @@ const levelConfig = { character: '@', maxHealth: 20, abilities: { - walk: (unit: any) => ({ - action: true, - perform(direction = FORWARD) { - unit.log(`walks ${direction}`); - const space = unit.getSpaceAt(direction); - if (space.isEmpty()) { - unit.move(direction); - } else { - unit.log(`bumps into ${space}`); - } - }, - }), - attack: (unit: any) => ({ - action: true, - perform(direction = FORWARD) { - const receiver = unit.getSpaceAt(direction).getUnit(); - if (receiver) { - unit.log(`attacks ${direction} and hits ${receiver}`); - const attackingBackward = direction === BACKWARD; - const amount = attackingBackward ? 3 : 5; - unit.damage(receiver, amount); - } else { - unit.log(`attacks ${direction} and hits nothing`); - } - }, - }), - feel: (unit: any) => ({ - perform(direction = FORWARD) { - return unit.getSensedSpaceAt(direction); - }, - }), + walk: walk, + attack: attack.with({ power: 5 }), + feel: feel, }, position: { x: 0, @@ -62,25 +35,8 @@ const levelConfig = { character: 's', maxHealth: 12, abilities: { - attack: (unit: any) => ({ - action: true, - perform(direction = FORWARD) { - const receiver = unit.getSpaceAt(direction).getUnit(); - if (receiver) { - unit.log(`attacks ${direction} and hits ${receiver}`); - const attackingBackward = direction === BACKWARD; - const amount = attackingBackward ? 2 : 3; - unit.damage(receiver, amount); - } else { - unit.log(`attacks ${direction} and hits nothing`); - } - }, - }), - feel: (unit: any) => ({ - perform(direction = FORWARD) { - return unit.getSensedSpaceAt(direction); - }, - }), + attack: attack.with({ power: 3 }), + feel: feel, }, playTurn(sludge: any) { const threatDirection = RELATIVE_DIRECTIONS.find((direction) => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e8a2ff46..19a738ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -91,6 +91,9 @@ importers: libs/core: dependencies: + '@warriorjs/abilities': + specifier: workspace:^ + version: link:../abilities '@warriorjs/spatial': specifier: workspace:^ version: link:../spatial From f2a7b328ed65f3aa2ed3761e0d77a7882c8a37c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Olivera?= Date: Mon, 16 Mar 2026 17:53:38 -0300 Subject: [PATCH 06/39] refactor(cli): update renderTypes for class-based abilities Handle AbilityBinding, bare classes, and legacy factories when reading ability metadata for types.ts generation. Use instanceof Action for action/sense classification. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/cli/package.json | 1 + apps/cli/src/utils/renderTypes.test.ts | 104 ++++++++++++------------- apps/cli/src/utils/renderTypes.ts | 21 ++++- pnpm-lock.yaml | 3 + 4 files changed, 71 insertions(+), 58 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index af523514..41c5a4d5 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -55,6 +55,7 @@ "mock-fs": "^5.5.0" }, "dependencies": { + "@warriorjs/abilities": "workspace:^", "@warriorjs/core": "workspace:^", "@warriorjs/scoring": "workspace:^", "@warriorjs/tower-the-narrow-path": "workspace:^", diff --git a/apps/cli/src/utils/renderTypes.test.ts b/apps/cli/src/utils/renderTypes.test.ts index e3ceeb5b..bea8d145 100644 --- a/apps/cli/src/utils/renderTypes.test.ts +++ b/apps/cli/src/utils/renderTypes.test.ts @@ -1,34 +1,35 @@ +import type { AbilityMeta } from '@warriorjs/abilities'; +import { Action, Sense } from '@warriorjs/abilities'; import { describe, expect, test } from 'vitest'; import renderTypes from './renderTypes.js'; -const mockAbilities = { - walk: () => ({ - action: true, - description: 'Walks forward', - perform() {}, - meta: { - params: [{ name: 'direction', type: 'Direction' as const, optional: true }], - returns: 'void' as const, - }, - }), - feel: () => ({ - description: 'Feels the space ahead', - perform() {}, - meta: { - params: [{ name: 'direction', type: 'Direction' as const, optional: true }], - returns: 'Space' as const, - }, - }), - health: () => ({ - description: 'Returns current health', - perform() {}, - meta: { - params: [] as any[], - returns: 'number' as const, - }, - }), -}; +class MockWalk extends Action { + readonly description = 'Walks forward'; + readonly meta: AbilityMeta = { + params: [{ name: 'direction', type: 'Direction', optional: true }], + returns: 'void', + }; + perform() {} +} + +class MockFeel extends Sense { + readonly description = 'Feels the space ahead'; + readonly meta: AbilityMeta = { + params: [{ name: 'direction', type: 'Direction', optional: true }], + returns: 'Space', + }; + perform() {} +} + +class MockHealth extends Sense { + readonly description = 'Returns current health'; + readonly meta: AbilityMeta = { + params: [], + returns: 'number', + }; + perform() {} +} const profile: any = { language: 'typescript' }; @@ -38,7 +39,7 @@ function makeLevelConfig(abilities: Record): any { describe('renderTypes', () => { test('renders types with a single action', () => { - expect(renderTypes(profile, makeLevelConfig({ walk: mockAbilities.walk }))).toBe( + expect(renderTypes(profile, makeLevelConfig({ walk: MockWalk }))).toBe( [ '// @generated — Auto-generated each level. Do not edit.', '', @@ -58,9 +59,9 @@ describe('renderTypes', () => { renderTypes( profile, makeLevelConfig({ - health: mockAbilities.health, - walk: mockAbilities.walk, - feel: mockAbilities.feel, + health: MockHealth, + walk: MockWalk, + feel: MockFeel, }), ), ).toBe( @@ -107,12 +108,7 @@ describe('renderTypes', () => { }); test('omits Space and Unit interfaces when no abilities use Space', () => { - expect( - renderTypes( - profile, - makeLevelConfig({ walk: mockAbilities.walk, health: mockAbilities.health }), - ), - ).toBe( + expect(renderTypes(profile, makeLevelConfig({ walk: MockWalk, health: MockHealth }))).toBe( [ '// @generated — Auto-generated each level. Do not edit.', '', @@ -130,13 +126,12 @@ describe('renderTypes', () => { }); test('skips abilities without meta', () => { - const noMetaAbility = () => ({ - description: 'No meta', - perform() {}, - }); - expect( - renderTypes(profile, makeLevelConfig({ walk: mockAbilities.walk, legacy: noMetaAbility })), - ).toBe( + class NoMetaAbility extends Sense { + readonly description = 'No meta'; + readonly meta = undefined as any; + perform() {} + } + expect(renderTypes(profile, makeLevelConfig({ walk: MockWalk, legacy: NoMetaAbility }))).toBe( [ '// @generated — Auto-generated each level. Do not edit.', '', @@ -152,16 +147,15 @@ describe('renderTypes', () => { }); test('handles rest parameters', () => { - const restAbility = () => ({ - action: true, - description: 'Does something with rest params', - perform() {}, - meta: { - params: [{ name: 'targets', type: 'string', rest: true }], - returns: 'void' as const, - }, - }); - expect(renderTypes(profile, makeLevelConfig({ multi: restAbility }))).toBe( + class RestAction extends Action { + readonly description = 'Does something with rest params'; + readonly meta: AbilityMeta = { + params: [{ name: 'targets', type: 'any', rest: true }], + returns: 'void', + }; + perform() {} + } + expect(renderTypes(profile, makeLevelConfig({ multi: RestAction }))).toBe( [ '// @generated — Auto-generated each level. Do not edit.', '', @@ -169,7 +163,7 @@ describe('renderTypes', () => { '', 'export interface Warrior {', ' /** Does something with rest params */', - ' multi(...targets: string[]): void;', + ' multi(...targets: any[]): void;', '}', '', ].join('\n'), diff --git a/apps/cli/src/utils/renderTypes.ts b/apps/cli/src/utils/renderTypes.ts index 52d06a9f..5e856545 100644 --- a/apps/cli/src/utils/renderTypes.ts +++ b/apps/cli/src/utils/renderTypes.ts @@ -1,3 +1,4 @@ +import { Action } from '@warriorjs/abilities'; import type { LevelConfig } from '@warriorjs/core'; import type Profile from '../Profile.js'; @@ -56,14 +57,28 @@ function renderWarriorInterface(methods: MethodEntry[]): string { return `export interface Warrior {\n${body}\n}`; } +function instantiateAbility(entry: any): any { + if (Array.isArray(entry)) { + // AbilityBinding: [Class, config] + const [AbilityClass, config] = entry; + return new AbilityClass({} as any, config); + } + if (typeof entry === 'function' && entry.prototype?.perform) { + // Bare ability class + return new entry({} as any); + } + // Legacy factory + return entry({} as any); +} + function renderTypes(_profile: Profile, levelConfig: LevelConfig): string { const abilities = levelConfig.floor.warrior.abilities ?? {}; const methods: MethodEntry[] = []; let needsSpace = false; - for (const [name, creator] of Object.entries(abilities)) { - const ability = creator({} as any); + for (const [name, entry] of Object.entries(abilities)) { + const ability = instantiateAbility(entry); if (!ability.meta) { continue; } @@ -91,7 +106,7 @@ function renderTypes(_profile: Profile, levelConfig: LevelConfig): string { methods.push({ name, - action: !!ability.action, + action: ability instanceof Action, description: ability.description, signature: `${name}(${params.join(', ')}): ${returnType}`, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 19a738ea..15d0a569 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: apps/cli: dependencies: + '@warriorjs/abilities': + specifier: workspace:^ + version: link:../../libs/abilities '@warriorjs/core': specifier: workspace:^ version: link:../../libs/core From 28a2fcf4ab96e45ca99684de479b1841ff230de6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Olivera?= Date: Mon, 16 Mar 2026 17:57:24 -0300 Subject: [PATCH 07/39] refactor(units): use class-based ability bindings Replace factory function calls with AbilityBinding (.with()) and bare class references in unit ability declarations. Co-Authored-By: Claude Opus 4.6 (1M context) --- libs/units/src/Archer.ts | 4 ++-- libs/units/src/Sludge.ts | 4 ++-- libs/units/src/Wizard.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/libs/units/src/Archer.ts b/libs/units/src/Archer.ts index 623089a5..33590623 100644 --- a/libs/units/src/Archer.ts +++ b/libs/units/src/Archer.ts @@ -15,8 +15,8 @@ const Archer = { color: '#ebcb8b', maxHealth: 7, abilities: { - look: look({ range: 3 }), - shoot: shoot({ range: 3, power: 3 }), + look: look.with({ range: 3 }), + shoot: shoot.with({ range: 3, power: 3 }), }, playTurn(archer: UnitTurn) { const threatDirection = RELATIVE_DIRECTIONS.find((direction) => { diff --git a/libs/units/src/Sludge.ts b/libs/units/src/Sludge.ts index 9e5c7e2e..1c6e6872 100644 --- a/libs/units/src/Sludge.ts +++ b/libs/units/src/Sludge.ts @@ -14,8 +14,8 @@ const Sludge = { color: '#d08770', maxHealth: 12, abilities: { - attack: attack({ power: 3 }), - feel: feel(), + attack: attack.with({ power: 3 }), + feel: feel, }, playTurn(sludge: UnitTurn) { const threatDirection = RELATIVE_DIRECTIONS.find((direction) => { diff --git a/libs/units/src/Wizard.ts b/libs/units/src/Wizard.ts index 54627775..a0e880db 100644 --- a/libs/units/src/Wizard.ts +++ b/libs/units/src/Wizard.ts @@ -9,8 +9,8 @@ const Wizard = { color: '#b48ead', maxHealth: 3, abilities: { - look: look({ range: 3 }), - shoot: shoot({ range: 3, power: 11 }), + look: look.with({ range: 3 }), + shoot: shoot.with({ range: 3, power: 11 }), }, }; From 7016343e37c34062d0fa2504f65b787f8ca3579b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Olivera?= Date: Mon, 16 Mar 2026 18:21:56 -0300 Subject: [PATCH 08/39] refactor(towers): use class-based ability bindings Replace factory function calls with .with() bindings and bare class references in both tower definitions. Co-Authored-By: Claude Opus 4.6 (1M context) --- towers/the-narrow-path/src/index.ts | 38 ++++++++++++------------ towers/the-powder-keep/src/index.ts | 46 ++++++++++++++--------------- 2 files changed, 42 insertions(+), 42 deletions(-) diff --git a/towers/the-narrow-path/src/index.ts b/towers/the-narrow-path/src/index.ts index 57f6a206..5470f6a2 100644 --- a/towers/the-narrow-path/src/index.ts +++ b/towers/the-narrow-path/src/index.ts @@ -22,7 +22,7 @@ const tower: TowerDefinition = { { description: 'A long hallway stretches before you, torchlight glinting off stairs at the far end. The air is still. Nothing stirs.', - tip: "The path is clear. Call `warrior.walk()` to walk forward in the Player's `playTurn` method.", + tip: "The path is clear. Call `warrior.walk` to walk forward in the Player's `playTurn` method.", timeBonus: 15, aceScore: 10, floor: { @@ -37,8 +37,8 @@ const tower: TowerDefinition = { warrior: { ...Warrior, abilities: { - think: think(), - walk: walk(), + think: think, + walk: walk, }, position: { x: 0, @@ -52,8 +52,8 @@ const tower: TowerDefinition = { { description: 'The torches have gone out. Darkness swallows the corridor, but the stench of sludge hangs thick in the air.', - tip: "Something lurks ahead. Use `warrior.feel().isEmpty()` to check if the space is clear. If not, `warrior.attack()` will fight whatever's there. Remember: one action per turn.", - clue: 'Add an if/else condition using `warrior.feel().isEmpty()` to decide whether to attack or walk.', + tip: "Something lurks ahead. Use `warrior.feel.isEmpty()` to check if the space is clear. If not, `warrior.attack()` will fight whatever's there. Remember: one action per turn.", + clue: 'Add an if/else condition using `warrior.feel.isEmpty()` to decide whether to attack or walk.', timeBonus: 20, aceScore: 26, floor: { @@ -68,8 +68,8 @@ const tower: TowerDefinition = { warrior: { ...Warrior, abilities: { - attack: attack({ power: 5 }), - feel: feel(), + attack: attack.with({ power: 5 }), + feel: feel, }, position: { x: 0, @@ -92,7 +92,7 @@ const tower: TowerDefinition = { { description: 'The air is heavy and wet, almost hard to breathe. The stench is overwhelming — there must be a horde of them.', - tip: 'These walls will wear you down. Use `warrior.health()` and `warrior.maxHealth()` to keep watch over your health, and `warrior.rest()` to recover 10% of your max health.', + tip: 'These walls will wear you down. Use `warrior.health` and `warrior.maxHealth` to keep watch over your health, and `warrior.rest()` to recover 10% of your max health.', clue: "When there's no enemy ahead of you, call `warrior.rest()` until your health is full before walking forward.", timeBonus: 35, aceScore: 71, @@ -108,9 +108,9 @@ const tower: TowerDefinition = { warrior: { ...Warrior, abilities: { - health: health(), - maxHealth: maxHealth(), - rest: rest({ healthGain: 0.1 }), + health: health, + maxHealth: maxHealth, + rest: rest.with({ healthGain: 0.1 }), }, position: { x: 0, @@ -208,7 +208,7 @@ const tower: TowerDefinition = { }, { description: 'Muffled cries echo through the stone. Someone is alive down here — and bound.', - tip: 'Not every figure in the dark is a foe. Use `warrior.feel().getUnit().isEnemy()` and `warrior.feel().getUnit().isBound()` to identify captives, and `warrior.rescue()` to free them.', + tip: 'Not every figure in the dark is a foe. Use `warrior.feel.getUnit().isEnemy()` and `warrior.feel.getUnit().isBound()` to identify captives, and `warrior.rescue` to free them.', clue: "Don't forget to constantly check if you are being attacked. Rest until your health is full if you're not taking damage.", timeBonus: 45, aceScore: 123, @@ -224,7 +224,7 @@ const tower: TowerDefinition = { warrior: { ...Warrior, abilities: { - rescue: rescue(), + rescue: rescue, }, position: { x: 0, @@ -279,8 +279,8 @@ const tower: TowerDefinition = { { description: 'The corridor opens wider than before. Cries reach you from both ends — ahead and behind.', - tip: "Danger on two fronts. Pass `'backward'` to `walk()`, `feel()`, `rescue()`, and `attack()` to act behind you. Archers have a limited attack distance.", - clue: "Walk backward if you're taking damage from afar and don't have enough health to attack. You may also want to consider walking backward until you hit a wall. Use `warrior.feel().isWall()` to see if there's a wall.", + tip: "Danger on two fronts. Pass `'backward'` to `walk`, `feel`, `rescue`, and `attack()` to act behind you. Archers have a limited attack distance.", + clue: "Walk backward if you're taking damage from afar and don't have enough health to attack. You may also want to consider walking backward until you hit a wall. Use `warrior.feel.isWall()` to see if there's a wall.", timeBonus: 55, aceScore: 105, floor: { @@ -339,7 +339,7 @@ const tower: TowerDefinition = { { description: 'Cold stone meets your outstretched hand. A dead end — but a draft at your back tells you the way lies behind.', - tip: "Fighting backward dulls your blade. Use `warrior.feel().isWall()` to detect the wall, and `warrior.pivot()` to turn and face what's coming.", + tip: "Fighting backward dulls your blade. Use `warrior.feel.isWall()` to detect the wall, and `warrior.pivot` to turn and face what's coming.", timeBonus: 30, aceScore: 50, floor: { @@ -354,7 +354,7 @@ const tower: TowerDefinition = { warrior: { ...Warrior, abilities: { - pivot: pivot(), + pivot: pivot, }, position: { x: 5, @@ -406,8 +406,8 @@ const tower: TowerDefinition = { facing: EAST, }, abilities: { - look: look({ range: 3 }), - shoot: shoot({ power: 3, range: 3 }), + look: look.with({ range: 3 }), + shoot: shoot.with({ power: 3, range: 3 }), }, }, units: [ diff --git a/towers/the-powder-keep/src/index.ts b/towers/the-powder-keep/src/index.ts index 305be12b..5b9ddf9e 100644 --- a/towers/the-powder-keep/src/index.ts +++ b/towers/the-powder-keep/src/index.ts @@ -27,7 +27,7 @@ const tower: TowerDefinition = { { description: 'Silence. The room stretches wide and empty, your footsteps swallowed by the dark. A crumpled map in your hand marks the way to the stairs.', - tip: "The dark won't guide you, but the map will. Use `warrior.directionOfStairs()` to find the stairs, and pass the result to `warrior.walk()` to move toward them.", + tip: "The dark won't guide you, but the map will. Use `warrior.directionOfStairs` to find the stairs, and pass the result to `warrior.walk` to move toward them.", timeBonus: 20, aceScore: 19, floor: { @@ -42,9 +42,9 @@ const tower: TowerDefinition = { warrior: { ...Warrior, abilities: { - directionOfStairs: directionOfStairs(), - think: think(), - walk: walk(), + directionOfStairs: directionOfStairs, + think: think, + walk: walk, }, position: { x: 0, @@ -59,7 +59,7 @@ const tower: TowerDefinition = { description: 'The next chamber is not empty. Shapes shift in the darkness on all sides, between you and the stairs.', tip: 'Threats can come from any direction now. You can attack and feel forward, left, right, and backward.', - clue: "Call `warrior.feel().isUnit()` and `warrior.feel().getUnit().isEnemy()` in each direction to make sure there isn't an enemy beside you (attack if there is). Call `warrior.rest()` if you're low in health when there are no enemies around.", + clue: "Call `warrior.feel.isUnit()` and `warrior.feel.getUnit().isEnemy()` in each direction to make sure there isn't an enemy beside you (attack if there is). Call `warrior.rest()` if you're low in health when there are no enemies around.", timeBonus: 40, aceScore: 84, floor: { @@ -74,11 +74,11 @@ const tower: TowerDefinition = { warrior: { ...Warrior, abilities: { - attack: attack({ power: 5 }), - feel: feel(), - health: health(), - maxHealth: maxHealth(), - rest: rest({ healthGain: 0.1 }), + attack: attack.with({ power: 5 }), + feel: feel, + health: health, + maxHealth: maxHealth, + rest: rest.with({ healthGain: 0.1 }), }, position: { x: 0, @@ -116,7 +116,7 @@ const tower: TowerDefinition = { }, { description: 'Slime presses against you from every direction. You are surrounded.', - tip: 'Too many to fight at once. Call `warrior.bind()` to hold an enemy in place while you deal with the others.', + tip: 'Too many to fight at once. Call `warrior.bind` to hold an enemy in place while you deal with the others.', clue: 'Count the number of unbound enemies around you. Bind an enemy if there are two or more.', timeBonus: 50, aceScore: 101, @@ -137,8 +137,8 @@ const tower: TowerDefinition = { facing: EAST, }, abilities: { - bind: bind(), - rescue: rescue(), + bind: bind, + rescue: rescue, }, }, units: [ @@ -180,8 +180,8 @@ const tower: TowerDefinition = { { description: 'Your eyes are useless here, but your ears sharpen. Breathing. Struggling. Faint sounds scattered across the room.', - tip: 'Listen for what you cannot see. Use `warrior.listen()` to find spaces with other units, and `warrior.directionOf()` to determine which way they are.', - clue: 'Walk towards a unit with `warrior.walk(warrior.directionOf(warrior.listen()[0]))`. Once `warrior.listen().length === 0`, head for the stairs.', + tip: 'Listen for what you cannot see. Use `warrior.listen` to find spaces with other units, and `warrior.directionOf` to determine which way they are.', + clue: 'Walk towards a unit with `warrior.walk(warrior.directionOf(warrior.listen[0]))`. Once `warrior.listen.length === 0`, head for the stairs.', timeBonus: 55, aceScore: 144, floor: { @@ -201,8 +201,8 @@ const tower: TowerDefinition = { facing: EAST, }, abilities: { - directionOf: directionOf(), - listen: listen(), + directionOf: directionOf, + listen: listen, }, }, units: [ @@ -252,7 +252,7 @@ const tower: TowerDefinition = { { description: 'The stairs are right beside you — you could leave now. But the room beyond is not empty, and neither is your conscience.', - tip: 'Leaving is easy. Clearing the floor is worth more. Use `warrior.feel().isStairs()` and `warrior.feel().isEmpty()` to choose your path.', + tip: 'Leaving is easy. Clearing the floor is worth more. Use `warrior.feel.isStairs()` and `warrior.feel.isEmpty()` to choose your path.', clue: 'If going towards a unit is the same direction as the stairs, try moving in another empty direction until you can safely move toward the enemies.', timeBonus: 45, aceScore: 107, @@ -305,7 +305,7 @@ const tower: TowerDefinition = { description: 'A rhythmic ticking cuts through the silence. Somewhere in the dark, a captive kneels over a bomb that will not wait.', tip: "Time is short. Rescue captives with `space.getUnit().isUnderEffect('ticking')` first — they won't last long.", - clue: "Avoid fighting enemies at first. Use `warrior.listen()` and `space.getUnit().isUnderEffect('ticking')` and quickly rescue those captives.", + clue: "Avoid fighting enemies at first. Use `warrior.listen` and `space.getUnit().isUnderEffect('ticking')` and quickly rescue those captives.", timeBonus: 50, aceScore: 108, floor: { @@ -459,8 +459,8 @@ const tower: TowerDefinition = { facing: EAST, }, abilities: { - detonate: detonate({ targetPower: 8, surroundingPower: 4 }), - look: look({ range: 3 }), + detonate: detonate.with({ targetPower: 8, surroundingPower: 4 }), + look: look.with({ range: 3 }), }, }, units: [ @@ -497,7 +497,7 @@ const tower: TowerDefinition = { { description: 'The final chamber writhes with sludge — more than you have ever seen. The ticking beneath the floor has not stopped.', - tip: 'One wrong blast and the captive dies with the rest. Use `warrior.distanceOf()` to keep the flames clear of those you came to save.', + tip: 'One wrong blast and the captive dies with the rest. Use `warrior.distanceOf` to keep the flames clear of those you came to save.', clue: 'Be sure to bind the surrounding enemies before fighting. Check your health before detonating explosives.', timeBonus: 70, aceScore: 176, @@ -518,7 +518,7 @@ const tower: TowerDefinition = { facing: EAST, }, abilities: { - distanceOf: distanceOf(), + distanceOf: distanceOf, }, }, units: [ From 3a7078f03b5e64e4ce630866b8bfc174db6a475b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Olivera?= Date: Mon, 16 Mar 2026 18:46:39 -0300 Subject: [PATCH 09/39] refactor(units): convert unit definitions to classes Units are now classes extending Unit from core, with MeleeUnit and RangedUnit abstract intermediaries providing shared playTurn behavior. Tower configs use { unit: new Sludge(), position: ... } instead of spreading plain objects. Engine handles both formats via loadUnitFromInstance/loadUnitFromConfig. Deep clone preserves class instances. Co-Authored-By: Claude Opus 4.6 (1M context) --- libs/core/src/getLevelConfig.ts | 5 + libs/core/src/index.ts | 2 + libs/core/src/loadLevel.ts | 34 ++- libs/core/src/types.ts | 14 +- libs/units/package.json | 1 + libs/units/src/Archer.test.ts | 35 ++- libs/units/src/Archer.ts | 34 +-- libs/units/src/Captive.test.ts | 25 +- libs/units/src/Captive.ts | 17 +- libs/units/src/MeleeUnit.ts | 25 ++ libs/units/src/RangedUnit.ts | 25 ++ libs/units/src/Sludge.test.ts | 31 +- libs/units/src/Sludge.ts | 33 +-- libs/units/src/ThickSludge.test.ts | 31 +- libs/units/src/ThickSludge.ts | 23 +- libs/units/src/Wizard.test.ts | 35 ++- libs/units/src/Wizard.ts | 19 +- libs/units/src/index.ts | 2 + pnpm-lock.yaml | 3 + towers/the-narrow-path/src/index.ts | 443 +++++----------------------- towers/the-powder-keep/src/index.ts | 72 ++--- 21 files changed, 355 insertions(+), 554 deletions(-) create mode 100644 libs/units/src/MeleeUnit.ts create mode 100644 libs/units/src/RangedUnit.ts diff --git a/libs/core/src/getLevelConfig.ts b/libs/core/src/getLevelConfig.ts index 712d4566..c75ac747 100644 --- a/libs/core/src/getLevelConfig.ts +++ b/libs/core/src/getLevelConfig.ts @@ -13,6 +13,11 @@ function deepClone(obj: T): T { return obj.map((item) => deepClone(item)) as T; } + // Preserve class instances (Unit subclasses, etc.) — don't deep-clone them. + if (obj.constructor !== Object) { + return obj; + } + const clone = {} as Record; for (const key of Object.keys(obj)) { clone[key] = deepClone((obj as Record)[key]); diff --git a/libs/core/src/index.ts b/libs/core/src/index.ts index 02facd01..1c9134ce 100644 --- a/libs/core/src/index.ts +++ b/libs/core/src/index.ts @@ -7,5 +7,7 @@ export type { TowerDefinition, TowerFloorUnit, TowerLevel, + TowerUnitEntry, UnitConfig, } from './types.js'; +export { default as Unit } from './Unit.js'; diff --git a/libs/core/src/loadLevel.ts b/libs/core/src/loadLevel.ts index 9e3cd5d0..208affbb 100644 --- a/libs/core/src/loadLevel.ts +++ b/libs/core/src/loadLevel.ts @@ -3,7 +3,7 @@ import type { AbilityBinding, Ability as AbilityInstance } from '@warriorjs/abil import Floor from './Floor.js'; import Level from './Level.js'; import loadPlayer from './loadPlayer.js'; -import type { LevelConfig, UnitConfig } from './types.js'; +import type { LevelConfig, TowerUnitEntry, UnitConfig } from './types.js'; import Unit from './Unit.js'; import Warrior from './Warrior.js'; @@ -46,8 +46,12 @@ function loadWarrior( floor.addWarrior(warrior, position); } -function loadUnit( - { +function isTowerUnitEntry(entry: UnitConfig | TowerUnitEntry): entry is TowerUnitEntry { + return 'unit' in entry && entry.unit instanceof Unit; +} + +function loadUnitFromConfig(config: UnitConfig, floor: Floor): void { + const { name, character, color, @@ -59,9 +63,7 @@ function loadUnit( effects, playTurn, position, - }: UnitConfig, - floor: Floor, -): void { + } = config; const unit = new Unit(name, character, color, maxHealth, reward, enemy, bound); loadAbilities(unit, abilities); loadEffects(unit, effects); @@ -71,6 +73,18 @@ function loadUnit( floor.addUnit(unit, position); } +function loadUnitFromInstance(entry: TowerUnitEntry, floor: Floor): void { + const { unit, effects, position } = entry; + const declaredAbilities = (unit as any).declaredAbilities; + if (declaredAbilities) { + loadAbilities(unit, declaredAbilities); + } + if (effects) { + loadEffects(unit, effects); + } + floor.addUnit(unit, position); +} + function loadLevel( { number, description, tip, clue, floor: { size, stairs, warrior, units = [] } }: LevelConfig, playerCode?: string, @@ -81,7 +95,13 @@ function loadLevel( const floor = new Floor(width, height, stairsLocation); loadWarrior(warrior, floor, playerCode, language); - units.forEach((unit) => loadUnit(unit, floor)); + for (const entry of units) { + if (isTowerUnitEntry(entry)) { + loadUnitFromInstance(entry, floor); + } else { + loadUnitFromConfig(entry, floor); + } + } return new Level(number!, description!, tip!, clue!, floor); } diff --git a/libs/core/src/types.ts b/libs/core/src/types.ts index bdb5f94f..b23ab3c5 100644 --- a/libs/core/src/types.ts +++ b/libs/core/src/types.ts @@ -1,3 +1,5 @@ +import type Unit from './Unit.js'; + export interface UnitConfig { name: string; character: string; @@ -6,7 +8,7 @@ export interface UnitConfig { reward?: number; enemy?: boolean; bound?: boolean; - abilities?: Record any>; + abilities?: Record; effects?: Record any>; playTurn?: (turn: any) => void; position: { x: number; y: number; facing: string }; @@ -23,10 +25,16 @@ export interface LevelConfig { size: { width: number; height: number }; stairs: { x: number; y: number }; warrior: UnitConfig; - units?: UnitConfig[]; + units?: (UnitConfig | TowerUnitEntry)[]; }; } +export interface TowerUnitEntry { + unit: Unit; + effects?: Record any>; + position: { x: number; y: number; facing: string }; +} + export interface TowerFloorUnit { [key: string]: unknown; position: { x: number; y: number; facing?: string }; @@ -42,7 +50,7 @@ export interface TowerLevel { size: { width: number; height: number }; stairs: { x: number; y: number }; warrior: TowerFloorUnit; - units: TowerFloorUnit[]; + units: (TowerFloorUnit | TowerUnitEntry)[]; }; } diff --git a/libs/units/package.json b/libs/units/package.json index 62e6c66d..b937f0e3 100644 --- a/libs/units/package.json +++ b/libs/units/package.json @@ -28,6 +28,7 @@ }, "dependencies": { "@warriorjs/abilities": "workspace:^", + "@warriorjs/core": "workspace:^", "@warriorjs/spatial": "workspace:^" } } diff --git a/libs/units/src/Archer.test.ts b/libs/units/src/Archer.test.ts index 292f9c99..b3b9f07b 100644 --- a/libs/units/src/Archer.test.ts +++ b/libs/units/src/Archer.test.ts @@ -2,28 +2,37 @@ import { BACKWARD, FORWARD, LEFT, RIGHT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import Archer from './Archer.js'; - -vi.mock('@warriorjs/abilities'); +import RangedUnit from './RangedUnit.js'; describe('Archer', () => { + let archer: Archer; + + beforeEach(() => { + archer = new Archer(); + }); + + test('extends RangedUnit', () => { + expect(archer).toBeInstanceOf(RangedUnit); + }); + test("appears as 'a' on map", () => { - expect(Archer.character).toBe('a'); + expect(archer.character).toBe('a'); }); test('has #ebcb8b color', () => { - expect(Archer.color).toBe('#ebcb8b'); + expect(archer.color).toBe('#ebcb8b'); }); test('has 7 max health', () => { - expect(Archer.maxHealth).toBe(7); + expect(archer.maxHealth).toBe(7); }); - test('has shoot ability with power 3 and range 3', () => { - expect(Archer.abilities).toHaveProperty('shoot'); + test('has shoot ability', () => { + expect(archer.declaredAbilities).toHaveProperty('shoot'); }); - test('has look ability with range 3', () => { - expect(Archer.abilities).toHaveProperty('look'); + test('has look ability', () => { + expect(archer.declaredAbilities).toHaveProperty('look'); }); describe('playing turn', () => { @@ -39,7 +48,7 @@ describe('Archer', () => { }); test('looks for player in all directions', () => { - Archer.playTurn(turn); + archer.playTurn(turn); expect(turn.look).toHaveBeenCalledWith(FORWARD); expect(turn.look).toHaveBeenCalledWith(RIGHT); expect(turn.look).toHaveBeenCalledWith(BACKWARD); @@ -56,7 +65,7 @@ describe('Archer', () => { }, anotherSpace, ]); - Archer.playTurn(turn); + archer.playTurn(turn); expect(anotherSpace.isUnit).not.toHaveBeenCalled(); }); @@ -72,7 +81,7 @@ describe('Archer', () => { }, space, ]); - Archer.playTurn(turn); + archer.playTurn(turn); expect(turn.look).toHaveBeenCalledWith(FORWARD); expect(turn.look).toHaveBeenCalledWith(RIGHT); expect(turn.look).not.toHaveBeenCalledWith(BACKWARD); @@ -92,7 +101,7 @@ describe('Archer', () => { }), }, ]); - Archer.playTurn(turn); + archer.playTurn(turn); expect(turn.shoot).not.toHaveBeenCalled(); }); }); diff --git a/libs/units/src/Archer.ts b/libs/units/src/Archer.ts index 33590623..09ec0627 100644 --- a/libs/units/src/Archer.ts +++ b/libs/units/src/Archer.ts @@ -1,32 +1,16 @@ import { look, shoot } from '@warriorjs/abilities'; -import { RELATIVE_DIRECTIONS } from '@warriorjs/spatial'; -export interface UnitTurn { - look(direction: string): Array<{ - isUnit(): boolean; - getUnit(): { isEnemy(): boolean; isBound(): boolean }; - }>; - shoot(direction: string): void; -} +import RangedUnit from './RangedUnit.js'; -const Archer = { - name: 'Archer', - character: 'a', - color: '#ebcb8b', - maxHealth: 7, - abilities: { +class Archer extends RangedUnit { + declaredAbilities = { look: look.with({ range: 3 }), shoot: shoot.with({ range: 3, power: 3 }), - }, - playTurn(archer: UnitTurn) { - const threatDirection = RELATIVE_DIRECTIONS.find((direction) => { - const spaceWithUnit = archer.look(direction).find((space) => space.isUnit()); - return spaceWithUnit?.getUnit().isEnemy() && !spaceWithUnit.getUnit().isBound(); - }); - if (threatDirection) { - archer.shoot(threatDirection); - } - }, -}; + }; + + constructor() { + super('Archer', 'a', '#ebcb8b', 7); + } +} export default Archer; diff --git a/libs/units/src/Captive.test.ts b/libs/units/src/Captive.test.ts index 9746b924..d54414ac 100644 --- a/libs/units/src/Captive.test.ts +++ b/libs/units/src/Captive.test.ts @@ -1,29 +1,40 @@ -import { describe, expect, test } from 'vitest'; +import { Unit } from '@warriorjs/core'; +import { beforeEach, describe, expect, test } from 'vitest'; import Captive from './Captive.js'; describe('Captive', () => { + let captive: Captive; + + beforeEach(() => { + captive = new Captive(); + }); + + test('extends Unit', () => { + expect(captive).toBeInstanceOf(Unit); + }); + test("appears as 'C' on map", () => { - expect(Captive.character).toBe('C'); + expect(captive.character).toBe('C'); }); test('has #81a1c1 color', () => { - expect(Captive.color).toBe('#81a1c1'); + expect(captive.color).toBe('#81a1c1'); }); test('has 1 max health', () => { - expect(Captive.maxHealth).toBe(1); + expect(captive.maxHealth).toBe(1); }); test('has a reward of 20 points', () => { - expect(Captive.reward).toBe(20); + expect(captive.reward).toBe(20); }); test('is not an enemy', () => { - expect(Captive.enemy).toBe(false); + expect(captive.enemy).toBe(false); }); test('is bound', () => { - expect(Captive.bound).toBe(true); + expect(captive.bound).toBe(true); }); }); diff --git a/libs/units/src/Captive.ts b/libs/units/src/Captive.ts index bd59b545..b83baa35 100644 --- a/libs/units/src/Captive.ts +++ b/libs/units/src/Captive.ts @@ -1,12 +1,9 @@ -const Captive = { - name: 'Captive', - character: 'C', - color: '#81a1c1', - maxHealth: 1, - reward: 20, - enemy: false, - bound: true, - playTurn() {}, -}; +import { Unit } from '@warriorjs/core'; + +class Captive extends Unit { + constructor() { + super('Captive', 'C', '#81a1c1', 1, 20, false, true); + } +} export default Captive; diff --git a/libs/units/src/MeleeUnit.ts b/libs/units/src/MeleeUnit.ts new file mode 100644 index 00000000..fdf366ab --- /dev/null +++ b/libs/units/src/MeleeUnit.ts @@ -0,0 +1,25 @@ +import { Unit } from '@warriorjs/core'; +import { RELATIVE_DIRECTIONS } from '@warriorjs/spatial'; + +abstract class MeleeUnit extends Unit { + constructor( + name: string, + character: string, + color: string, + maxHealth: number, + reward?: number | null, + ) { + super(name, character, color, maxHealth, reward); + this.playTurn = (turn: any) => { + const threatDirection = RELATIVE_DIRECTIONS.find((direction) => { + const unit = turn.feel(direction).getUnit(); + return unit?.isEnemy() && !unit.isBound(); + }); + if (threatDirection) { + turn.attack(threatDirection); + } + }; + } +} + +export default MeleeUnit; diff --git a/libs/units/src/RangedUnit.ts b/libs/units/src/RangedUnit.ts new file mode 100644 index 00000000..ee4c8b23 --- /dev/null +++ b/libs/units/src/RangedUnit.ts @@ -0,0 +1,25 @@ +import { Unit } from '@warriorjs/core'; +import { RELATIVE_DIRECTIONS } from '@warriorjs/spatial'; + +abstract class RangedUnit extends Unit { + constructor( + name: string, + character: string, + color: string, + maxHealth: number, + reward?: number | null, + ) { + super(name, character, color, maxHealth, reward); + this.playTurn = (turn: any) => { + const threatDirection = RELATIVE_DIRECTIONS.find((direction) => { + const spaceWithUnit = turn.look(direction).find((space: any) => space.isUnit()); + return spaceWithUnit?.getUnit().isEnemy() && !spaceWithUnit.getUnit().isBound(); + }); + if (threatDirection) { + turn.shoot(threatDirection); + } + }; + } +} + +export default RangedUnit; diff --git a/libs/units/src/Sludge.test.ts b/libs/units/src/Sludge.test.ts index 22fd4d13..e2570185 100644 --- a/libs/units/src/Sludge.test.ts +++ b/libs/units/src/Sludge.test.ts @@ -1,29 +1,38 @@ import { BACKWARD, FORWARD, LEFT, RIGHT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; +import MeleeUnit from './MeleeUnit.js'; import Sludge from './Sludge.js'; -vi.mock('@warriorjs/abilities'); - describe('Sludge', () => { + let sludge: Sludge; + + beforeEach(() => { + sludge = new Sludge(); + }); + + test('extends MeleeUnit', () => { + expect(sludge).toBeInstanceOf(MeleeUnit); + }); + test("appears as 's' on map", () => { - expect(Sludge.character).toBe('s'); + expect(sludge.character).toBe('s'); }); test('has #d08770 color', () => { - expect(Sludge.color).toBe('#d08770'); + expect(sludge.color).toBe('#d08770'); }); test('has 12 max health', () => { - expect(Sludge.maxHealth).toBe(12); + expect(sludge.maxHealth).toBe(12); }); - test('has attack ability with power 3', () => { - expect(Sludge.abilities).toHaveProperty('attack'); + test('has attack ability', () => { + expect(sludge.declaredAbilities).toHaveProperty('attack'); }); test('has feel ability', () => { - expect(Sludge.abilities).toHaveProperty('feel'); + expect(sludge.declaredAbilities).toHaveProperty('feel'); }); describe('playing turn', () => { @@ -39,7 +48,7 @@ describe('Sludge', () => { }); test('looks for player in all directions', () => { - Sludge.playTurn(turn); + sludge.playTurn(turn); expect(turn.feel).toHaveBeenCalledWith(FORWARD); expect(turn.feel).toHaveBeenCalledWith(RIGHT); expect(turn.feel).toHaveBeenCalledWith(BACKWARD); @@ -53,7 +62,7 @@ describe('Sludge', () => { isEnemy: () => true, }), }); - Sludge.playTurn(turn); + sludge.playTurn(turn); expect(turn.feel).toHaveBeenCalledWith(FORWARD); expect(turn.feel).toHaveBeenCalledWith(RIGHT); expect(turn.feel).not.toHaveBeenCalledWith(BACKWARD); @@ -62,7 +71,7 @@ describe('Sludge', () => { }); test("does nothing if it doesn't find threat", () => { - Sludge.playTurn(turn); + sludge.playTurn(turn); expect(turn.attack).not.toHaveBeenCalled(); }); }); diff --git a/libs/units/src/Sludge.ts b/libs/units/src/Sludge.ts index 1c6e6872..f024e2c8 100644 --- a/libs/units/src/Sludge.ts +++ b/libs/units/src/Sludge.ts @@ -1,31 +1,16 @@ import { attack, feel } from '@warriorjs/abilities'; -import { RELATIVE_DIRECTIONS } from '@warriorjs/spatial'; -export interface UnitTurn { - feel(direction: string): { - getUnit(): { isEnemy(): boolean; isBound(): boolean } | undefined; - }; - attack(direction: string): void; -} +import MeleeUnit from './MeleeUnit.js'; -const Sludge = { - name: 'Sludge', - character: 's', - color: '#d08770', - maxHealth: 12, - abilities: { +class Sludge extends MeleeUnit { + declaredAbilities = { attack: attack.with({ power: 3 }), feel: feel, - }, - playTurn(sludge: UnitTurn) { - const threatDirection = RELATIVE_DIRECTIONS.find((direction) => { - const unit = sludge.feel(direction).getUnit(); - return unit?.isEnemy() && !unit.isBound(); - }); - if (threatDirection) { - sludge.attack(threatDirection); - } - }, -}; + }; + + constructor() { + super('Sludge', 's', '#d08770', 12); + } +} export default Sludge; diff --git a/libs/units/src/ThickSludge.test.ts b/libs/units/src/ThickSludge.test.ts index 45558b44..2a75d42c 100644 --- a/libs/units/src/ThickSludge.test.ts +++ b/libs/units/src/ThickSludge.test.ts @@ -1,29 +1,38 @@ import { BACKWARD, FORWARD, LEFT, RIGHT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; +import MeleeUnit from './MeleeUnit.js'; import ThickSludge from './ThickSludge.js'; -vi.mock('@warriorjs/abilities'); - describe('ThickSludge', () => { + let thickSludge: ThickSludge; + + beforeEach(() => { + thickSludge = new ThickSludge(); + }); + + test('extends MeleeUnit', () => { + expect(thickSludge).toBeInstanceOf(MeleeUnit); + }); + test("appears as 'S' on map", () => { - expect(ThickSludge.character).toBe('S'); + expect(thickSludge.character).toBe('S'); }); test('has #bf616a color', () => { - expect(ThickSludge.color).toBe('#bf616a'); + expect(thickSludge.color).toBe('#bf616a'); }); test('has 24 max health', () => { - expect(ThickSludge.maxHealth).toBe(24); + expect(thickSludge.maxHealth).toBe(24); }); - test('has attack ability with power 3', () => { - expect(ThickSludge.abilities).toHaveProperty('attack'); + test('has attack ability', () => { + expect(thickSludge.declaredAbilities).toHaveProperty('attack'); }); test('has feel ability', () => { - expect(ThickSludge.abilities).toHaveProperty('feel'); + expect(thickSludge.declaredAbilities).toHaveProperty('feel'); }); describe('playing turn', () => { @@ -39,7 +48,7 @@ describe('ThickSludge', () => { }); test('looks for player in all directions', () => { - ThickSludge.playTurn(turn); + thickSludge.playTurn(turn); expect(turn.feel).toHaveBeenCalledWith(FORWARD); expect(turn.feel).toHaveBeenCalledWith(RIGHT); expect(turn.feel).toHaveBeenCalledWith(BACKWARD); @@ -53,7 +62,7 @@ describe('ThickSludge', () => { isEnemy: () => true, }), }); - ThickSludge.playTurn(turn); + thickSludge.playTurn(turn); expect(turn.feel).toHaveBeenCalledWith(FORWARD); expect(turn.feel).toHaveBeenCalledWith(RIGHT); expect(turn.feel).not.toHaveBeenCalledWith(BACKWARD); @@ -62,7 +71,7 @@ describe('ThickSludge', () => { }); test("does nothing if it doesn't find threat", () => { - ThickSludge.playTurn(turn); + thickSludge.playTurn(turn); expect(turn.attack).not.toHaveBeenCalled(); }); }); diff --git a/libs/units/src/ThickSludge.ts b/libs/units/src/ThickSludge.ts index dc2d3feb..e0f99404 100644 --- a/libs/units/src/ThickSludge.ts +++ b/libs/units/src/ThickSludge.ts @@ -1,11 +1,16 @@ -import Sludge from './Sludge.js'; - -const ThickSludge = { - ...Sludge, - name: 'Thick Sludge', - character: 'S', - color: '#bf616a', - maxHealth: 24, -}; +import { attack, feel } from '@warriorjs/abilities'; + +import MeleeUnit from './MeleeUnit.js'; + +class ThickSludge extends MeleeUnit { + declaredAbilities = { + attack: attack.with({ power: 3 }), + feel: feel, + }; + + constructor() { + super('Thick Sludge', 'S', '#bf616a', 24); + } +} export default ThickSludge; diff --git a/libs/units/src/Wizard.test.ts b/libs/units/src/Wizard.test.ts index f5dac4b7..6ae6f25b 100644 --- a/libs/units/src/Wizard.test.ts +++ b/libs/units/src/Wizard.test.ts @@ -1,29 +1,38 @@ import { BACKWARD, FORWARD, LEFT, RIGHT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; +import RangedUnit from './RangedUnit.js'; import Wizard from './Wizard.js'; -vi.mock('@warriorjs/abilities'); - describe('Wizard', () => { + let wizard: Wizard; + + beforeEach(() => { + wizard = new Wizard(); + }); + + test('extends RangedUnit', () => { + expect(wizard).toBeInstanceOf(RangedUnit); + }); + test("appears as 'w' on map", () => { - expect(Wizard.character).toBe('w'); + expect(wizard.character).toBe('w'); }); test('has #b48ead color', () => { - expect(Wizard.color).toBe('#b48ead'); + expect(wizard.color).toBe('#b48ead'); }); test('has 3 max health', () => { - expect(Wizard.maxHealth).toBe(3); + expect(wizard.maxHealth).toBe(3); }); - test('has shoot ability with power 11 and range 3', () => { - expect(Wizard.abilities).toHaveProperty('shoot'); + test('has shoot ability', () => { + expect(wizard.declaredAbilities).toHaveProperty('shoot'); }); - test('has look ability with range 3', () => { - expect(Wizard.abilities).toHaveProperty('look'); + test('has look ability', () => { + expect(wizard.declaredAbilities).toHaveProperty('look'); }); describe('playing turn', () => { @@ -39,7 +48,7 @@ describe('Wizard', () => { }); test('looks for player in all directions', () => { - Wizard.playTurn(turn); + wizard.playTurn(turn); expect(turn.look).toHaveBeenCalledWith(FORWARD); expect(turn.look).toHaveBeenCalledWith(RIGHT); expect(turn.look).toHaveBeenCalledWith(BACKWARD); @@ -56,7 +65,7 @@ describe('Wizard', () => { }, anotherSpace, ]); - Wizard.playTurn(turn); + wizard.playTurn(turn); expect(anotherSpace.isUnit).not.toHaveBeenCalled(); }); @@ -72,7 +81,7 @@ describe('Wizard', () => { }, space, ]); - Wizard.playTurn(turn); + wizard.playTurn(turn); expect(turn.look).toHaveBeenCalledWith(FORWARD); expect(turn.look).toHaveBeenCalledWith(RIGHT); expect(turn.look).not.toHaveBeenCalledWith(BACKWARD); @@ -92,7 +101,7 @@ describe('Wizard', () => { }), }, ]); - Wizard.playTurn(turn); + wizard.playTurn(turn); expect(turn.shoot).not.toHaveBeenCalled(); }); }); diff --git a/libs/units/src/Wizard.ts b/libs/units/src/Wizard.ts index a0e880db..3a1ddbc9 100644 --- a/libs/units/src/Wizard.ts +++ b/libs/units/src/Wizard.ts @@ -1,17 +1,16 @@ import { look, shoot } from '@warriorjs/abilities'; -import Archer from './Archer.js'; +import RangedUnit from './RangedUnit.js'; -const Wizard = { - ...Archer, - name: 'Wizard', - character: 'w', - color: '#b48ead', - maxHealth: 3, - abilities: { +class Wizard extends RangedUnit { + declaredAbilities = { look: look.with({ range: 3 }), shoot: shoot.with({ range: 3, power: 11 }), - }, -}; + }; + + constructor() { + super('Wizard', 'w', '#b48ead', 3); + } +} export default Wizard; diff --git a/libs/units/src/index.ts b/libs/units/src/index.ts index 85a36b38..4c8f9f1f 100644 --- a/libs/units/src/index.ts +++ b/libs/units/src/index.ts @@ -1,5 +1,7 @@ export { default as Archer } from './Archer.js'; export { default as Captive } from './Captive.js'; +export { default as MeleeUnit } from './MeleeUnit.js'; +export { default as RangedUnit } from './RangedUnit.js'; export { default as Sludge } from './Sludge.js'; export { default as ThickSludge } from './ThickSludge.js'; export { default as Warrior } from './Warrior.js'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 15d0a569..9352309b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -115,6 +115,9 @@ importers: '@warriorjs/abilities': specifier: workspace:^ version: link:../abilities + '@warriorjs/core': + specifier: workspace:^ + version: link:../core '@warriorjs/spatial': specifier: workspace:^ version: link:../spatial diff --git a/towers/the-narrow-path/src/index.ts b/towers/the-narrow-path/src/index.ts index 5470f6a2..d6462753 100644 --- a/towers/the-narrow-path/src/index.ts +++ b/towers/the-narrow-path/src/index.ts @@ -22,29 +22,16 @@ const tower: TowerDefinition = { { description: 'A long hallway stretches before you, torchlight glinting off stairs at the far end. The air is still. Nothing stirs.', - tip: "The path is clear. Call `warrior.walk` to walk forward in the Player's `playTurn` method.", + tip: "The path is clear. Call `warrior.walk()` to walk forward in the Player's `playTurn` method.", timeBonus: 15, aceScore: 10, floor: { - size: { - width: 8, - height: 1, - }, - stairs: { - x: 7, - y: 0, - }, + size: { width: 8, height: 1 }, + stairs: { x: 7, y: 0 }, warrior: { ...Warrior, - abilities: { - think: think, - walk: walk, - }, - position: { - x: 0, - y: 0, - facing: EAST, - }, + abilities: { think, walk }, + position: { x: 0, y: 0, facing: EAST }, }, units: [], }, @@ -52,105 +39,41 @@ const tower: TowerDefinition = { { description: 'The torches have gone out. Darkness swallows the corridor, but the stench of sludge hangs thick in the air.', - tip: "Something lurks ahead. Use `warrior.feel.isEmpty()` to check if the space is clear. If not, `warrior.attack()` will fight whatever's there. Remember: one action per turn.", - clue: 'Add an if/else condition using `warrior.feel.isEmpty()` to decide whether to attack or walk.', + tip: "Something lurks ahead. Use `warrior.feel().isEmpty()` to check if the space is clear. If not, `warrior.attack()` will fight whatever's there. Remember: one action per turn.", + clue: 'Add an if/else condition using `warrior.feel().isEmpty()` to decide whether to attack or walk.', timeBonus: 20, aceScore: 26, floor: { - size: { - width: 8, - height: 1, - }, - stairs: { - x: 7, - y: 0, - }, + size: { width: 8, height: 1 }, + stairs: { x: 7, y: 0 }, warrior: { ...Warrior, - abilities: { - attack: attack.with({ power: 5 }), - feel: feel, - }, - position: { - x: 0, - y: 0, - facing: EAST, - }, + abilities: { attack: attack.with({ power: 5 }), feel }, + position: { x: 0, y: 0, facing: EAST }, }, - units: [ - { - ...Sludge, - position: { - x: 4, - y: 0, - facing: WEST, - }, - }, - ], + units: [{ unit: new Sludge(), position: { x: 4, y: 0, facing: WEST } }], }, }, { description: 'The air is heavy and wet, almost hard to breathe. The stench is overwhelming — there must be a horde of them.', - tip: 'These walls will wear you down. Use `warrior.health` and `warrior.maxHealth` to keep watch over your health, and `warrior.rest()` to recover 10% of your max health.', + tip: 'These walls will wear you down. Use `warrior.health()` and `warrior.maxHealth()` to keep watch over your health, and `warrior.rest()` to recover 10% of your max health.', clue: "When there's no enemy ahead of you, call `warrior.rest()` until your health is full before walking forward.", timeBonus: 35, aceScore: 71, floor: { - size: { - width: 9, - height: 1, - }, - stairs: { - x: 8, - y: 0, - }, + size: { width: 9, height: 1 }, + stairs: { x: 8, y: 0 }, warrior: { ...Warrior, - abilities: { - health: health, - maxHealth: maxHealth, - rest: rest.with({ healthGain: 0.1 }), - }, - position: { - x: 0, - y: 0, - facing: EAST, - }, + abilities: { health, maxHealth, rest: rest.with({ healthGain: 0.1 }) }, + position: { x: 0, y: 0, facing: EAST }, }, units: [ - { - ...Sludge, - position: { - x: 2, - y: 0, - facing: WEST, - }, - }, - { - ...Sludge, - position: { - x: 4, - y: 0, - facing: WEST, - }, - }, - { - ...Sludge, - position: { - x: 5, - y: 0, - facing: WEST, - }, - }, - { - ...Sludge, - position: { - x: 7, - y: 0, - facing: WEST, - }, - }, + { unit: new Sludge(), position: { x: 2, y: 0, facing: WEST } }, + { unit: new Sludge(), position: { x: 4, y: 0, facing: WEST } }, + { unit: new Sludge(), position: { x: 5, y: 0, facing: WEST } }, + { unit: new Sludge(), position: { x: 7, y: 0, facing: WEST } }, ], }, }, @@ -162,223 +85,75 @@ const tower: TowerDefinition = { timeBonus: 45, aceScore: 90, floor: { - size: { - width: 7, - height: 1, - }, - stairs: { - x: 6, - y: 0, - }, - warrior: { - ...Warrior, - position: { - x: 0, - y: 0, - facing: EAST, - }, - }, + size: { width: 7, height: 1 }, + stairs: { x: 6, y: 0 }, + warrior: { ...Warrior, position: { x: 0, y: 0, facing: EAST } }, units: [ - { - ...ThickSludge, - position: { - x: 2, - y: 0, - facing: WEST, - }, - }, - { - ...Archer, - position: { - x: 3, - y: 0, - facing: WEST, - }, - }, - { - ...ThickSludge, - position: { - x: 5, - y: 0, - facing: WEST, - }, - }, + { unit: new ThickSludge(), position: { x: 2, y: 0, facing: WEST } }, + { unit: new Archer(), position: { x: 3, y: 0, facing: WEST } }, + { unit: new ThickSludge(), position: { x: 5, y: 0, facing: WEST } }, ], }, }, { description: 'Muffled cries echo through the stone. Someone is alive down here — and bound.', - tip: 'Not every figure in the dark is a foe. Use `warrior.feel.getUnit().isEnemy()` and `warrior.feel.getUnit().isBound()` to identify captives, and `warrior.rescue` to free them.', + tip: 'Not every figure in the dark is a foe. Use `warrior.feel().getUnit().isEnemy()` and `warrior.feel().getUnit().isBound()` to identify captives, and `warrior.rescue()` to free them.', clue: "Don't forget to constantly check if you are being attacked. Rest until your health is full if you're not taking damage.", timeBonus: 45, aceScore: 123, floor: { - size: { - width: 7, - height: 1, - }, - stairs: { - x: 6, - y: 0, - }, + size: { width: 7, height: 1 }, + stairs: { x: 6, y: 0 }, warrior: { ...Warrior, - abilities: { - rescue: rescue, - }, - position: { - x: 0, - y: 0, - facing: EAST, - }, + abilities: { rescue }, + position: { x: 0, y: 0, facing: EAST }, }, units: [ - { - ...Captive, - position: { - x: 2, - y: 0, - facing: WEST, - }, - }, - { - ...Archer, - position: { - x: 3, - y: 0, - facing: WEST, - }, - }, - { - ...Archer, - position: { - x: 4, - y: 0, - facing: WEST, - }, - }, - { - ...ThickSludge, - position: { - x: 5, - y: 0, - facing: WEST, - }, - }, - { - ...Captive, - position: { - x: 6, - y: 0, - facing: WEST, - }, - }, + { unit: new Captive(), position: { x: 2, y: 0, facing: WEST } }, + { unit: new Archer(), position: { x: 3, y: 0, facing: WEST } }, + { unit: new Archer(), position: { x: 4, y: 0, facing: WEST } }, + { unit: new ThickSludge(), position: { x: 5, y: 0, facing: WEST } }, + { unit: new Captive(), position: { x: 6, y: 0, facing: WEST } }, ], }, }, { description: 'The corridor opens wider than before. Cries reach you from both ends — ahead and behind.', - tip: "Danger on two fronts. Pass `'backward'` to `walk`, `feel`, `rescue`, and `attack()` to act behind you. Archers have a limited attack distance.", - clue: "Walk backward if you're taking damage from afar and don't have enough health to attack. You may also want to consider walking backward until you hit a wall. Use `warrior.feel.isWall()` to see if there's a wall.", + tip: "Danger on two fronts. Pass `'backward'` to `walk()`, `feel()`, `rescue()`, and `attack()` to act behind you. Archers have a limited attack distance.", + clue: "Walk backward if you're taking damage from afar and don't have enough health to attack. You may also want to consider walking backward until you hit a wall. Use `warrior.feel().isWall()` to see if there's a wall.", timeBonus: 55, aceScore: 105, floor: { - size: { - width: 8, - height: 1, - }, - stairs: { - x: 7, - y: 0, - }, - warrior: { - ...Warrior, - position: { - x: 2, - y: 0, - facing: EAST, - }, - }, + size: { width: 8, height: 1 }, + stairs: { x: 7, y: 0 }, + warrior: { ...Warrior, position: { x: 2, y: 0, facing: EAST } }, units: [ - { - ...Captive, - position: { - x: 0, - y: 0, - facing: EAST, - }, - }, - { - ...ThickSludge, - position: { - x: 4, - y: 0, - facing: WEST, - }, - }, - { - ...Archer, - position: { - x: 6, - y: 0, - facing: WEST, - }, - }, - { - ...Archer, - position: { - x: 7, - y: 0, - facing: WEST, - }, - }, + { unit: new Captive(), position: { x: 0, y: 0, facing: EAST } }, + { unit: new ThickSludge(), position: { x: 4, y: 0, facing: WEST } }, + { unit: new Archer(), position: { x: 6, y: 0, facing: WEST } }, + { unit: new Archer(), position: { x: 7, y: 0, facing: WEST } }, ], }, }, { description: 'Cold stone meets your outstretched hand. A dead end — but a draft at your back tells you the way lies behind.', - tip: "Fighting backward dulls your blade. Use `warrior.feel.isWall()` to detect the wall, and `warrior.pivot` to turn and face what's coming.", + tip: "Fighting backward dulls your blade. Use `warrior.feel().isWall()` to detect the wall, and `warrior.pivot()` to turn and face what's coming.", timeBonus: 30, aceScore: 50, floor: { - size: { - width: 6, - height: 1, - }, - stairs: { - x: 0, - y: 0, - }, + size: { width: 6, height: 1 }, + stairs: { x: 0, y: 0 }, warrior: { ...Warrior, - abilities: { - pivot: pivot, - }, - position: { - x: 5, - y: 0, - facing: EAST, - }, + abilities: { pivot }, + position: { x: 5, y: 0, facing: EAST }, }, units: [ - { - ...Archer, - position: { - x: 1, - y: 0, - facing: EAST, - }, - }, - { - ...ThickSludge, - position: { - x: 3, - y: 0, - facing: EAST, - }, - }, + { unit: new Archer(), position: { x: 1, y: 0, facing: EAST } }, + { unit: new ThickSludge(), position: { x: 3, y: 0, facing: EAST } }, ], }, }, @@ -390,51 +165,17 @@ const tower: TowerDefinition = { timeBonus: 20, aceScore: 46, floor: { - size: { - width: 6, - height: 1, - }, - stairs: { - x: 5, - y: 0, - }, + size: { width: 6, height: 1 }, + stairs: { x: 5, y: 0 }, warrior: { ...Warrior, - position: { - x: 0, - y: 0, - facing: EAST, - }, - abilities: { - look: look.with({ range: 3 }), - shoot: shoot.with({ power: 3, range: 3 }), - }, + position: { x: 0, y: 0, facing: EAST }, + abilities: { look: look.with({ range: 3 }), shoot: shoot.with({ power: 3, range: 3 }) }, }, units: [ - { - ...Captive, - position: { - x: 2, - y: 0, - facing: WEST, - }, - }, - { - ...Wizard, - position: { - x: 3, - y: 0, - facing: WEST, - }, - }, - { - ...Wizard, - position: { - x: 4, - y: 0, - facing: WEST, - }, - }, + { unit: new Captive(), position: { x: 2, y: 0, facing: WEST } }, + { unit: new Wizard(), position: { x: 3, y: 0, facing: WEST } }, + { unit: new Wizard(), position: { x: 4, y: 0, facing: WEST } }, ], }, }, @@ -446,63 +187,15 @@ const tower: TowerDefinition = { timeBonus: 40, aceScore: 100, floor: { - size: { - width: 11, - height: 1, - }, - stairs: { - x: 0, - y: 0, - }, - warrior: { - ...Warrior, - position: { - x: 5, - y: 0, - facing: EAST, - }, - }, + size: { width: 11, height: 1 }, + stairs: { x: 0, y: 0 }, + warrior: { ...Warrior, position: { x: 5, y: 0, facing: EAST } }, units: [ - { - ...Captive, - position: { - x: 1, - y: 0, - facing: EAST, - }, - }, - { - ...Archer, - position: { - x: 2, - y: 0, - facing: EAST, - }, - }, - { - ...ThickSludge, - position: { - x: 7, - y: 0, - facing: WEST, - }, - }, - { - ...Wizard, - position: { - x: 9, - y: 0, - facing: WEST, - }, - }, - { - ...Captive, - position: { - x: 10, - y: 0, - facing: WEST, - }, - }, + { unit: new Captive(), position: { x: 1, y: 0, facing: EAST } }, + { unit: new Archer(), position: { x: 2, y: 0, facing: EAST } }, + { unit: new ThickSludge(), position: { x: 7, y: 0, facing: WEST } }, + { unit: new Wizard(), position: { x: 9, y: 0, facing: WEST } }, + { unit: new Captive(), position: { x: 10, y: 0, facing: WEST } }, ], }, }, diff --git a/towers/the-powder-keep/src/index.ts b/towers/the-powder-keep/src/index.ts index 5b9ddf9e..2f4789f8 100644 --- a/towers/the-powder-keep/src/index.ts +++ b/towers/the-powder-keep/src/index.ts @@ -88,7 +88,7 @@ const tower: TowerDefinition = { }, units: [ { - ...Sludge, + unit: new Sludge(), position: { x: 1, y: 0, @@ -96,7 +96,7 @@ const tower: TowerDefinition = { }, }, { - ...ThickSludge, + unit: new ThickSludge(), position: { x: 2, y: 1, @@ -104,7 +104,7 @@ const tower: TowerDefinition = { }, }, { - ...Sludge, + unit: new Sludge(), position: { x: 1, y: 1, @@ -143,7 +143,7 @@ const tower: TowerDefinition = { }, units: [ { - ...Sludge, + unit: new Sludge(), position: { x: 1, y: 0, @@ -151,7 +151,7 @@ const tower: TowerDefinition = { }, }, { - ...Captive, + unit: new Captive(), position: { x: 1, y: 2, @@ -159,7 +159,7 @@ const tower: TowerDefinition = { }, }, { - ...Sludge, + unit: new Sludge(), position: { x: 0, y: 1, @@ -167,7 +167,7 @@ const tower: TowerDefinition = { }, }, { - ...Sludge, + unit: new Sludge(), position: { x: 2, y: 1, @@ -207,7 +207,7 @@ const tower: TowerDefinition = { }, units: [ { - ...Captive, + unit: new Captive(), position: { x: 0, y: 0, @@ -215,7 +215,7 @@ const tower: TowerDefinition = { }, }, { - ...Captive, + unit: new Captive(), position: { x: 0, y: 2, @@ -223,7 +223,7 @@ const tower: TowerDefinition = { }, }, { - ...Sludge, + unit: new Sludge(), position: { x: 2, y: 0, @@ -231,7 +231,7 @@ const tower: TowerDefinition = { }, }, { - ...ThickSludge, + unit: new ThickSludge(), position: { x: 3, y: 1, @@ -239,7 +239,7 @@ const tower: TowerDefinition = { }, }, { - ...Sludge, + unit: new Sludge(), position: { x: 2, y: 2, @@ -275,7 +275,7 @@ const tower: TowerDefinition = { }, units: [ { - ...ThickSludge, + unit: new ThickSludge(), position: { x: 4, y: 0, @@ -283,7 +283,7 @@ const tower: TowerDefinition = { }, }, { - ...ThickSludge, + unit: new ThickSludge(), position: { x: 3, y: 1, @@ -291,7 +291,7 @@ const tower: TowerDefinition = { }, }, { - ...Captive, + unit: new Captive(), position: { x: 4, y: 1, @@ -327,7 +327,7 @@ const tower: TowerDefinition = { }, units: [ { - ...Sludge, + unit: new Sludge(), position: { x: 1, y: 0, @@ -335,7 +335,7 @@ const tower: TowerDefinition = { }, }, { - ...Sludge, + unit: new Sludge(), position: { x: 3, y: 1, @@ -343,7 +343,7 @@ const tower: TowerDefinition = { }, }, { - ...Captive, + unit: new Captive(), position: { x: 0, y: 0, @@ -351,7 +351,7 @@ const tower: TowerDefinition = { }, }, { - ...Captive, + unit: new Captive(), effects: { ticking: ticking({ time: 7 }), }, @@ -390,7 +390,7 @@ const tower: TowerDefinition = { }, units: [ { - ...Sludge, + unit: new Sludge(), position: { x: 1, y: 0, @@ -398,7 +398,7 @@ const tower: TowerDefinition = { }, }, { - ...Sludge, + unit: new Sludge(), position: { x: 1, y: 2, @@ -406,7 +406,7 @@ const tower: TowerDefinition = { }, }, { - ...Captive, + unit: new Captive(), position: { x: 2, y: 1, @@ -414,7 +414,7 @@ const tower: TowerDefinition = { }, }, { - ...Captive, + unit: new Captive(), effects: { ticking: ticking({ time: 10 }), }, @@ -425,7 +425,7 @@ const tower: TowerDefinition = { }, }, { - ...Captive, + unit: new Captive(), position: { x: 2, y: 0, @@ -465,7 +465,7 @@ const tower: TowerDefinition = { }, units: [ { - ...Captive, + unit: new Captive(), effects: { ticking: ticking({ time: 9 }), }, @@ -476,7 +476,7 @@ const tower: TowerDefinition = { }, }, { - ...ThickSludge, + unit: new ThickSludge(), position: { x: 2, y: 0, @@ -484,7 +484,7 @@ const tower: TowerDefinition = { }, }, { - ...Sludge, + unit: new Sludge(), position: { x: 3, y: 0, @@ -523,7 +523,7 @@ const tower: TowerDefinition = { }, units: [ { - ...Captive, + unit: new Captive(), effects: { ticking: ticking({ time: 20 }), }, @@ -534,7 +534,7 @@ const tower: TowerDefinition = { }, }, { - ...Captive, + unit: new Captive(), position: { x: 2, y: 2, @@ -542,7 +542,7 @@ const tower: TowerDefinition = { }, }, { - ...Sludge, + unit: new Sludge(), position: { x: 0, y: 0, @@ -550,7 +550,7 @@ const tower: TowerDefinition = { }, }, { - ...Sludge, + unit: new Sludge(), position: { x: 1, y: 0, @@ -558,7 +558,7 @@ const tower: TowerDefinition = { }, }, { - ...Sludge, + unit: new Sludge(), position: { x: 1, y: 1, @@ -566,7 +566,7 @@ const tower: TowerDefinition = { }, }, { - ...Sludge, + unit: new Sludge(), position: { x: 2, y: 1, @@ -574,7 +574,7 @@ const tower: TowerDefinition = { }, }, { - ...Sludge, + unit: new Sludge(), position: { x: 3, y: 1, @@ -582,7 +582,7 @@ const tower: TowerDefinition = { }, }, { - ...Sludge, + unit: new Sludge(), position: { x: 0, y: 2, @@ -590,7 +590,7 @@ const tower: TowerDefinition = { }, }, { - ...Sludge, + unit: new Sludge(), position: { x: 1, y: 2, From 706d18e146e1a49744f7d764231302dcbb76dd11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Olivera?= Date: Tue, 17 Mar 2026 09:29:59 -0300 Subject: [PATCH 10/39] fix(towers): restore () in player-facing API references in tips/clues The Powder Keep tips and clues reference method calls players write (e.g. warrior.feel().isEmpty()), not ability config declarations. The parentheses were incorrectly removed during the class-based refactor. Co-Authored-By: Claude Opus 4.6 (1M context) --- towers/the-powder-keep/src/index.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/towers/the-powder-keep/src/index.ts b/towers/the-powder-keep/src/index.ts index 2f4789f8..5e901f9d 100644 --- a/towers/the-powder-keep/src/index.ts +++ b/towers/the-powder-keep/src/index.ts @@ -27,7 +27,7 @@ const tower: TowerDefinition = { { description: 'Silence. The room stretches wide and empty, your footsteps swallowed by the dark. A crumpled map in your hand marks the way to the stairs.', - tip: "The dark won't guide you, but the map will. Use `warrior.directionOfStairs` to find the stairs, and pass the result to `warrior.walk` to move toward them.", + tip: "The dark won't guide you, but the map will. Use `warrior.directionOfStairs()` to find the stairs, and pass the result to `warrior.walk()` to move toward them.", timeBonus: 20, aceScore: 19, floor: { @@ -59,7 +59,7 @@ const tower: TowerDefinition = { description: 'The next chamber is not empty. Shapes shift in the darkness on all sides, between you and the stairs.', tip: 'Threats can come from any direction now. You can attack and feel forward, left, right, and backward.', - clue: "Call `warrior.feel.isUnit()` and `warrior.feel.getUnit().isEnemy()` in each direction to make sure there isn't an enemy beside you (attack if there is). Call `warrior.rest()` if you're low in health when there are no enemies around.", + clue: "Call `warrior.feel().isUnit()` and `warrior.feel().getUnit().isEnemy()` in each direction to make sure there isn't an enemy beside you (attack if there is). Call `warrior.rest()` if you're low in health when there are no enemies around.", timeBonus: 40, aceScore: 84, floor: { @@ -116,7 +116,7 @@ const tower: TowerDefinition = { }, { description: 'Slime presses against you from every direction. You are surrounded.', - tip: 'Too many to fight at once. Call `warrior.bind` to hold an enemy in place while you deal with the others.', + tip: 'Too many to fight at once. Call `warrior.bind()` to hold an enemy in place while you deal with the others.', clue: 'Count the number of unbound enemies around you. Bind an enemy if there are two or more.', timeBonus: 50, aceScore: 101, @@ -180,8 +180,8 @@ const tower: TowerDefinition = { { description: 'Your eyes are useless here, but your ears sharpen. Breathing. Struggling. Faint sounds scattered across the room.', - tip: 'Listen for what you cannot see. Use `warrior.listen` to find spaces with other units, and `warrior.directionOf` to determine which way they are.', - clue: 'Walk towards a unit with `warrior.walk(warrior.directionOf(warrior.listen[0]))`. Once `warrior.listen.length === 0`, head for the stairs.', + tip: 'Listen for what you cannot see. Use `warrior.listen()` to find spaces with other units, and `warrior.directionOf()` to determine which way they are.', + clue: 'Walk towards a unit with `warrior.walk(warrior.directionOf(warrior.listen()[0]))`. Once `warrior.listen().length === 0`, head for the stairs.', timeBonus: 55, aceScore: 144, floor: { @@ -252,7 +252,7 @@ const tower: TowerDefinition = { { description: 'The stairs are right beside you — you could leave now. But the room beyond is not empty, and neither is your conscience.', - tip: 'Leaving is easy. Clearing the floor is worth more. Use `warrior.feel.isStairs()` and `warrior.feel.isEmpty()` to choose your path.', + tip: 'Leaving is easy. Clearing the floor is worth more. Use `warrior.feel().isStairs()` and `warrior.feel().isEmpty()` to choose your path.', clue: 'If going towards a unit is the same direction as the stairs, try moving in another empty direction until you can safely move toward the enemies.', timeBonus: 45, aceScore: 107, @@ -305,7 +305,7 @@ const tower: TowerDefinition = { description: 'A rhythmic ticking cuts through the silence. Somewhere in the dark, a captive kneels over a bomb that will not wait.', tip: "Time is short. Rescue captives with `space.getUnit().isUnderEffect('ticking')` first — they won't last long.", - clue: "Avoid fighting enemies at first. Use `warrior.listen` and `space.getUnit().isUnderEffect('ticking')` and quickly rescue those captives.", + clue: "Avoid fighting enemies at first. Use `warrior.listen()` and `space.getUnit().isUnderEffect('ticking')` and quickly rescue those captives.", timeBonus: 50, aceScore: 108, floor: { @@ -497,7 +497,7 @@ const tower: TowerDefinition = { { description: 'The final chamber writhes with sludge — more than you have ever seen. The ticking beneath the floor has not stopped.', - tip: 'One wrong blast and the captive dies with the rest. Use `warrior.distanceOf` to keep the flames clear of those you came to save.', + tip: 'One wrong blast and the captive dies with the rest. Use `warrior.distanceOf()` to keep the flames clear of those you came to save.', clue: 'Be sure to bind the surrounding enemies before fighting. Check your health before detonating explosives.', timeBonus: 70, aceScore: 176, From d58e4757c55f0223df4b2a288fcf95cce6051d5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Olivera?= Date: Tue, 17 Mar 2026 09:37:11 -0300 Subject: [PATCH 11/39] refactor(abilities): capitalize ability filenames to match class names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename ability files from camelCase to PascalCase (e.g., attack.ts → Attack.ts) to match the exported class names. Update all imports across abilities, units, core, cli, and tower packages. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/{attack.test.ts => Attack.test.ts} | 2 +- libs/abilities/src/{attack.ts => Attack.ts} | 0 .../src/{bind.test.ts => Bind.test.ts} | 2 +- libs/abilities/src/{bind.ts => Bind.ts} | 0 .../{detonate.test.ts => Detonate.test.ts} | 2 +- .../src/{detonate.ts => Detonate.ts} | 0 ...irectionOf.test.ts => DirectionOf.test.ts} | 2 +- .../src/{directionOf.ts => DirectionOf.ts} | 0 ...airs.test.ts => DirectionOfStairs.test.ts} | 2 +- ...ectionOfStairs.ts => DirectionOfStairs.ts} | 0 ...{distanceOf.test.ts => DistanceOf.test.ts} | 2 +- .../src/{distanceOf.ts => DistanceOf.ts} | 0 .../src/{feel.test.ts => Feel.test.ts} | 2 +- libs/abilities/src/{feel.ts => Feel.ts} | 0 .../src/{health.test.ts => Health.test.ts} | 2 +- libs/abilities/src/{health.ts => Health.ts} | 0 .../src/{listen.test.ts => Listen.test.ts} | 2 +- libs/abilities/src/{listen.ts => Listen.ts} | 0 .../src/{look.test.ts => Look.test.ts} | 2 +- libs/abilities/src/{look.ts => Look.ts} | 0 .../{maxHealth.test.ts => MaxHealth.test.ts} | 2 +- .../src/{maxHealth.ts => MaxHealth.ts} | 0 .../src/{pivot.test.ts => Pivot.test.ts} | 2 +- libs/abilities/src/{pivot.ts => Pivot.ts} | 0 .../src/{rescue.test.ts => Rescue.test.ts} | 2 +- libs/abilities/src/{rescue.ts => Rescue.ts} | 0 .../src/{rest.test.ts => Rest.test.ts} | 2 +- libs/abilities/src/{rest.ts => Rest.ts} | 0 .../src/{shoot.test.ts => Shoot.test.ts} | 2 +- libs/abilities/src/{shoot.ts => Shoot.ts} | 0 .../src/{think.test.ts => Think.test.ts} | 2 +- libs/abilities/src/{think.ts => Think.ts} | 0 .../src/{walk.test.ts => Walk.test.ts} | 2 +- libs/abilities/src/{walk.ts => Walk.ts} | 0 libs/abilities/src/index.ts | 34 +++++------ libs/core/src/getLevel.test.ts | 12 ++-- libs/core/src/runLevel.test.ts | 12 ++-- libs/units/src/Archer.ts | 6 +- libs/units/src/Sludge.ts | 6 +- libs/units/src/ThickSludge.ts | 6 +- libs/units/src/Wizard.ts | 6 +- towers/the-narrow-path/src/index.ts | 34 +++++------ towers/the-powder-keep/src/index.ts | 60 +++++++++---------- 43 files changed, 105 insertions(+), 105 deletions(-) rename libs/abilities/src/{attack.test.ts => Attack.test.ts} (98%) rename libs/abilities/src/{attack.ts => Attack.ts} (100%) rename libs/abilities/src/{bind.test.ts => Bind.test.ts} (98%) rename libs/abilities/src/{bind.ts => Bind.ts} (100%) rename libs/abilities/src/{detonate.test.ts => Detonate.test.ts} (98%) rename libs/abilities/src/{detonate.ts => Detonate.ts} (100%) rename libs/abilities/src/{directionOf.test.ts => DirectionOf.test.ts} (95%) rename libs/abilities/src/{directionOf.ts => DirectionOf.ts} (100%) rename libs/abilities/src/{directionOfStairs.test.ts => DirectionOfStairs.test.ts} (94%) rename libs/abilities/src/{directionOfStairs.ts => DirectionOfStairs.ts} (100%) rename libs/abilities/src/{distanceOf.test.ts => DistanceOf.test.ts} (95%) rename libs/abilities/src/{distanceOf.ts => DistanceOf.ts} (100%) rename libs/abilities/src/{feel.test.ts => Feel.test.ts} (97%) rename libs/abilities/src/{feel.ts => Feel.ts} (100%) rename libs/abilities/src/{health.test.ts => Health.test.ts} (95%) rename libs/abilities/src/{health.ts => Health.ts} (100%) rename libs/abilities/src/{listen.test.ts => Listen.test.ts} (97%) rename libs/abilities/src/{listen.ts => Listen.ts} (100%) rename libs/abilities/src/{look.test.ts => Look.test.ts} (98%) rename libs/abilities/src/{look.ts => Look.ts} (100%) rename libs/abilities/src/{maxHealth.test.ts => MaxHealth.test.ts} (95%) rename libs/abilities/src/{maxHealth.ts => MaxHealth.ts} (100%) rename libs/abilities/src/{pivot.test.ts => Pivot.test.ts} (97%) rename libs/abilities/src/{pivot.ts => Pivot.ts} (100%) rename libs/abilities/src/{rescue.test.ts => Rescue.test.ts} (98%) rename libs/abilities/src/{rescue.ts => Rescue.ts} (100%) rename libs/abilities/src/{rest.test.ts => Rest.test.ts} (97%) rename libs/abilities/src/{rest.ts => Rest.ts} (100%) rename libs/abilities/src/{shoot.test.ts => Shoot.test.ts} (98%) rename libs/abilities/src/{shoot.ts => Shoot.ts} (100%) rename libs/abilities/src/{think.test.ts => Think.test.ts} (97%) rename libs/abilities/src/{think.ts => Think.ts} (100%) rename libs/abilities/src/{walk.test.ts => Walk.test.ts} (98%) rename libs/abilities/src/{walk.ts => Walk.ts} (100%) diff --git a/libs/abilities/src/attack.test.ts b/libs/abilities/src/Attack.test.ts similarity index 98% rename from libs/abilities/src/attack.test.ts rename to libs/abilities/src/Attack.test.ts index 38ca755f..686fa740 100644 --- a/libs/abilities/src/attack.test.ts +++ b/libs/abilities/src/Attack.test.ts @@ -2,7 +2,7 @@ import { BACKWARD, FORWARD, LEFT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import Action from './Action.js'; -import Attack from './attack.js'; +import Attack from './Attack.js'; describe('Attack', () => { let attack: Attack; diff --git a/libs/abilities/src/attack.ts b/libs/abilities/src/Attack.ts similarity index 100% rename from libs/abilities/src/attack.ts rename to libs/abilities/src/Attack.ts diff --git a/libs/abilities/src/bind.test.ts b/libs/abilities/src/Bind.test.ts similarity index 98% rename from libs/abilities/src/bind.test.ts rename to libs/abilities/src/Bind.test.ts index 6dcac3ef..771e1117 100644 --- a/libs/abilities/src/bind.test.ts +++ b/libs/abilities/src/Bind.test.ts @@ -2,7 +2,7 @@ import { FORWARD, LEFT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import Action from './Action.js'; -import Bind from './bind.js'; +import Bind from './Bind.js'; describe('Bind', () => { let bind: Bind; diff --git a/libs/abilities/src/bind.ts b/libs/abilities/src/Bind.ts similarity index 100% rename from libs/abilities/src/bind.ts rename to libs/abilities/src/Bind.ts diff --git a/libs/abilities/src/detonate.test.ts b/libs/abilities/src/Detonate.test.ts similarity index 98% rename from libs/abilities/src/detonate.test.ts rename to libs/abilities/src/Detonate.test.ts index 7b59d819..d5383c4b 100644 --- a/libs/abilities/src/detonate.test.ts +++ b/libs/abilities/src/Detonate.test.ts @@ -2,7 +2,7 @@ import { FORWARD, LEFT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import Action from './Action.js'; -import Detonate from './detonate.js'; +import Detonate from './Detonate.js'; describe('Detonate', () => { let detonate: Detonate; diff --git a/libs/abilities/src/detonate.ts b/libs/abilities/src/Detonate.ts similarity index 100% rename from libs/abilities/src/detonate.ts rename to libs/abilities/src/Detonate.ts diff --git a/libs/abilities/src/directionOf.test.ts b/libs/abilities/src/DirectionOf.test.ts similarity index 95% rename from libs/abilities/src/directionOf.test.ts rename to libs/abilities/src/DirectionOf.test.ts index c622a294..b9b9874c 100644 --- a/libs/abilities/src/directionOf.test.ts +++ b/libs/abilities/src/DirectionOf.test.ts @@ -1,6 +1,6 @@ import { BACKWARD, FORWARD, LEFT, RIGHT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; -import DirectionOf from './directionOf.js'; +import DirectionOf from './DirectionOf.js'; import Sense from './Sense.js'; describe('DirectionOf', () => { diff --git a/libs/abilities/src/directionOf.ts b/libs/abilities/src/DirectionOf.ts similarity index 100% rename from libs/abilities/src/directionOf.ts rename to libs/abilities/src/DirectionOf.ts diff --git a/libs/abilities/src/directionOfStairs.test.ts b/libs/abilities/src/DirectionOfStairs.test.ts similarity index 94% rename from libs/abilities/src/directionOfStairs.test.ts rename to libs/abilities/src/DirectionOfStairs.test.ts index 29e43431..34c141f7 100644 --- a/libs/abilities/src/directionOfStairs.test.ts +++ b/libs/abilities/src/DirectionOfStairs.test.ts @@ -1,6 +1,6 @@ import { BACKWARD, FORWARD, LEFT, RIGHT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; -import DirectionOfStairs from './directionOfStairs.js'; +import DirectionOfStairs from './DirectionOfStairs.js'; import Sense from './Sense.js'; describe('DirectionOfStairs', () => { diff --git a/libs/abilities/src/directionOfStairs.ts b/libs/abilities/src/DirectionOfStairs.ts similarity index 100% rename from libs/abilities/src/directionOfStairs.ts rename to libs/abilities/src/DirectionOfStairs.ts diff --git a/libs/abilities/src/distanceOf.test.ts b/libs/abilities/src/DistanceOf.test.ts similarity index 95% rename from libs/abilities/src/distanceOf.test.ts rename to libs/abilities/src/DistanceOf.test.ts index 445a2be3..b1422f7e 100644 --- a/libs/abilities/src/distanceOf.test.ts +++ b/libs/abilities/src/DistanceOf.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; -import DistanceOf from './distanceOf.js'; +import DistanceOf from './DistanceOf.js'; import Sense from './Sense.js'; describe('DistanceOf', () => { diff --git a/libs/abilities/src/distanceOf.ts b/libs/abilities/src/DistanceOf.ts similarity index 100% rename from libs/abilities/src/distanceOf.ts rename to libs/abilities/src/DistanceOf.ts diff --git a/libs/abilities/src/feel.test.ts b/libs/abilities/src/Feel.test.ts similarity index 97% rename from libs/abilities/src/feel.test.ts rename to libs/abilities/src/Feel.test.ts index cf7d2e35..3267692d 100644 --- a/libs/abilities/src/feel.test.ts +++ b/libs/abilities/src/Feel.test.ts @@ -1,6 +1,6 @@ import { FORWARD, LEFT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; -import Feel from './feel.js'; +import Feel from './Feel.js'; import Sense from './Sense.js'; describe('Feel', () => { diff --git a/libs/abilities/src/feel.ts b/libs/abilities/src/Feel.ts similarity index 100% rename from libs/abilities/src/feel.ts rename to libs/abilities/src/Feel.ts diff --git a/libs/abilities/src/health.test.ts b/libs/abilities/src/Health.test.ts similarity index 95% rename from libs/abilities/src/health.test.ts rename to libs/abilities/src/Health.test.ts index d0d0ccc5..44452fee 100644 --- a/libs/abilities/src/health.test.ts +++ b/libs/abilities/src/Health.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, test } from 'vitest'; -import Health from './health.js'; +import Health from './Health.js'; import Sense from './Sense.js'; describe('Health', () => { diff --git a/libs/abilities/src/health.ts b/libs/abilities/src/Health.ts similarity index 100% rename from libs/abilities/src/health.ts rename to libs/abilities/src/Health.ts diff --git a/libs/abilities/src/listen.test.ts b/libs/abilities/src/Listen.test.ts similarity index 97% rename from libs/abilities/src/listen.test.ts rename to libs/abilities/src/Listen.test.ts index 977ef7ad..7c41689f 100644 --- a/libs/abilities/src/listen.test.ts +++ b/libs/abilities/src/Listen.test.ts @@ -1,6 +1,6 @@ import { FORWARD, NORTH } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; -import Listen from './listen.js'; +import Listen from './Listen.js'; import Sense from './Sense.js'; describe('Listen', () => { diff --git a/libs/abilities/src/listen.ts b/libs/abilities/src/Listen.ts similarity index 100% rename from libs/abilities/src/listen.ts rename to libs/abilities/src/Listen.ts diff --git a/libs/abilities/src/look.test.ts b/libs/abilities/src/Look.test.ts similarity index 98% rename from libs/abilities/src/look.test.ts rename to libs/abilities/src/Look.test.ts index b6b4af05..68bf59ef 100644 --- a/libs/abilities/src/look.test.ts +++ b/libs/abilities/src/Look.test.ts @@ -1,6 +1,6 @@ import { FORWARD, LEFT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; -import Look from './look.js'; +import Look from './Look.js'; import Sense from './Sense.js'; describe('Look', () => { diff --git a/libs/abilities/src/look.ts b/libs/abilities/src/Look.ts similarity index 100% rename from libs/abilities/src/look.ts rename to libs/abilities/src/Look.ts diff --git a/libs/abilities/src/maxHealth.test.ts b/libs/abilities/src/MaxHealth.test.ts similarity index 95% rename from libs/abilities/src/maxHealth.test.ts rename to libs/abilities/src/MaxHealth.test.ts index 039c2865..881472ba 100644 --- a/libs/abilities/src/maxHealth.test.ts +++ b/libs/abilities/src/MaxHealth.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, test } from 'vitest'; -import MaxHealth from './maxHealth.js'; +import MaxHealth from './MaxHealth.js'; import Sense from './Sense.js'; describe('MaxHealth', () => { diff --git a/libs/abilities/src/maxHealth.ts b/libs/abilities/src/MaxHealth.ts similarity index 100% rename from libs/abilities/src/maxHealth.ts rename to libs/abilities/src/MaxHealth.ts diff --git a/libs/abilities/src/pivot.test.ts b/libs/abilities/src/Pivot.test.ts similarity index 97% rename from libs/abilities/src/pivot.test.ts rename to libs/abilities/src/Pivot.test.ts index 07af2e61..8e3185d9 100644 --- a/libs/abilities/src/pivot.test.ts +++ b/libs/abilities/src/Pivot.test.ts @@ -2,7 +2,7 @@ import { BACKWARD, RIGHT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import Action from './Action.js'; -import Pivot from './pivot.js'; +import Pivot from './Pivot.js'; describe('Pivot', () => { let pivot: Pivot; diff --git a/libs/abilities/src/pivot.ts b/libs/abilities/src/Pivot.ts similarity index 100% rename from libs/abilities/src/pivot.ts rename to libs/abilities/src/Pivot.ts diff --git a/libs/abilities/src/rescue.test.ts b/libs/abilities/src/Rescue.test.ts similarity index 98% rename from libs/abilities/src/rescue.test.ts rename to libs/abilities/src/Rescue.test.ts index 22918bb7..ac4e023c 100644 --- a/libs/abilities/src/rescue.test.ts +++ b/libs/abilities/src/Rescue.test.ts @@ -2,7 +2,7 @@ import { FORWARD, RIGHT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import Action from './Action.js'; -import Rescue from './rescue.js'; +import Rescue from './Rescue.js'; describe('Rescue', () => { let rescue: Rescue; diff --git a/libs/abilities/src/rescue.ts b/libs/abilities/src/Rescue.ts similarity index 100% rename from libs/abilities/src/rescue.ts rename to libs/abilities/src/Rescue.ts diff --git a/libs/abilities/src/rest.test.ts b/libs/abilities/src/Rest.test.ts similarity index 97% rename from libs/abilities/src/rest.test.ts rename to libs/abilities/src/Rest.test.ts index 91495ec6..864c6e38 100644 --- a/libs/abilities/src/rest.test.ts +++ b/libs/abilities/src/Rest.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; import Action from './Action.js'; -import Rest from './rest.js'; +import Rest from './Rest.js'; describe('Rest', () => { let rest: Rest; diff --git a/libs/abilities/src/rest.ts b/libs/abilities/src/Rest.ts similarity index 100% rename from libs/abilities/src/rest.ts rename to libs/abilities/src/Rest.ts diff --git a/libs/abilities/src/shoot.test.ts b/libs/abilities/src/Shoot.test.ts similarity index 98% rename from libs/abilities/src/shoot.test.ts rename to libs/abilities/src/Shoot.test.ts index 60eeab6b..7b1d50b3 100644 --- a/libs/abilities/src/shoot.test.ts +++ b/libs/abilities/src/Shoot.test.ts @@ -2,7 +2,7 @@ import { FORWARD, LEFT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import Action from './Action.js'; -import Shoot from './shoot.js'; +import Shoot from './Shoot.js'; describe('Shoot', () => { let shoot: Shoot; diff --git a/libs/abilities/src/shoot.ts b/libs/abilities/src/Shoot.ts similarity index 100% rename from libs/abilities/src/shoot.ts rename to libs/abilities/src/Shoot.ts diff --git a/libs/abilities/src/think.test.ts b/libs/abilities/src/Think.test.ts similarity index 97% rename from libs/abilities/src/think.test.ts rename to libs/abilities/src/Think.test.ts index 165d5c61..ed0084f7 100644 --- a/libs/abilities/src/think.test.ts +++ b/libs/abilities/src/Think.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; import Sense from './Sense.js'; -import Think from './think.js'; +import Think from './Think.js'; describe('Think', () => { let think: Think; diff --git a/libs/abilities/src/think.ts b/libs/abilities/src/Think.ts similarity index 100% rename from libs/abilities/src/think.ts rename to libs/abilities/src/Think.ts diff --git a/libs/abilities/src/walk.test.ts b/libs/abilities/src/Walk.test.ts similarity index 98% rename from libs/abilities/src/walk.test.ts rename to libs/abilities/src/Walk.test.ts index 3227e106..a2499b83 100644 --- a/libs/abilities/src/walk.test.ts +++ b/libs/abilities/src/Walk.test.ts @@ -2,7 +2,7 @@ import { FORWARD, RIGHT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import Action from './Action.js'; -import Walk from './walk.js'; +import Walk from './Walk.js'; describe('Walk', () => { let walk: Walk; diff --git a/libs/abilities/src/walk.ts b/libs/abilities/src/Walk.ts similarity index 100% rename from libs/abilities/src/walk.ts rename to libs/abilities/src/Walk.ts diff --git a/libs/abilities/src/index.ts b/libs/abilities/src/index.ts index f9cd9cfd..f04c67fc 100644 --- a/libs/abilities/src/index.ts +++ b/libs/abilities/src/index.ts @@ -1,22 +1,22 @@ export type { AbilityBinding } from './Ability.js'; export { default as Ability } from './Ability.js'; export { default as Action } from './Action.js'; -export { default as attack } from './attack.js'; -export { default as bind } from './bind.js'; -export { default as detonate } from './detonate.js'; -export { default as directionOf } from './directionOf.js'; -export { default as directionOfStairs } from './directionOfStairs.js'; -export { default as distanceOf } from './distanceOf.js'; -export { default as feel } from './feel.js'; -export { default as health } from './health.js'; -export { default as listen } from './listen.js'; -export { default as look } from './look.js'; -export { default as maxHealth } from './maxHealth.js'; -export { default as pivot } from './pivot.js'; -export { default as rescue } from './rescue.js'; -export { default as rest } from './rest.js'; +export { default as Attack } from './Attack.js'; +export { default as Bind } from './Bind.js'; +export { default as Detonate } from './Detonate.js'; +export { default as DirectionOf } from './DirectionOf.js'; +export { default as DirectionOfStairs } from './DirectionOfStairs.js'; +export { default as DistanceOf } from './DistanceOf.js'; +export { default as Feel } from './Feel.js'; +export { default as Health } from './Health.js'; +export { default as Listen } from './Listen.js'; +export { default as Look } from './Look.js'; +export { default as MaxHealth } from './MaxHealth.js'; +export { default as Pivot } from './Pivot.js'; +export { default as Rescue } from './Rescue.js'; +export { default as Rest } from './Rest.js'; export { default as Sense } from './Sense.js'; -export { default as shoot } from './shoot.js'; -export { default as think } from './think.js'; +export { default as Shoot } from './Shoot.js'; +export { default as Think } from './Think.js'; export type { AbilityMeta, SensedSpace, Space, Unit } from './types.js'; -export { default as walk } from './walk.js'; +export { default as Walk } from './Walk.js'; diff --git a/libs/core/src/getLevel.test.ts b/libs/core/src/getLevel.test.ts index 1a6a3813..d201fc2e 100644 --- a/libs/core/src/getLevel.test.ts +++ b/libs/core/src/getLevel.test.ts @@ -1,4 +1,4 @@ -import { attack, feel, walk } from '@warriorjs/abilities'; +import { Attack, Feel, Walk } from '@warriorjs/abilities'; import { EAST, RELATIVE_DIRECTIONS, WEST } from '@warriorjs/spatial'; import { expect, test } from 'vitest'; @@ -24,9 +24,9 @@ const levelConfig = { color: '#8fbcbb', maxHealth: 20, abilities: { - walk: walk, - attack: attack.with({ power: 5 }), - feel: feel, + walk: Walk, + attack: Attack.with({ power: 5 }), + feel: Feel, }, position: { x: 0, @@ -41,8 +41,8 @@ const levelConfig = { color: '#d08770', maxHealth: 12, abilities: { - attack: attack.with({ power: 3 }), - feel: feel, + attack: Attack.with({ power: 3 }), + feel: Feel, }, playTurn(sludge: any) { const playerDirection = RELATIVE_DIRECTIONS.find((direction) => { diff --git a/libs/core/src/runLevel.test.ts b/libs/core/src/runLevel.test.ts index ba1f3907..a0dcac05 100644 --- a/libs/core/src/runLevel.test.ts +++ b/libs/core/src/runLevel.test.ts @@ -1,4 +1,4 @@ -import { attack, feel, walk } from '@warriorjs/abilities'; +import { Attack, Feel, Walk } from '@warriorjs/abilities'; import { EAST, RELATIVE_DIRECTIONS, WEST } from '@warriorjs/spatial'; import { expect, test } from 'vitest'; @@ -19,9 +19,9 @@ const levelConfig = { character: '@', maxHealth: 20, abilities: { - walk: walk, - attack: attack.with({ power: 5 }), - feel: feel, + walk: Walk, + attack: Attack.with({ power: 5 }), + feel: Feel, }, position: { x: 0, @@ -35,8 +35,8 @@ const levelConfig = { character: 's', maxHealth: 12, abilities: { - attack: attack.with({ power: 3 }), - feel: feel, + attack: Attack.with({ power: 3 }), + feel: Feel, }, playTurn(sludge: any) { const threatDirection = RELATIVE_DIRECTIONS.find((direction) => { diff --git a/libs/units/src/Archer.ts b/libs/units/src/Archer.ts index 09ec0627..4763c27e 100644 --- a/libs/units/src/Archer.ts +++ b/libs/units/src/Archer.ts @@ -1,11 +1,11 @@ -import { look, shoot } from '@warriorjs/abilities'; +import { Look, Shoot } from '@warriorjs/abilities'; import RangedUnit from './RangedUnit.js'; class Archer extends RangedUnit { declaredAbilities = { - look: look.with({ range: 3 }), - shoot: shoot.with({ range: 3, power: 3 }), + look: Look.with({ range: 3 }), + shoot: Shoot.with({ range: 3, power: 3 }), }; constructor() { diff --git a/libs/units/src/Sludge.ts b/libs/units/src/Sludge.ts index f024e2c8..7ac19c1a 100644 --- a/libs/units/src/Sludge.ts +++ b/libs/units/src/Sludge.ts @@ -1,11 +1,11 @@ -import { attack, feel } from '@warriorjs/abilities'; +import { Attack, Feel } from '@warriorjs/abilities'; import MeleeUnit from './MeleeUnit.js'; class Sludge extends MeleeUnit { declaredAbilities = { - attack: attack.with({ power: 3 }), - feel: feel, + attack: Attack.with({ power: 3 }), + feel: Feel, }; constructor() { diff --git a/libs/units/src/ThickSludge.ts b/libs/units/src/ThickSludge.ts index e0f99404..f58ccee1 100644 --- a/libs/units/src/ThickSludge.ts +++ b/libs/units/src/ThickSludge.ts @@ -1,11 +1,11 @@ -import { attack, feel } from '@warriorjs/abilities'; +import { Attack, Feel } from '@warriorjs/abilities'; import MeleeUnit from './MeleeUnit.js'; class ThickSludge extends MeleeUnit { declaredAbilities = { - attack: attack.with({ power: 3 }), - feel: feel, + attack: Attack.with({ power: 3 }), + feel: Feel, }; constructor() { diff --git a/libs/units/src/Wizard.ts b/libs/units/src/Wizard.ts index 3a1ddbc9..0243e927 100644 --- a/libs/units/src/Wizard.ts +++ b/libs/units/src/Wizard.ts @@ -1,11 +1,11 @@ -import { look, shoot } from '@warriorjs/abilities'; +import { Look, Shoot } from '@warriorjs/abilities'; import RangedUnit from './RangedUnit.js'; class Wizard extends RangedUnit { declaredAbilities = { - look: look.with({ range: 3 }), - shoot: shoot.with({ range: 3, power: 11 }), + look: Look.with({ range: 3 }), + shoot: Shoot.with({ range: 3, power: 11 }), }; constructor() { diff --git a/towers/the-narrow-path/src/index.ts b/towers/the-narrow-path/src/index.ts index d6462753..d05ff22f 100644 --- a/towers/the-narrow-path/src/index.ts +++ b/towers/the-narrow-path/src/index.ts @@ -1,15 +1,15 @@ import { - attack, - feel, - health, - look, - maxHealth, - pivot, - rescue, - rest, - shoot, - think, - walk, + Attack, + Feel, + Health, + Look, + MaxHealth, + Pivot, + Rescue, + Rest, + Shoot, + Think, + Walk, } from '@warriorjs/abilities'; import type { TowerDefinition } from '@warriorjs/core'; import { EAST, WEST } from '@warriorjs/spatial'; @@ -30,7 +30,7 @@ const tower: TowerDefinition = { stairs: { x: 7, y: 0 }, warrior: { ...Warrior, - abilities: { think, walk }, + abilities: { think: Think, walk: Walk }, position: { x: 0, y: 0, facing: EAST }, }, units: [], @@ -48,7 +48,7 @@ const tower: TowerDefinition = { stairs: { x: 7, y: 0 }, warrior: { ...Warrior, - abilities: { attack: attack.with({ power: 5 }), feel }, + abilities: { attack: Attack.with({ power: 5 }), feel: Feel }, position: { x: 0, y: 0, facing: EAST }, }, units: [{ unit: new Sludge(), position: { x: 4, y: 0, facing: WEST } }], @@ -66,7 +66,7 @@ const tower: TowerDefinition = { stairs: { x: 8, y: 0 }, warrior: { ...Warrior, - abilities: { health, maxHealth, rest: rest.with({ healthGain: 0.1 }) }, + abilities: { health: Health, maxHealth: MaxHealth, rest: Rest.with({ healthGain: 0.1 }) }, position: { x: 0, y: 0, facing: EAST }, }, units: [ @@ -106,7 +106,7 @@ const tower: TowerDefinition = { stairs: { x: 6, y: 0 }, warrior: { ...Warrior, - abilities: { rescue }, + abilities: { rescue: Rescue }, position: { x: 0, y: 0, facing: EAST }, }, units: [ @@ -148,7 +148,7 @@ const tower: TowerDefinition = { stairs: { x: 0, y: 0 }, warrior: { ...Warrior, - abilities: { pivot }, + abilities: { pivot: Pivot }, position: { x: 5, y: 0, facing: EAST }, }, units: [ @@ -170,7 +170,7 @@ const tower: TowerDefinition = { warrior: { ...Warrior, position: { x: 0, y: 0, facing: EAST }, - abilities: { look: look.with({ range: 3 }), shoot: shoot.with({ power: 3, range: 3 }) }, + abilities: { look: Look.with({ range: 3 }), shoot: Shoot.with({ power: 3, range: 3 }) }, }, units: [ { unit: new Captive(), position: { x: 2, y: 0, facing: WEST } }, diff --git a/towers/the-powder-keep/src/index.ts b/towers/the-powder-keep/src/index.ts index 5e901f9d..b62a829a 100644 --- a/towers/the-powder-keep/src/index.ts +++ b/towers/the-powder-keep/src/index.ts @@ -1,19 +1,19 @@ import { - attack, - bind, - detonate, - directionOf, - directionOfStairs, - distanceOf, - feel, - health, - listen, - look, - maxHealth, - rescue, - rest, - think, - walk, + Attack, + Bind, + Detonate, + DirectionOf, + DirectionOfStairs, + DistanceOf, + Feel, + Health, + Listen, + Look, + MaxHealth, + Rescue, + Rest, + Think, + Walk, } from '@warriorjs/abilities'; import type { TowerDefinition } from '@warriorjs/core'; import { ticking } from '@warriorjs/effects'; @@ -42,9 +42,9 @@ const tower: TowerDefinition = { warrior: { ...Warrior, abilities: { - directionOfStairs: directionOfStairs, - think: think, - walk: walk, + directionOfStairs: DirectionOfStairs, + think: Think, + walk: Walk, }, position: { x: 0, @@ -74,11 +74,11 @@ const tower: TowerDefinition = { warrior: { ...Warrior, abilities: { - attack: attack.with({ power: 5 }), - feel: feel, - health: health, - maxHealth: maxHealth, - rest: rest.with({ healthGain: 0.1 }), + attack: Attack.with({ power: 5 }), + feel: Feel, + health: Health, + maxHealth: MaxHealth, + rest: Rest.with({ healthGain: 0.1 }), }, position: { x: 0, @@ -137,8 +137,8 @@ const tower: TowerDefinition = { facing: EAST, }, abilities: { - bind: bind, - rescue: rescue, + bind: Bind, + rescue: Rescue, }, }, units: [ @@ -201,8 +201,8 @@ const tower: TowerDefinition = { facing: EAST, }, abilities: { - directionOf: directionOf, - listen: listen, + directionOf: DirectionOf, + listen: Listen, }, }, units: [ @@ -459,8 +459,8 @@ const tower: TowerDefinition = { facing: EAST, }, abilities: { - detonate: detonate.with({ targetPower: 8, surroundingPower: 4 }), - look: look.with({ range: 3 }), + detonate: Detonate.with({ targetPower: 8, surroundingPower: 4 }), + look: Look.with({ range: 3 }), }, }, units: [ @@ -518,7 +518,7 @@ const tower: TowerDefinition = { facing: EAST, }, abilities: { - distanceOf: distanceOf, + distanceOf: DistanceOf, }, }, units: [ From 84214c954b84a3cb90690b00d8feb7ff9c191612 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Olivera?= Date: Tue, 17 Mar 2026 09:46:22 -0300 Subject: [PATCH 12/39] test(abilities): add tests for Ability, Action, and Sense base classes Co-Authored-By: Claude Opus 4.6 (1M context) --- libs/abilities/src/Ability.test.ts | 31 ++++++++++++++++++++ libs/abilities/src/Action.test.ts | 47 ++++++++++++++++++++++++++++++ libs/abilities/src/Sense.test.ts | 36 +++++++++++++++++++++++ 3 files changed, 114 insertions(+) create mode 100644 libs/abilities/src/Ability.test.ts create mode 100644 libs/abilities/src/Action.test.ts create mode 100644 libs/abilities/src/Sense.test.ts diff --git a/libs/abilities/src/Ability.test.ts b/libs/abilities/src/Ability.test.ts new file mode 100644 index 00000000..93b31629 --- /dev/null +++ b/libs/abilities/src/Ability.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test, vi } from 'vitest'; + +import Ability from './Ability.js'; +import Action from './Action.js'; +import Sense from './Sense.js'; +import type { AbilityMeta } from './types.js'; + +class ConcreteAction extends Action { + readonly description = 'test action'; + readonly meta: AbilityMeta = { params: [], returns: 'void' }; + perform = vi.fn(); +} + +class ConcreteSense extends Sense { + readonly description = 'test sense'; + readonly meta: AbilityMeta = { params: [], returns: 'number' }; + perform = vi.fn(() => 42); +} + +describe('Ability', () => { + test('stores unit reference', () => { + const unit = {} as any; + const action = new ConcreteAction(unit); + expect(action).toBeInstanceOf(Ability); + }); + + test('Action and Sense both extend Ability', () => { + expect(new ConcreteAction({} as any)).toBeInstanceOf(Ability); + expect(new ConcreteSense({} as any)).toBeInstanceOf(Ability); + }); +}); diff --git a/libs/abilities/src/Action.test.ts b/libs/abilities/src/Action.test.ts new file mode 100644 index 00000000..d4754c4d --- /dev/null +++ b/libs/abilities/src/Action.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test, vi } from 'vitest'; + +import Ability from './Ability.js'; +import Action from './Action.js'; +import Sense from './Sense.js'; +import type { AbilityMeta } from './types.js'; + +class TestAction extends Action { + readonly description = 'test action'; + readonly meta: AbilityMeta = { params: [], returns: 'void' }; + perform = vi.fn(); + + static with(config: { power: number }) { + return [TestAction, config] as const; + } +} + +describe('Action', () => { + test('extends Ability', () => { + const action = new TestAction({} as any); + expect(action).toBeInstanceOf(Ability); + expect(action).toBeInstanceOf(Action); + }); + + test('is not an instance of Sense', () => { + const action = new TestAction({} as any); + expect(action).not.toBeInstanceOf(Sense); + }); + + test('has description and meta', () => { + const action = new TestAction({} as any); + expect(action.description).toBe('test action'); + expect(action.meta).toEqual({ params: [], returns: 'void' }); + }); + + test('perform can be called', () => { + const action = new TestAction({} as any); + action.perform(); + expect(action.perform).toHaveBeenCalled(); + }); + + test('.with() returns an AbilityBinding', () => { + const binding = TestAction.with({ power: 5 }); + expect(binding[0]).toBe(TestAction); + expect(binding[1]).toEqual({ power: 5 }); + }); +}); diff --git a/libs/abilities/src/Sense.test.ts b/libs/abilities/src/Sense.test.ts new file mode 100644 index 00000000..daf07fbd --- /dev/null +++ b/libs/abilities/src/Sense.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test, vi } from 'vitest'; + +import Ability from './Ability.js'; +import Action from './Action.js'; +import Sense from './Sense.js'; +import type { AbilityMeta } from './types.js'; + +class TestSense extends Sense { + readonly description = 'test sense'; + readonly meta: AbilityMeta = { params: [], returns: 'number' }; + perform = vi.fn(() => 42); +} + +describe('Sense', () => { + test('extends Ability', () => { + const sense = new TestSense({} as any); + expect(sense).toBeInstanceOf(Ability); + expect(sense).toBeInstanceOf(Sense); + }); + + test('is not an instance of Action', () => { + const sense = new TestSense({} as any); + expect(sense).not.toBeInstanceOf(Action); + }); + + test('has description and meta', () => { + const sense = new TestSense({} as any); + expect(sense.description).toBe('test sense'); + expect(sense.meta).toEqual({ params: [], returns: 'number' }); + }); + + test('perform returns a value', () => { + const sense = new TestSense({} as any); + expect(sense.perform()).toBe(42); + }); +}); From df89d526c93aa34399a81b987ce379b6058dbea7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Olivera?= Date: Tue, 17 Mar 2026 09:58:03 -0300 Subject: [PATCH 13/39] style(abilities): reorder class members in configurable abilities Move readonly fields (description, meta) before private fields and reorder constructor assignments for consistency. Co-Authored-By: Claude Opus 4.6 (1M context) --- libs/abilities/src/Attack.ts | 5 +++-- libs/abilities/src/Detonate.ts | 7 ++++--- libs/abilities/src/Look.ts | 5 +++-- libs/abilities/src/Rest.ts | 6 +++--- libs/abilities/src/Shoot.ts | 7 ++++--- 5 files changed, 17 insertions(+), 13 deletions(-) diff --git a/libs/abilities/src/Attack.ts b/libs/abilities/src/Attack.ts index a3f5494e..6055db88 100644 --- a/libs/abilities/src/Attack.ts +++ b/libs/abilities/src/Attack.ts @@ -11,17 +11,18 @@ interface AttackConfig { } class Attack extends Action { - private power: number; readonly description: string; readonly meta: AbilityMeta = { params: [{ name: 'direction', type: 'Direction', optional: true }], returns: 'void', }; + private power: number; + constructor(unit: Unit, { power }: AttackConfig) { super(unit); - this.power = power; this.description = `Attacks a unit in the given direction (\`'${defaultDirection}'\` by default), dealing ${power} HP of damage.`; + this.power = power; } perform(direction: RelativeDirection = defaultDirection): void { diff --git a/libs/abilities/src/Detonate.ts b/libs/abilities/src/Detonate.ts index e67b5590..69f14bdb 100644 --- a/libs/abilities/src/Detonate.ts +++ b/libs/abilities/src/Detonate.ts @@ -18,19 +18,20 @@ interface DetonateConfig { } class Detonate extends Action { - private targetPower: number; - private surroundingPower: number; readonly description: string; readonly meta: AbilityMeta = { params: [{ name: 'direction', type: 'Direction', optional: true }], returns: 'void', }; + private targetPower: number; + private surroundingPower: number; + constructor(unit: Unit, { targetPower, surroundingPower }: DetonateConfig) { super(unit); + this.description = `Detonates a bomb in a given direction (\`'${defaultDirection}'\` by default), dealing ${targetPower} HP of damage to that space and ${surroundingPower} HP of damage to surrounding 4 spaces (including yourself).`; this.targetPower = targetPower; this.surroundingPower = surroundingPower; - this.description = `Detonates a bomb in a given direction (\`'${defaultDirection}'\` by default), dealing ${targetPower} HP of damage to that space and ${surroundingPower} HP of damage to surrounding 4 spaces (including yourself).`; } perform(direction: RelativeDirection = defaultDirection): void { diff --git a/libs/abilities/src/Look.ts b/libs/abilities/src/Look.ts index fa393c28..ae20e20e 100644 --- a/libs/abilities/src/Look.ts +++ b/libs/abilities/src/Look.ts @@ -11,17 +11,18 @@ interface LookConfig { } class Look extends Sense { - private range: number; readonly description: string; readonly meta: AbilityMeta = { params: [{ name: 'direction', type: 'Direction', optional: true }], returns: 'Space[]', }; + private range: number; + constructor(unit: Unit, { range }: LookConfig) { super(unit); - this.range = range; this.description = `Returns an array of up to ${range} spaces in the given direction (\`'${defaultDirection}'\` by default).`; + this.range = range; } perform(direction: RelativeDirection = defaultDirection) { diff --git a/libs/abilities/src/Rest.ts b/libs/abilities/src/Rest.ts index 3d1ab993..a0adb5e4 100644 --- a/libs/abilities/src/Rest.ts +++ b/libs/abilities/src/Rest.ts @@ -7,18 +7,18 @@ interface RestConfig { } class Rest extends Action { - private healthGain: number; readonly description: string; readonly meta: AbilityMeta = { params: [], returns: 'void', }; + private healthGain: number; + constructor(unit: Unit, { healthGain }: RestConfig) { super(unit); + this.description = `Gains ${healthGain * 100}% of max health back, but does nothing more.`; this.healthGain = healthGain; - const healthGainPercentage = healthGain * 100; - this.description = `Gains ${healthGainPercentage}% of max health back, but does nothing more.`; } perform(): void { diff --git a/libs/abilities/src/Shoot.ts b/libs/abilities/src/Shoot.ts index 114312ae..aebab45b 100644 --- a/libs/abilities/src/Shoot.ts +++ b/libs/abilities/src/Shoot.ts @@ -12,19 +12,20 @@ interface ShootConfig { } class Shoot extends Action { - private power: number; - private range: number; readonly description: string; readonly meta: AbilityMeta = { params: [{ name: 'direction', type: 'Direction', optional: true }], returns: 'void', }; + private power: number; + private range: number; + constructor(unit: Unit, { power, range }: ShootConfig) { super(unit); + this.description = `Shoots the bow & arrow in the given direction (\`'${defaultDirection}'\` by default), dealing ${power} HP of damage to the first unit in a range of ${range} spaces.`; this.power = power; this.range = range; - this.description = `Shoots the bow & arrow in the given direction (\`'${defaultDirection}'\` by default), dealing ${power} HP of damage to the first unit in a range of ${range} spaces.`; } perform(direction: RelativeDirection = defaultDirection): void { From 631129be21711c03f1e29cf86ece1a9ac5678ee7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Olivera?= Date: Tue, 17 Mar 2026 10:52:41 -0300 Subject: [PATCH 14/39] refactor(core): move Ability, Action, Sense base classes to core Move the foundational ability base classes and types (Ability, Action, Sense, AbilityBinding, AbilityMeta) from @warriorjs/abilities to @warriorjs/core. Core no longer depends on abilities; abilities re-exports the base classes from core. Tests for base classes move to core; concrete ability tests import from @warriorjs/core. Co-Authored-By: Claude Opus 4.6 (1M context) --- libs/abilities/package.json | 1 + libs/abilities/src/Ability.ts | 18 ---- libs/abilities/src/Attack.test.ts | 3 +- libs/abilities/src/Attack.ts | 6 +- libs/abilities/src/Bind.test.ts | 3 +- libs/abilities/src/Bind.ts | 3 +- libs/abilities/src/Detonate.test.ts | 3 +- libs/abilities/src/Detonate.ts | 6 +- libs/abilities/src/DirectionOf.test.ts | 2 +- libs/abilities/src/DirectionOf.ts | 3 +- libs/abilities/src/DirectionOfStairs.test.ts | 2 +- libs/abilities/src/DirectionOfStairs.ts | 3 +- libs/abilities/src/DistanceOf.test.ts | 2 +- libs/abilities/src/DistanceOf.ts | 2 +- libs/abilities/src/Feel.test.ts | 2 +- libs/abilities/src/Feel.ts | 3 +- libs/abilities/src/Health.test.ts | 2 +- libs/abilities/src/Health.ts | 2 +- libs/abilities/src/Listen.test.ts | 2 +- libs/abilities/src/Listen.ts | 9 +- libs/abilities/src/Look.test.ts | 2 +- libs/abilities/src/Look.ts | 6 +- libs/abilities/src/MaxHealth.test.ts | 2 +- libs/abilities/src/MaxHealth.ts | 2 +- libs/abilities/src/Pivot.test.ts | 3 +- libs/abilities/src/Pivot.ts | 3 +- libs/abilities/src/Rescue.test.ts | 3 +- libs/abilities/src/Rescue.ts | 3 +- libs/abilities/src/Rest.test.ts | 3 +- libs/abilities/src/Rest.ts | 4 +- libs/abilities/src/Shoot.test.ts | 3 +- libs/abilities/src/Shoot.ts | 6 +- libs/abilities/src/Think.test.ts | 3 +- libs/abilities/src/Think.ts | 2 +- libs/abilities/src/Walk.test.ts | 3 +- libs/abilities/src/Walk.ts | 3 +- libs/abilities/src/index.ts | 8 +- libs/core/package.json | 1 - libs/{abilities => core}/src/Ability.test.ts | 3 +- libs/core/src/Ability.ts | 28 ++++++ libs/{abilities => core}/src/Action.test.ts | 3 +- libs/{abilities => core}/src/Action.ts | 0 libs/{abilities => core}/src/Sense.test.ts | 3 +- libs/{abilities => core}/src/Sense.ts | 0 libs/core/src/Unit.test.ts | 4 +- libs/core/src/Unit.ts | 3 +- libs/core/src/Warrior.test.ts | 3 +- libs/core/src/Warrior.ts | 3 +- libs/core/src/getLevel.test.ts | 88 ++++++++++-------- libs/core/src/index.ts | 4 + libs/core/src/loadLevel.ts | 8 +- libs/core/src/runLevel.test.ts | 93 ++++++++++++++------ pnpm-lock.yaml | 6 +- 53 files changed, 217 insertions(+), 169 deletions(-) delete mode 100644 libs/abilities/src/Ability.ts rename libs/{abilities => core}/src/Ability.test.ts (94%) create mode 100644 libs/core/src/Ability.ts rename libs/{abilities => core}/src/Action.test.ts (96%) rename libs/{abilities => core}/src/Action.ts (100%) rename libs/{abilities => core}/src/Sense.test.ts (95%) rename libs/{abilities => core}/src/Sense.ts (100%) diff --git a/libs/abilities/package.json b/libs/abilities/package.json index 6c707f3b..852356e0 100644 --- a/libs/abilities/package.json +++ b/libs/abilities/package.json @@ -27,6 +27,7 @@ "build": "tsc -p tsconfig.json" }, "dependencies": { + "@warriorjs/core": "workspace:^", "@warriorjs/spatial": "workspace:^" } } diff --git a/libs/abilities/src/Ability.ts b/libs/abilities/src/Ability.ts deleted file mode 100644 index 7303d588..00000000 --- a/libs/abilities/src/Ability.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { AbilityMeta, Unit } from './types.js'; - -export type AbilityBinding = [new (unit: any, config: any) => Ability, object]; - -abstract class Ability { - protected unit: Unit; - - abstract readonly description: string; - abstract readonly meta: AbilityMeta; - - constructor(unit: Unit, _config?: Record) { - this.unit = unit; - } - - abstract perform(...args: unknown[]): unknown; -} - -export default Ability; diff --git a/libs/abilities/src/Attack.test.ts b/libs/abilities/src/Attack.test.ts index 686fa740..20e78027 100644 --- a/libs/abilities/src/Attack.test.ts +++ b/libs/abilities/src/Attack.test.ts @@ -1,7 +1,6 @@ +import { Action } from '@warriorjs/core'; import { BACKWARD, FORWARD, LEFT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; - -import Action from './Action.js'; import Attack from './Attack.js'; describe('Attack', () => { diff --git a/libs/abilities/src/Attack.ts b/libs/abilities/src/Attack.ts index 6055db88..1087dc9a 100644 --- a/libs/abilities/src/Attack.ts +++ b/libs/abilities/src/Attack.ts @@ -1,7 +1,7 @@ -import { BACKWARD, FORWARD, type RelativeDirection } from '@warriorjs/spatial'; +import type { AbilityBinding } from '@warriorjs/core'; -import type { AbilityBinding } from './Ability.js'; -import Action from './Action.js'; +import { Action } from '@warriorjs/core'; +import { BACKWARD, FORWARD, type RelativeDirection } from '@warriorjs/spatial'; import type { AbilityMeta, Unit } from './types.js'; const defaultDirection = FORWARD; diff --git a/libs/abilities/src/Bind.test.ts b/libs/abilities/src/Bind.test.ts index 771e1117..541c6575 100644 --- a/libs/abilities/src/Bind.test.ts +++ b/libs/abilities/src/Bind.test.ts @@ -1,7 +1,6 @@ +import { Action } from '@warriorjs/core'; import { FORWARD, LEFT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; - -import Action from './Action.js'; import Bind from './Bind.js'; describe('Bind', () => { diff --git a/libs/abilities/src/Bind.ts b/libs/abilities/src/Bind.ts index b61d5a05..3e679996 100644 --- a/libs/abilities/src/Bind.ts +++ b/libs/abilities/src/Bind.ts @@ -1,6 +1,5 @@ +import { Action } from '@warriorjs/core'; import { FORWARD, type RelativeDirection } from '@warriorjs/spatial'; - -import Action from './Action.js'; import type { AbilityMeta } from './types.js'; const defaultDirection = FORWARD; diff --git a/libs/abilities/src/Detonate.test.ts b/libs/abilities/src/Detonate.test.ts index d5383c4b..a611dd05 100644 --- a/libs/abilities/src/Detonate.test.ts +++ b/libs/abilities/src/Detonate.test.ts @@ -1,7 +1,6 @@ +import { Action } from '@warriorjs/core'; import { FORWARD, LEFT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; - -import Action from './Action.js'; import Detonate from './Detonate.js'; describe('Detonate', () => { diff --git a/libs/abilities/src/Detonate.ts b/libs/abilities/src/Detonate.ts index 69f14bdb..c6f46194 100644 --- a/libs/abilities/src/Detonate.ts +++ b/libs/abilities/src/Detonate.ts @@ -1,7 +1,7 @@ -import { FORWARD, type RelativeDirection } from '@warriorjs/spatial'; +import type { AbilityBinding } from '@warriorjs/core'; -import type { AbilityBinding } from './Ability.js'; -import Action from './Action.js'; +import { Action } from '@warriorjs/core'; +import { FORWARD, type RelativeDirection } from '@warriorjs/spatial'; import type { AbilityMeta, Space, Unit } from './types.js'; const defaultDirection = FORWARD; diff --git a/libs/abilities/src/DirectionOf.test.ts b/libs/abilities/src/DirectionOf.test.ts index b9b9874c..e561395e 100644 --- a/libs/abilities/src/DirectionOf.test.ts +++ b/libs/abilities/src/DirectionOf.test.ts @@ -1,7 +1,7 @@ +import { Sense } from '@warriorjs/core'; import { BACKWARD, FORWARD, LEFT, RIGHT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import DirectionOf from './DirectionOf.js'; -import Sense from './Sense.js'; describe('DirectionOf', () => { let directionOf: DirectionOf; diff --git a/libs/abilities/src/DirectionOf.ts b/libs/abilities/src/DirectionOf.ts index 643cd712..d92d81b6 100644 --- a/libs/abilities/src/DirectionOf.ts +++ b/libs/abilities/src/DirectionOf.ts @@ -1,6 +1,5 @@ +import { Sense } from '@warriorjs/core'; import { BACKWARD, FORWARD, LEFT, RIGHT } from '@warriorjs/spatial'; - -import Sense from './Sense.js'; import type { AbilityMeta } from './types.js'; class DirectionOf extends Sense { diff --git a/libs/abilities/src/DirectionOfStairs.test.ts b/libs/abilities/src/DirectionOfStairs.test.ts index 34c141f7..cfa2496c 100644 --- a/libs/abilities/src/DirectionOfStairs.test.ts +++ b/libs/abilities/src/DirectionOfStairs.test.ts @@ -1,7 +1,7 @@ +import { Sense } from '@warriorjs/core'; import { BACKWARD, FORWARD, LEFT, RIGHT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import DirectionOfStairs from './DirectionOfStairs.js'; -import Sense from './Sense.js'; describe('DirectionOfStairs', () => { let directionOfStairs: DirectionOfStairs; diff --git a/libs/abilities/src/DirectionOfStairs.ts b/libs/abilities/src/DirectionOfStairs.ts index adc5a070..58ae7764 100644 --- a/libs/abilities/src/DirectionOfStairs.ts +++ b/libs/abilities/src/DirectionOfStairs.ts @@ -1,6 +1,5 @@ +import { Sense } from '@warriorjs/core'; import { BACKWARD, FORWARD, LEFT, RIGHT } from '@warriorjs/spatial'; - -import Sense from './Sense.js'; import type { AbilityMeta } from './types.js'; class DirectionOfStairs extends Sense { diff --git a/libs/abilities/src/DistanceOf.test.ts b/libs/abilities/src/DistanceOf.test.ts index b1422f7e..48bd052c 100644 --- a/libs/abilities/src/DistanceOf.test.ts +++ b/libs/abilities/src/DistanceOf.test.ts @@ -1,6 +1,6 @@ +import { Sense } from '@warriorjs/core'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import DistanceOf from './DistanceOf.js'; -import Sense from './Sense.js'; describe('DistanceOf', () => { let distanceOf: DistanceOf; diff --git a/libs/abilities/src/DistanceOf.ts b/libs/abilities/src/DistanceOf.ts index d53fb072..80f1009d 100644 --- a/libs/abilities/src/DistanceOf.ts +++ b/libs/abilities/src/DistanceOf.ts @@ -1,4 +1,4 @@ -import Sense from './Sense.js'; +import { Sense } from '@warriorjs/core'; import type { AbilityMeta } from './types.js'; class DistanceOf extends Sense { diff --git a/libs/abilities/src/Feel.test.ts b/libs/abilities/src/Feel.test.ts index 3267692d..61016b29 100644 --- a/libs/abilities/src/Feel.test.ts +++ b/libs/abilities/src/Feel.test.ts @@ -1,7 +1,7 @@ +import { Sense } from '@warriorjs/core'; import { FORWARD, LEFT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import Feel from './Feel.js'; -import Sense from './Sense.js'; describe('Feel', () => { let feel: Feel; diff --git a/libs/abilities/src/Feel.ts b/libs/abilities/src/Feel.ts index 3cf5d550..d9510c73 100644 --- a/libs/abilities/src/Feel.ts +++ b/libs/abilities/src/Feel.ts @@ -1,6 +1,5 @@ +import { Sense } from '@warriorjs/core'; import { FORWARD, type RelativeDirection } from '@warriorjs/spatial'; - -import Sense from './Sense.js'; import type { AbilityMeta } from './types.js'; const defaultDirection = FORWARD; diff --git a/libs/abilities/src/Health.test.ts b/libs/abilities/src/Health.test.ts index 44452fee..061497c3 100644 --- a/libs/abilities/src/Health.test.ts +++ b/libs/abilities/src/Health.test.ts @@ -1,6 +1,6 @@ +import { Sense } from '@warriorjs/core'; import { beforeEach, describe, expect, test } from 'vitest'; import Health from './Health.js'; -import Sense from './Sense.js'; describe('Health', () => { let health: Health; diff --git a/libs/abilities/src/Health.ts b/libs/abilities/src/Health.ts index 69260b76..44d7390c 100644 --- a/libs/abilities/src/Health.ts +++ b/libs/abilities/src/Health.ts @@ -1,4 +1,4 @@ -import Sense from './Sense.js'; +import { Sense } from '@warriorjs/core'; import type { AbilityMeta } from './types.js'; class Health extends Sense { diff --git a/libs/abilities/src/Listen.test.ts b/libs/abilities/src/Listen.test.ts index 7c41689f..f6ebdfd6 100644 --- a/libs/abilities/src/Listen.test.ts +++ b/libs/abilities/src/Listen.test.ts @@ -1,7 +1,7 @@ +import { Sense } from '@warriorjs/core'; import { FORWARD, NORTH } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import Listen from './Listen.js'; -import Sense from './Sense.js'; describe('Listen', () => { let listen: Listen; diff --git a/libs/abilities/src/Listen.ts b/libs/abilities/src/Listen.ts index b7237b21..2e8ae4a9 100644 --- a/libs/abilities/src/Listen.ts +++ b/libs/abilities/src/Listen.ts @@ -1,6 +1,5 @@ +import { Sense } from '@warriorjs/core'; import { FORWARD, getRelativeOffset } from '@warriorjs/spatial'; - -import Sense from './Sense.js'; import type { AbilityMeta } from './types.js'; class Listen extends Sense { @@ -14,14 +13,16 @@ class Listen extends Sense { perform() { return this.unit .getOtherUnits() - .map((anotherUnit) => + .map((anotherUnit: any) => getRelativeOffset( anotherUnit.getSpace().location, this.unit.position.location, this.unit.position.orientation, ), ) - .map(([forward, right]) => this.unit.getSensedSpaceAt(FORWARD, forward, right)); + .map(([forward, right]: [number, number]) => + this.unit.getSensedSpaceAt(FORWARD, forward, right), + ); } } diff --git a/libs/abilities/src/Look.test.ts b/libs/abilities/src/Look.test.ts index 68bf59ef..c127e62b 100644 --- a/libs/abilities/src/Look.test.ts +++ b/libs/abilities/src/Look.test.ts @@ -1,7 +1,7 @@ +import { Sense } from '@warriorjs/core'; import { FORWARD, LEFT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import Look from './Look.js'; -import Sense from './Sense.js'; describe('Look', () => { let look: Look; diff --git a/libs/abilities/src/Look.ts b/libs/abilities/src/Look.ts index ae20e20e..21683448 100644 --- a/libs/abilities/src/Look.ts +++ b/libs/abilities/src/Look.ts @@ -1,7 +1,7 @@ -import { FORWARD, type RelativeDirection } from '@warriorjs/spatial'; +import type { AbilityBinding } from '@warriorjs/core'; -import type { AbilityBinding } from './Ability.js'; -import Sense from './Sense.js'; +import { Sense } from '@warriorjs/core'; +import { FORWARD, type RelativeDirection } from '@warriorjs/spatial'; import type { AbilityMeta, Unit } from './types.js'; const defaultDirection = FORWARD; diff --git a/libs/abilities/src/MaxHealth.test.ts b/libs/abilities/src/MaxHealth.test.ts index 881472ba..03331a35 100644 --- a/libs/abilities/src/MaxHealth.test.ts +++ b/libs/abilities/src/MaxHealth.test.ts @@ -1,6 +1,6 @@ +import { Sense } from '@warriorjs/core'; import { beforeEach, describe, expect, test } from 'vitest'; import MaxHealth from './MaxHealth.js'; -import Sense from './Sense.js'; describe('MaxHealth', () => { let maxHealth: MaxHealth; diff --git a/libs/abilities/src/MaxHealth.ts b/libs/abilities/src/MaxHealth.ts index aa448fc6..4f81dca9 100644 --- a/libs/abilities/src/MaxHealth.ts +++ b/libs/abilities/src/MaxHealth.ts @@ -1,4 +1,4 @@ -import Sense from './Sense.js'; +import { Sense } from '@warriorjs/core'; import type { AbilityMeta } from './types.js'; class MaxHealth extends Sense { diff --git a/libs/abilities/src/Pivot.test.ts b/libs/abilities/src/Pivot.test.ts index 8e3185d9..88505bea 100644 --- a/libs/abilities/src/Pivot.test.ts +++ b/libs/abilities/src/Pivot.test.ts @@ -1,7 +1,6 @@ +import { Action } from '@warriorjs/core'; import { BACKWARD, RIGHT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; - -import Action from './Action.js'; import Pivot from './Pivot.js'; describe('Pivot', () => { diff --git a/libs/abilities/src/Pivot.ts b/libs/abilities/src/Pivot.ts index cb1eeab3..3f2967a8 100644 --- a/libs/abilities/src/Pivot.ts +++ b/libs/abilities/src/Pivot.ts @@ -1,6 +1,5 @@ +import { Action } from '@warriorjs/core'; import { BACKWARD, type RelativeDirection } from '@warriorjs/spatial'; - -import Action from './Action.js'; import type { AbilityMeta } from './types.js'; const defaultDirection = BACKWARD; diff --git a/libs/abilities/src/Rescue.test.ts b/libs/abilities/src/Rescue.test.ts index ac4e023c..b8ee836d 100644 --- a/libs/abilities/src/Rescue.test.ts +++ b/libs/abilities/src/Rescue.test.ts @@ -1,7 +1,6 @@ +import { Action } from '@warriorjs/core'; import { FORWARD, RIGHT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; - -import Action from './Action.js'; import Rescue from './Rescue.js'; describe('Rescue', () => { diff --git a/libs/abilities/src/Rescue.ts b/libs/abilities/src/Rescue.ts index e9ea3209..f72b327b 100644 --- a/libs/abilities/src/Rescue.ts +++ b/libs/abilities/src/Rescue.ts @@ -1,6 +1,5 @@ +import { Action } from '@warriorjs/core'; import { FORWARD, type RelativeDirection } from '@warriorjs/spatial'; - -import Action from './Action.js'; import type { AbilityMeta } from './types.js'; const defaultDirection = FORWARD; diff --git a/libs/abilities/src/Rest.test.ts b/libs/abilities/src/Rest.test.ts index 864c6e38..e065179a 100644 --- a/libs/abilities/src/Rest.test.ts +++ b/libs/abilities/src/Rest.test.ts @@ -1,6 +1,5 @@ +import { Action } from '@warriorjs/core'; import { beforeEach, describe, expect, test, vi } from 'vitest'; - -import Action from './Action.js'; import Rest from './Rest.js'; describe('Rest', () => { diff --git a/libs/abilities/src/Rest.ts b/libs/abilities/src/Rest.ts index a0adb5e4..dad95935 100644 --- a/libs/abilities/src/Rest.ts +++ b/libs/abilities/src/Rest.ts @@ -1,5 +1,5 @@ -import type { AbilityBinding } from './Ability.js'; -import Action from './Action.js'; +import type { AbilityBinding } from '@warriorjs/core'; +import { Action } from '@warriorjs/core'; import type { AbilityMeta, Unit } from './types.js'; interface RestConfig { diff --git a/libs/abilities/src/Shoot.test.ts b/libs/abilities/src/Shoot.test.ts index 7b1d50b3..24c926f5 100644 --- a/libs/abilities/src/Shoot.test.ts +++ b/libs/abilities/src/Shoot.test.ts @@ -1,7 +1,6 @@ +import { Action } from '@warriorjs/core'; import { FORWARD, LEFT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; - -import Action from './Action.js'; import Shoot from './Shoot.js'; describe('Shoot', () => { diff --git a/libs/abilities/src/Shoot.ts b/libs/abilities/src/Shoot.ts index aebab45b..49ebe981 100644 --- a/libs/abilities/src/Shoot.ts +++ b/libs/abilities/src/Shoot.ts @@ -1,7 +1,7 @@ -import { FORWARD, type RelativeDirection } from '@warriorjs/spatial'; +import type { AbilityBinding } from '@warriorjs/core'; -import type { AbilityBinding } from './Ability.js'; -import Action from './Action.js'; +import { Action } from '@warriorjs/core'; +import { FORWARD, type RelativeDirection } from '@warriorjs/spatial'; import type { AbilityMeta, Unit } from './types.js'; const defaultDirection = FORWARD; diff --git a/libs/abilities/src/Think.test.ts b/libs/abilities/src/Think.test.ts index ed0084f7..deba0642 100644 --- a/libs/abilities/src/Think.test.ts +++ b/libs/abilities/src/Think.test.ts @@ -1,6 +1,5 @@ +import { Sense } from '@warriorjs/core'; import { beforeEach, describe, expect, test, vi } from 'vitest'; - -import Sense from './Sense.js'; import Think from './Think.js'; describe('Think', () => { diff --git a/libs/abilities/src/Think.ts b/libs/abilities/src/Think.ts index f8657bf8..b13618a9 100644 --- a/libs/abilities/src/Think.ts +++ b/libs/abilities/src/Think.ts @@ -1,6 +1,6 @@ import util from 'node:util'; -import Sense from './Sense.js'; +import { Sense } from '@warriorjs/core'; import type { AbilityMeta } from './types.js'; class Think extends Sense { diff --git a/libs/abilities/src/Walk.test.ts b/libs/abilities/src/Walk.test.ts index a2499b83..26801bdb 100644 --- a/libs/abilities/src/Walk.test.ts +++ b/libs/abilities/src/Walk.test.ts @@ -1,7 +1,6 @@ +import { Action } from '@warriorjs/core'; import { FORWARD, RIGHT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; - -import Action from './Action.js'; import Walk from './Walk.js'; describe('Walk', () => { diff --git a/libs/abilities/src/Walk.ts b/libs/abilities/src/Walk.ts index fc3e8165..25073f90 100644 --- a/libs/abilities/src/Walk.ts +++ b/libs/abilities/src/Walk.ts @@ -1,6 +1,5 @@ +import { Action } from '@warriorjs/core'; import { FORWARD, type RelativeDirection } from '@warriorjs/spatial'; - -import Action from './Action.js'; import type { AbilityMeta } from './types.js'; const defaultDirection = FORWARD; diff --git a/libs/abilities/src/index.ts b/libs/abilities/src/index.ts index f04c67fc..3333d08a 100644 --- a/libs/abilities/src/index.ts +++ b/libs/abilities/src/index.ts @@ -1,6 +1,5 @@ -export type { AbilityBinding } from './Ability.js'; -export { default as Ability } from './Ability.js'; -export { default as Action } from './Action.js'; +export type { AbilityBinding, AbilityMeta } from '@warriorjs/core'; +export { Ability, Action, Sense } from '@warriorjs/core'; export { default as Attack } from './Attack.js'; export { default as Bind } from './Bind.js'; export { default as Detonate } from './Detonate.js'; @@ -15,8 +14,7 @@ export { default as MaxHealth } from './MaxHealth.js'; export { default as Pivot } from './Pivot.js'; export { default as Rescue } from './Rescue.js'; export { default as Rest } from './Rest.js'; -export { default as Sense } from './Sense.js'; export { default as Shoot } from './Shoot.js'; export { default as Think } from './Think.js'; -export type { AbilityMeta, SensedSpace, Space, Unit } from './types.js'; +export type { SensedSpace, Space, Unit } from './types.js'; export { default as Walk } from './Walk.js'; diff --git a/libs/core/package.json b/libs/core/package.json index 65072033..31e313a1 100644 --- a/libs/core/package.json +++ b/libs/core/package.json @@ -45,7 +45,6 @@ "build": "tsc -p tsconfig.json" }, "dependencies": { - "@warriorjs/abilities": "workspace:^", "@warriorjs/spatial": "workspace:^", "esbuild": "^0.27.3" } diff --git a/libs/abilities/src/Ability.test.ts b/libs/core/src/Ability.test.ts similarity index 94% rename from libs/abilities/src/Ability.test.ts rename to libs/core/src/Ability.test.ts index 93b31629..58364455 100644 --- a/libs/abilities/src/Ability.test.ts +++ b/libs/core/src/Ability.test.ts @@ -1,9 +1,8 @@ import { describe, expect, test, vi } from 'vitest'; - +import type { AbilityMeta } from './Ability.js'; import Ability from './Ability.js'; import Action from './Action.js'; import Sense from './Sense.js'; -import type { AbilityMeta } from './types.js'; class ConcreteAction extends Action { readonly description = 'test action'; diff --git a/libs/core/src/Ability.ts b/libs/core/src/Ability.ts new file mode 100644 index 00000000..633bf8bf --- /dev/null +++ b/libs/core/src/Ability.ts @@ -0,0 +1,28 @@ +export interface AbilityParam { + name: string; + type: 'Direction' | 'Space' | 'number' | 'any'; + optional?: boolean; + rest?: boolean; +} + +export interface AbilityMeta { + params: AbilityParam[]; + returns: 'void' | 'number' | 'string' | 'Direction' | 'Space' | 'Space[]'; +} + +export type AbilityBinding = [new (unit: any, config: any) => Ability, object]; + +abstract class Ability { + protected unit: any; + + abstract readonly description: string; + abstract readonly meta: AbilityMeta; + + constructor(unit: any, _config?: Record) { + this.unit = unit; + } + + abstract perform(...args: unknown[]): unknown; +} + +export default Ability; diff --git a/libs/abilities/src/Action.test.ts b/libs/core/src/Action.test.ts similarity index 96% rename from libs/abilities/src/Action.test.ts rename to libs/core/src/Action.test.ts index d4754c4d..093a1baa 100644 --- a/libs/abilities/src/Action.test.ts +++ b/libs/core/src/Action.test.ts @@ -1,9 +1,8 @@ import { describe, expect, test, vi } from 'vitest'; - +import type { AbilityMeta } from './Ability.js'; import Ability from './Ability.js'; import Action from './Action.js'; import Sense from './Sense.js'; -import type { AbilityMeta } from './types.js'; class TestAction extends Action { readonly description = 'test action'; diff --git a/libs/abilities/src/Action.ts b/libs/core/src/Action.ts similarity index 100% rename from libs/abilities/src/Action.ts rename to libs/core/src/Action.ts diff --git a/libs/abilities/src/Sense.test.ts b/libs/core/src/Sense.test.ts similarity index 95% rename from libs/abilities/src/Sense.test.ts rename to libs/core/src/Sense.test.ts index daf07fbd..8bc9c999 100644 --- a/libs/abilities/src/Sense.test.ts +++ b/libs/core/src/Sense.test.ts @@ -1,9 +1,8 @@ import { describe, expect, test, vi } from 'vitest'; - +import type { AbilityMeta } from './Ability.js'; import Ability from './Ability.js'; import Action from './Action.js'; import Sense from './Sense.js'; -import type { AbilityMeta } from './types.js'; class TestSense extends Sense { readonly description = 'test sense'; diff --git a/libs/abilities/src/Sense.ts b/libs/core/src/Sense.ts similarity index 100% rename from libs/abilities/src/Sense.ts rename to libs/core/src/Sense.ts diff --git a/libs/core/src/Unit.test.ts b/libs/core/src/Unit.test.ts index ec4992ba..f42afda6 100644 --- a/libs/core/src/Unit.test.ts +++ b/libs/core/src/Unit.test.ts @@ -1,8 +1,8 @@ -import { Action, Sense } from '@warriorjs/abilities'; import { BACKWARD, FORWARD, LEFT, NORTH, RIGHT, SOUTH } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; - +import Action from './Action.js'; import Floor from './Floor.js'; +import Sense from './Sense.js'; import Unit from './Unit.js'; class MockAction extends Action { diff --git a/libs/core/src/Unit.ts b/libs/core/src/Unit.ts index ae8452f2..269cb557 100644 --- a/libs/core/src/Unit.ts +++ b/libs/core/src/Unit.ts @@ -1,5 +1,4 @@ -import { Action } from '@warriorjs/abilities'; - +import Action from './Action.js'; import Logger from './Logger.js'; import type Position from './Position.js'; import type { SensedSpace, SensedUnit } from './Space.js'; diff --git a/libs/core/src/Warrior.test.ts b/libs/core/src/Warrior.test.ts index d322dae6..1baade9e 100644 --- a/libs/core/src/Warrior.test.ts +++ b/libs/core/src/Warrior.test.ts @@ -1,5 +1,6 @@ -import { Action, Sense } from '@warriorjs/abilities'; import { beforeEach, describe, expect, test, vi } from 'vitest'; +import Action from './Action.js'; +import Sense from './Sense.js'; import Warrior from './Warrior.js'; diff --git a/libs/core/src/Warrior.ts b/libs/core/src/Warrior.ts index 9d0ad6c6..7665ab6b 100644 --- a/libs/core/src/Warrior.ts +++ b/libs/core/src/Warrior.ts @@ -1,5 +1,4 @@ -import { Action } from '@warriorjs/abilities'; - +import Action from './Action.js'; import Unit from './Unit.js'; interface AbilityInfo { diff --git a/libs/core/src/getLevel.test.ts b/libs/core/src/getLevel.test.ts index d201fc2e..476d8029 100644 --- a/libs/core/src/getLevel.test.ts +++ b/libs/core/src/getLevel.test.ts @@ -1,8 +1,45 @@ -import { Attack, Feel, Walk } from '@warriorjs/abilities'; import { EAST, RELATIVE_DIRECTIONS, WEST } from '@warriorjs/spatial'; import { expect, test } from 'vitest'; +import type { AbilityMeta } from './Ability.js'; +import Action from './Action.js'; import getLevel from './getLevel.js'; +import Sense from './Sense.js'; + +class TestWalk extends Action { + readonly description = "Moves one space in the given direction (`'forward'` by default)."; + readonly meta: AbilityMeta = { + params: [{ name: 'direction', type: 'Direction', optional: true }], + returns: 'void', + }; + perform() {} +} + +class TestAttack extends Action { + readonly description: string; + readonly meta: AbilityMeta = { + params: [{ name: 'direction', type: 'Direction', optional: true }], + returns: 'void', + }; + constructor(unit: any, { power }: { power: number }) { + super(unit); + this.description = `Attacks a unit in the given direction (\`'forward'\` by default), dealing ${power} HP of damage.`; + } + perform() {} + static with(config: { power: number }) { + return [TestAttack, config] as [new (unit: any, config: any) => TestAttack, object]; + } +} + +class TestFeel extends Sense { + readonly description = + "Returns the adjacent space in the given direction (`'forward'` by default)."; + readonly meta: AbilityMeta = { + params: [{ name: 'direction', type: 'Direction', optional: true }], + returns: 'Space', + }; + perform() {} +} const levelConfig = { number: 2, @@ -10,29 +47,19 @@ const levelConfig = { tip: "Use `warrior.feel().isEmpty()` to see if there's anything in front of you, and `warrior.attack()` to fight it. Remember, you can only do one action per turn.", clue: 'Add an if/else condition using `warrior.feel().isEmpty()` to decide whether to attack or walk.', floor: { - size: { - width: 8, - height: 1, - }, - stairs: { - x: 7, - y: 0, - }, + size: { width: 8, height: 1 }, + stairs: { x: 7, y: 0 }, warrior: { name: 'Joe', character: '@', color: '#8fbcbb', maxHealth: 20, abilities: { - walk: Walk, - attack: Attack.with({ power: 5 }), - feel: Feel, - }, - position: { - x: 0, - y: 0, - facing: EAST, + walk: TestWalk, + attack: TestAttack.with({ power: 5 }), + feel: TestFeel, }, + position: { x: 0, y: 0, facing: EAST }, }, units: [ { @@ -41,8 +68,8 @@ const levelConfig = { color: '#d08770', maxHealth: 12, abilities: { - attack: Attack.with({ power: 3 }), - feel: Feel, + attack: TestAttack.with({ power: 3 }), + feel: TestFeel, }, playTurn(sludge: any) { const playerDirection = RELATIVE_DIRECTIONS.find((direction) => { @@ -53,11 +80,7 @@ const levelConfig = { sludge.attack(playerDirection); } }, - position: { - x: 4, - y: 0, - facing: WEST, - }, + position: { x: 4, y: 0, facing: WEST }, }, ], }, @@ -86,22 +109,14 @@ test('returns level', () => { { character: '\u2551' }, { character: '@', - unit: { - name: 'Joe', - color: '#8fbcbb', - maxHealth: 20, - }, + unit: { name: 'Joe', color: '#8fbcbb', maxHealth: 20 }, }, { character: ' ' }, { character: ' ' }, { character: ' ' }, { character: 's', - unit: { - name: 'Sludge', - color: '#d08770', - maxHealth: 12, - }, + unit: { name: 'Sludge', color: '#d08770', maxHealth: 12 }, }, { character: ' ' }, { character: ' ' }, @@ -121,10 +136,7 @@ test('returns level', () => { { character: '\u255d' }, ], ], - warriorStatus: { - health: 20, - score: 0, - }, + warriorStatus: { health: 20, score: 0 }, warriorAbilities: { actions: [ { diff --git a/libs/core/src/index.ts b/libs/core/src/index.ts index 1c9134ce..5fb39467 100644 --- a/libs/core/src/index.ts +++ b/libs/core/src/index.ts @@ -1,7 +1,11 @@ +export type { AbilityBinding, AbilityMeta, AbilityParam } from './Ability.js'; +export { default as Ability } from './Ability.js'; +export { default as Action } from './Action.js'; export { default as getLevel } from './getLevel.js'; export { default as getLevelConfig } from './getLevelConfig.js'; export type { TurnEvent } from './Logger.js'; export { default as runLevel } from './runLevel.js'; +export { default as Sense } from './Sense.js'; export type { LevelConfig, TowerDefinition, diff --git a/libs/core/src/loadLevel.ts b/libs/core/src/loadLevel.ts index 208affbb..7fcd0bab 100644 --- a/libs/core/src/loadLevel.ts +++ b/libs/core/src/loadLevel.ts @@ -1,5 +1,5 @@ -import type { AbilityBinding, Ability as AbilityInstance } from '@warriorjs/abilities'; - +import type Ability from './Ability.js'; +import type { AbilityBinding } from './Ability.js'; import Floor from './Floor.js'; import Level from './Level.js'; import loadPlayer from './loadPlayer.js'; @@ -7,7 +7,7 @@ import type { LevelConfig, TowerUnitEntry, UnitConfig } from './types.js'; import Unit from './Unit.js'; import Warrior from './Warrior.js'; -type AbilityEntry = AbilityBinding | (new (unit: any) => AbilityInstance) | ((unit: Unit) => any); +type AbilityEntry = AbilityBinding | (new (unit: any) => Ability) | ((unit: Unit) => any); function loadAbilities(unit: Unit, abilities: Record = {}): void { for (const [name, entry] of Object.entries(abilities)) { @@ -17,7 +17,7 @@ function loadAbilities(unit: Unit, abilities: Record = {}) unit.addAbility(name, new AbilityClass(unit, config)); } else if (typeof entry === 'function' && entry.prototype?.perform) { // Bare ability class (no config) - unit.addAbility(name, new (entry as new (unit: any) => AbilityInstance)(unit)); + unit.addAbility(name, new (entry as new (unit: any) => Ability)(unit)); } else { // Legacy factory function: (unit) => ability const ability = (entry as (unit: Unit) => any)(unit); diff --git a/libs/core/src/runLevel.test.ts b/libs/core/src/runLevel.test.ts index a0dcac05..767d9316 100644 --- a/libs/core/src/runLevel.test.ts +++ b/libs/core/src/runLevel.test.ts @@ -1,33 +1,80 @@ -import { Attack, Feel, Walk } from '@warriorjs/abilities'; -import { EAST, RELATIVE_DIRECTIONS, WEST } from '@warriorjs/spatial'; +import { BACKWARD, EAST, FORWARD, RELATIVE_DIRECTIONS, WEST } from '@warriorjs/spatial'; import { expect, test } from 'vitest'; +import type { AbilityMeta } from './Ability.js'; +import Action from './Action.js'; import runLevel from './runLevel.js'; +import Sense from './Sense.js'; + +class TestWalk extends Action { + readonly description = 'Walks forward'; + readonly meta: AbilityMeta = { + params: [{ name: 'direction', type: 'Direction', optional: true }], + returns: 'void', + }; + perform(direction = FORWARD) { + const space = this.unit.getSpaceAt(direction); + if (space.isEmpty()) { + this.unit.move(direction); + this.unit.log(`walks ${direction}`); + } else { + this.unit.log(`walks ${direction} and bumps into ${space}`); + } + } +} + +class TestAttack extends Action { + readonly description: string; + readonly meta: AbilityMeta = { + params: [{ name: 'direction', type: 'Direction', optional: true }], + returns: 'void', + }; + private power: number; + constructor(unit: any, { power }: { power: number }) { + super(unit); + this.description = `Attacks dealing ${power} HP`; + this.power = power; + } + perform(direction = FORWARD) { + const receiver = this.unit.getSpaceAt(direction).getUnit(); + if (receiver) { + this.unit.log(`attacks ${direction} and hits ${receiver}`); + const amount = direction === BACKWARD ? Math.ceil(this.power / 2.0) : this.power; + this.unit.damage(receiver, amount); + } else { + this.unit.log(`attacks ${direction} and hits nothing`); + } + } + static with(config: { power: number }) { + return [TestAttack, config] as [new (unit: any, config: any) => TestAttack, object]; + } +} + +class TestFeel extends Sense { + readonly description = 'Feels ahead'; + readonly meta: AbilityMeta = { + params: [{ name: 'direction', type: 'Direction', optional: true }], + returns: 'Space', + }; + perform(direction = FORWARD) { + return this.unit.getSensedSpaceAt(direction); + } +} const levelConfig = { floor: { - size: { - width: 8, - height: 1, - }, - stairs: { - x: 7, - y: 0, - }, + size: { width: 8, height: 1 }, + stairs: { x: 7, y: 0 }, warrior: { name: 'Joe', character: '@', maxHealth: 20, abilities: { - walk: Walk, - attack: Attack.with({ power: 5 }), - feel: Feel, - }, - position: { - x: 0, - y: 0, - facing: EAST, + walk: TestWalk, + attack: TestAttack.with({ power: 5 }), + feel: TestFeel, }, + position: { x: 0, y: 0, facing: EAST }, }, units: [ { @@ -35,8 +82,8 @@ const levelConfig = { character: 's', maxHealth: 12, abilities: { - attack: Attack.with({ power: 3 }), - feel: Feel, + attack: TestAttack.with({ power: 3 }), + feel: TestFeel, }, playTurn(sludge: any) { const threatDirection = RELATIVE_DIRECTIONS.find((direction) => { @@ -47,11 +94,7 @@ const levelConfig = { sludge.attack(threatDirection); } }, - position: { - x: 4, - y: 0, - facing: WEST, - }, + position: { x: 4, y: 0, facing: WEST }, }, ], }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9352309b..726699dd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -88,15 +88,15 @@ importers: libs/abilities: dependencies: + '@warriorjs/core': + specifier: workspace:^ + version: link:../core '@warriorjs/spatial': specifier: workspace:^ version: link:../spatial libs/core: dependencies: - '@warriorjs/abilities': - specifier: workspace:^ - version: link:../abilities '@warriorjs/spatial': specifier: workspace:^ version: link:../spatial From c55e90d389347035a7eb792072af78528d8eeebe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Olivera?= Date: Tue, 17 Mar 2026 11:55:18 -0300 Subject: [PATCH 15/39] fix(core): add missing color fields in runLevel test config Co-Authored-By: Claude Opus 4.6 (1M context) --- libs/core/src/runLevel.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/libs/core/src/runLevel.test.ts b/libs/core/src/runLevel.test.ts index 767d9316..96b0b1e7 100644 --- a/libs/core/src/runLevel.test.ts +++ b/libs/core/src/runLevel.test.ts @@ -68,6 +68,7 @@ const levelConfig = { warrior: { name: 'Joe', character: '@', + color: '#8fbcbb', maxHealth: 20, abilities: { walk: TestWalk, @@ -80,6 +81,7 @@ const levelConfig = { { name: 'Sludge', character: 's', + color: '#d08770', maxHealth: 12, abilities: { attack: TestAttack.with({ power: 3 }), From aad8da9c489d457511a89815c25d136673efee20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Olivera?= Date: Tue, 17 Mar 2026 12:38:02 -0300 Subject: [PATCH 16/39] refactor(core): reuse Ability type from Ability.ts in Unit Replace the local Ability interface in Unit.ts with an import of the Ability class from core, removing the duplicate definition. Co-Authored-By: Claude Opus 4.6 (1M context) --- libs/core/src/Unit.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/libs/core/src/Unit.ts b/libs/core/src/Unit.ts index 269cb557..ea352512 100644 --- a/libs/core/src/Unit.ts +++ b/libs/core/src/Unit.ts @@ -1,14 +1,10 @@ +import type Ability from './Ability.js'; import Action from './Action.js'; import Logger from './Logger.js'; import type Position from './Position.js'; import type { SensedSpace, SensedUnit } from './Space.js'; import Space from './Space.js'; -interface Ability { - description?: string; - perform(...args: any[]): any; -} - interface Effect { passTurn(): void; trigger(): void; From 0728e05151b2cc9f9020956ecd5a22f2a694ae0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Olivera?= Date: Tue, 17 Mar 2026 12:43:09 -0300 Subject: [PATCH 17/39] refactor(effects): convert ticking effect to class-based architecture Add Effect base class to core with abstract passTurn/trigger methods. Convert ticking from a curried factory to a Ticking class extending Effect with static .with() for config. Update loadEffects to handle both class bindings and legacy factories. Remove local Effect interface from Unit.ts. Co-Authored-By: Claude Opus 4.6 (1M context) --- libs/core/src/Effect.ts | 14 ++++++ libs/core/src/Unit.ts | 6 +-- libs/core/src/index.ts | 1 + libs/core/src/loadLevel.ts | 15 +++++-- libs/effects/package.json | 3 ++ libs/effects/src/index.ts | 3 +- libs/effects/src/ticking.test.ts | 20 +++++++-- libs/effects/src/ticking.ts | 68 +++++++++++++++-------------- pnpm-lock.yaml | 6 ++- towers/the-powder-keep/src/index.ts | 10 ++--- 10 files changed, 93 insertions(+), 53 deletions(-) create mode 100644 libs/core/src/Effect.ts diff --git a/libs/core/src/Effect.ts b/libs/core/src/Effect.ts new file mode 100644 index 00000000..494c9b9a --- /dev/null +++ b/libs/core/src/Effect.ts @@ -0,0 +1,14 @@ +abstract class Effect { + protected unit: any; + + abstract readonly description: string; + + constructor(unit: any, _config?: Record) { + this.unit = unit; + } + + abstract passTurn(): void; + abstract trigger(): void; +} + +export default Effect; diff --git a/libs/core/src/Unit.ts b/libs/core/src/Unit.ts index ea352512..c2b3d5a7 100644 --- a/libs/core/src/Unit.ts +++ b/libs/core/src/Unit.ts @@ -1,15 +1,11 @@ import type Ability from './Ability.js'; import Action from './Action.js'; +import type Effect from './Effect.js'; import Logger from './Logger.js'; import type Position from './Position.js'; import type { SensedSpace, SensedUnit } from './Space.js'; import Space from './Space.js'; -interface Effect { - passTurn(): void; - trigger(): void; -} - interface Turn { action: [string, any[]] | null; [key: string]: any; diff --git a/libs/core/src/index.ts b/libs/core/src/index.ts index 5fb39467..c7c0b700 100644 --- a/libs/core/src/index.ts +++ b/libs/core/src/index.ts @@ -1,6 +1,7 @@ export type { AbilityBinding, AbilityMeta, AbilityParam } from './Ability.js'; export { default as Ability } from './Ability.js'; export { default as Action } from './Action.js'; +export { default as Effect } from './Effect.js'; export { default as getLevel } from './getLevel.js'; export { default as getLevelConfig } from './getLevelConfig.js'; export type { TurnEvent } from './Logger.js'; diff --git a/libs/core/src/loadLevel.ts b/libs/core/src/loadLevel.ts index 7fcd0bab..83c98f06 100644 --- a/libs/core/src/loadLevel.ts +++ b/libs/core/src/loadLevel.ts @@ -26,10 +26,17 @@ function loadAbilities(unit: Unit, abilities: Record = {}) } } -function loadEffects(unit: Unit, effects: Record any> = {}): void { - for (const [effectName, effectCreator] of Object.entries(effects)) { - const effect = effectCreator(unit); - unit.addEffect(effectName, effect); +function loadEffects(unit: Unit, effects: Record = {}): void { + for (const [name, entry] of Object.entries(effects)) { + if (Array.isArray(entry)) { + const [EffectClass, config] = entry; + unit.addEffect(name, new EffectClass(unit, config)); + } else if (typeof entry === 'function' && entry.prototype?.passTurn) { + unit.addEffect(name, new entry(unit)); + } else { + const effect = entry(unit); + unit.addEffect(name, effect); + } } } diff --git a/libs/effects/package.json b/libs/effects/package.json index cc1d9a4d..440eef4b 100644 --- a/libs/effects/package.json +++ b/libs/effects/package.json @@ -25,5 +25,8 @@ }, "scripts": { "build": "tsc -p tsconfig.json" + }, + "dependencies": { + "@warriorjs/core": "workspace:^" } } diff --git a/libs/effects/src/index.ts b/libs/effects/src/index.ts index f6047f19..e4f218c6 100644 --- a/libs/effects/src/index.ts +++ b/libs/effects/src/index.ts @@ -1 +1,2 @@ -export { default as ticking } from './ticking.js'; // eslint-disable-line import/prefer-default-export +export { Effect } from '@warriorjs/core'; +export { default as Ticking } from './ticking.js'; diff --git a/libs/effects/src/ticking.test.ts b/libs/effects/src/ticking.test.ts index 7bf6a31b..d4cad087 100644 --- a/libs/effects/src/ticking.test.ts +++ b/libs/effects/src/ticking.test.ts @@ -1,8 +1,10 @@ +import { Effect } from '@warriorjs/core'; import { beforeEach, describe, expect, test, vi } from 'vitest'; -import tickingCreator from './ticking.js'; -describe('ticking', () => { - let ticking: ReturnType>; +import Ticking from './ticking.js'; + +describe('Ticking', () => { + let ticking: Ticking; let unit: { health: number; takeDamage: ReturnType; @@ -16,13 +18,23 @@ describe('ticking', () => { takeDamage: vi.fn(), log: vi.fn(), }; - ticking = tickingCreator({ time: 3 })(unit as never); + ticking = new Ticking(unit, { time: 3 }); + }); + + test('extends Effect', () => { + expect(ticking).toBeInstanceOf(Effect); }); test('has a description', () => { expect(ticking.description).toBe('Kills you and all surrounding units when time reaches zero.'); }); + test('.with() returns a binding', () => { + const binding = Ticking.with({ time: 5 }); + expect(binding[0]).toBe(Ticking); + expect(binding[1]).toEqual({ time: 5 }); + }); + describe('passing turn', () => { test('counts down bomb timer once', () => { ticking.passTurn(); diff --git a/libs/effects/src/ticking.ts b/libs/effects/src/ticking.ts index 9e0183cb..fac822e6 100644 --- a/libs/effects/src/ticking.ts +++ b/libs/effects/src/ticking.ts @@ -1,39 +1,41 @@ -interface Unit { - health: number; - takeDamage(amount: number): void; - log(message: string): void; - getOtherUnits(): Unit[]; -} +import { Effect } from '@warriorjs/core'; -interface TickingEffect { +interface TickingConfig { time: number; - description: string; - passTurn(): void; - trigger(): void; } -function ticking({ time }: { time: number }): (unit: Unit) => TickingEffect { - return (unit: Unit): TickingEffect => ({ - time, - description: 'Kills you and all surrounding units when time reaches zero.', - passTurn() { - if (this.time) { - this.time -= 1; - } - - unit.log('is ticking'); - - if (!this.time) { - this.trigger(); - } - }, - trigger() { - unit.log('explodes, collapsing the ceiling and killing every unit'); - [...unit.getOtherUnits(), unit].forEach((anotherUnit: Unit) => - anotherUnit.takeDamage(anotherUnit.health), - ); - }, - }); +class Ticking extends Effect { + readonly description = 'Kills you and all surrounding units when time reaches zero.'; + + time: number; + + constructor(unit: any, { time }: TickingConfig) { + super(unit); + this.time = time; + } + + passTurn(): void { + if (this.time) { + this.time -= 1; + } + + this.unit.log('is ticking'); + + if (!this.time) { + this.trigger(); + } + } + + trigger(): void { + this.unit.log('explodes, collapsing the ceiling and killing every unit'); + [...this.unit.getOtherUnits(), this.unit].forEach((anotherUnit: any) => + anotherUnit.takeDamage(anotherUnit.health), + ); + } + + static with(config: TickingConfig) { + return [Ticking, config] as [new (unit: any, config: any) => Ticking, object]; + } } -export default ticking; +export default Ticking; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 726699dd..d26049d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,7 +104,11 @@ importers: specifier: ^0.27.3 version: 0.27.3 - libs/effects: {} + libs/effects: + dependencies: + '@warriorjs/core': + specifier: workspace:^ + version: link:../core libs/scoring: {} diff --git a/towers/the-powder-keep/src/index.ts b/towers/the-powder-keep/src/index.ts index b62a829a..9a402979 100644 --- a/towers/the-powder-keep/src/index.ts +++ b/towers/the-powder-keep/src/index.ts @@ -16,7 +16,7 @@ import { Walk, } from '@warriorjs/abilities'; import type { TowerDefinition } from '@warriorjs/core'; -import { ticking } from '@warriorjs/effects'; +import { Ticking } from '@warriorjs/effects'; import { EAST, NORTH, SOUTH, WEST } from '@warriorjs/spatial'; import { Captive, Sludge, ThickSludge, Warrior } from '@warriorjs/units'; @@ -353,7 +353,7 @@ const tower: TowerDefinition = { { unit: new Captive(), effects: { - ticking: ticking({ time: 7 }), + ticking: Ticking.with({ time: 7 }), }, position: { x: 4, @@ -416,7 +416,7 @@ const tower: TowerDefinition = { { unit: new Captive(), effects: { - ticking: ticking({ time: 10 }), + ticking: Ticking.with({ time: 10 }), }, position: { x: 4, @@ -467,7 +467,7 @@ const tower: TowerDefinition = { { unit: new Captive(), effects: { - ticking: ticking({ time: 9 }), + ticking: Ticking.with({ time: 9 }), }, position: { x: 5, @@ -525,7 +525,7 @@ const tower: TowerDefinition = { { unit: new Captive(), effects: { - ticking: ticking({ time: 20 }), + ticking: Ticking.with({ time: 20 }), }, position: { x: 2, From 4fd3c5ba8e2629bb4ddd9009172445526451088e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Olivera?= Date: Tue, 17 Mar 2026 12:49:42 -0300 Subject: [PATCH 18/39] feat(core): add EffectBinding type Add EffectBinding type to core's Effect.ts mirroring AbilityBinding, and export from both core and effects packages. Co-Authored-By: Claude Opus 4.6 (1M context) --- libs/core/src/Effect.ts | 2 + libs/core/src/index.ts | 1 + libs/effects/src/ticking.test.ts | 77 -------------------------------- libs/effects/src/ticking.ts | 41 ----------------- 4 files changed, 3 insertions(+), 118 deletions(-) delete mode 100644 libs/effects/src/ticking.test.ts delete mode 100644 libs/effects/src/ticking.ts diff --git a/libs/core/src/Effect.ts b/libs/core/src/Effect.ts index 494c9b9a..e640afe5 100644 --- a/libs/core/src/Effect.ts +++ b/libs/core/src/Effect.ts @@ -1,3 +1,5 @@ +export type EffectBinding = [new (unit: any, config: any) => Effect, object]; + abstract class Effect { protected unit: any; diff --git a/libs/core/src/index.ts b/libs/core/src/index.ts index c7c0b700..bddf1e95 100644 --- a/libs/core/src/index.ts +++ b/libs/core/src/index.ts @@ -1,6 +1,7 @@ export type { AbilityBinding, AbilityMeta, AbilityParam } from './Ability.js'; export { default as Ability } from './Ability.js'; export { default as Action } from './Action.js'; +export type { EffectBinding } from './Effect.js'; export { default as Effect } from './Effect.js'; export { default as getLevel } from './getLevel.js'; export { default as getLevelConfig } from './getLevelConfig.js'; diff --git a/libs/effects/src/ticking.test.ts b/libs/effects/src/ticking.test.ts deleted file mode 100644 index d4cad087..00000000 --- a/libs/effects/src/ticking.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { Effect } from '@warriorjs/core'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; - -import Ticking from './ticking.js'; - -describe('Ticking', () => { - let ticking: Ticking; - let unit: { - health: number; - takeDamage: ReturnType; - log: ReturnType; - getOtherUnits?: () => { health: number; takeDamage: ReturnType }[]; - }; - - beforeEach(() => { - unit = { - health: 20, - takeDamage: vi.fn(), - log: vi.fn(), - }; - ticking = new Ticking(unit, { time: 3 }); - }); - - test('extends Effect', () => { - expect(ticking).toBeInstanceOf(Effect); - }); - - test('has a description', () => { - expect(ticking.description).toBe('Kills you and all surrounding units when time reaches zero.'); - }); - - test('.with() returns a binding', () => { - const binding = Ticking.with({ time: 5 }); - expect(binding[0]).toBe(Ticking); - expect(binding[1]).toEqual({ time: 5 }); - }); - - describe('passing turn', () => { - test('counts down bomb timer once', () => { - ticking.passTurn(); - expect(ticking.time).toBe(2); - expect(unit.log).toHaveBeenCalledWith('is ticking'); - }); - - test("doesn't count down bomb timer below zero", () => { - ticking.trigger = () => {}; - ticking.time = 0; - ticking.passTurn(); - expect(ticking.time).toBe(0); - }); - - test('triggers when bomb time reaches zero', () => { - ticking.trigger = vi.fn(); - ticking.time = 2; - ticking.passTurn(); - expect(ticking.trigger).not.toHaveBeenCalled(); - ticking.passTurn(); - expect(ticking.trigger).toHaveBeenCalled(); - }); - }); - - describe('triggering', () => { - test('kills each unit on the floor', () => { - const anotherUnit = { - health: 10, - takeDamage: vi.fn(), - }; - unit.getOtherUnits = () => [anotherUnit as never]; - ticking.trigger(); - expect(unit.log).toHaveBeenCalledWith( - 'explodes, collapsing the ceiling and killing every unit', - ); - expect(anotherUnit.takeDamage).toHaveBeenCalledWith(10); - expect(unit.takeDamage).toHaveBeenCalledWith(20); - }); - }); -}); diff --git a/libs/effects/src/ticking.ts b/libs/effects/src/ticking.ts deleted file mode 100644 index fac822e6..00000000 --- a/libs/effects/src/ticking.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Effect } from '@warriorjs/core'; - -interface TickingConfig { - time: number; -} - -class Ticking extends Effect { - readonly description = 'Kills you and all surrounding units when time reaches zero.'; - - time: number; - - constructor(unit: any, { time }: TickingConfig) { - super(unit); - this.time = time; - } - - passTurn(): void { - if (this.time) { - this.time -= 1; - } - - this.unit.log('is ticking'); - - if (!this.time) { - this.trigger(); - } - } - - trigger(): void { - this.unit.log('explodes, collapsing the ceiling and killing every unit'); - [...this.unit.getOtherUnits(), this.unit].forEach((anotherUnit: any) => - anotherUnit.takeDamage(anotherUnit.health), - ); - } - - static with(config: TickingConfig) { - return [Ticking, config] as [new (unit: any, config: any) => Ticking, object]; - } -} - -export default Ticking; From 37b5bbbe3ee2804e5d50334b90eb2374b7e67767 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Olivera?= Date: Tue, 17 Mar 2026 12:50:13 -0300 Subject: [PATCH 19/39] refactor(effects): capitalize Ticking filename Rename ticking.ts to Ticking.ts to match the class name, consistent with the abilities package convention. Co-Authored-By: Claude Opus 4.6 (1M context) --- libs/effects/src/Ticking.test.ts | 77 ++++++++++++++++++++++++++++++++ libs/effects/src/Ticking.ts | 41 +++++++++++++++++ libs/effects/src/index.ts | 3 +- 3 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 libs/effects/src/Ticking.test.ts create mode 100644 libs/effects/src/Ticking.ts diff --git a/libs/effects/src/Ticking.test.ts b/libs/effects/src/Ticking.test.ts new file mode 100644 index 00000000..d4cad087 --- /dev/null +++ b/libs/effects/src/Ticking.test.ts @@ -0,0 +1,77 @@ +import { Effect } from '@warriorjs/core'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import Ticking from './ticking.js'; + +describe('Ticking', () => { + let ticking: Ticking; + let unit: { + health: number; + takeDamage: ReturnType; + log: ReturnType; + getOtherUnits?: () => { health: number; takeDamage: ReturnType }[]; + }; + + beforeEach(() => { + unit = { + health: 20, + takeDamage: vi.fn(), + log: vi.fn(), + }; + ticking = new Ticking(unit, { time: 3 }); + }); + + test('extends Effect', () => { + expect(ticking).toBeInstanceOf(Effect); + }); + + test('has a description', () => { + expect(ticking.description).toBe('Kills you and all surrounding units when time reaches zero.'); + }); + + test('.with() returns a binding', () => { + const binding = Ticking.with({ time: 5 }); + expect(binding[0]).toBe(Ticking); + expect(binding[1]).toEqual({ time: 5 }); + }); + + describe('passing turn', () => { + test('counts down bomb timer once', () => { + ticking.passTurn(); + expect(ticking.time).toBe(2); + expect(unit.log).toHaveBeenCalledWith('is ticking'); + }); + + test("doesn't count down bomb timer below zero", () => { + ticking.trigger = () => {}; + ticking.time = 0; + ticking.passTurn(); + expect(ticking.time).toBe(0); + }); + + test('triggers when bomb time reaches zero', () => { + ticking.trigger = vi.fn(); + ticking.time = 2; + ticking.passTurn(); + expect(ticking.trigger).not.toHaveBeenCalled(); + ticking.passTurn(); + expect(ticking.trigger).toHaveBeenCalled(); + }); + }); + + describe('triggering', () => { + test('kills each unit on the floor', () => { + const anotherUnit = { + health: 10, + takeDamage: vi.fn(), + }; + unit.getOtherUnits = () => [anotherUnit as never]; + ticking.trigger(); + expect(unit.log).toHaveBeenCalledWith( + 'explodes, collapsing the ceiling and killing every unit', + ); + expect(anotherUnit.takeDamage).toHaveBeenCalledWith(10); + expect(unit.takeDamage).toHaveBeenCalledWith(20); + }); + }); +}); diff --git a/libs/effects/src/Ticking.ts b/libs/effects/src/Ticking.ts new file mode 100644 index 00000000..7f0bd7e5 --- /dev/null +++ b/libs/effects/src/Ticking.ts @@ -0,0 +1,41 @@ +import { Effect, type EffectBinding } from '@warriorjs/core'; + +interface TickingConfig { + time: number; +} + +class Ticking extends Effect { + readonly description = 'Kills you and all surrounding units when time reaches zero.'; + + time: number; + + constructor(unit: any, { time }: TickingConfig) { + super(unit); + this.time = time; + } + + passTurn(): void { + if (this.time) { + this.time -= 1; + } + + this.unit.log('is ticking'); + + if (!this.time) { + this.trigger(); + } + } + + trigger(): void { + this.unit.log('explodes, collapsing the ceiling and killing every unit'); + [...this.unit.getOtherUnits(), this.unit].forEach((anotherUnit: any) => + anotherUnit.takeDamage(anotherUnit.health), + ); + } + + static with(config: TickingConfig): EffectBinding { + return [Ticking, config]; + } +} + +export default Ticking; diff --git a/libs/effects/src/index.ts b/libs/effects/src/index.ts index e4f218c6..a3e79333 100644 --- a/libs/effects/src/index.ts +++ b/libs/effects/src/index.ts @@ -1,2 +1,3 @@ +export type { EffectBinding } from '@warriorjs/core'; export { Effect } from '@warriorjs/core'; -export { default as Ticking } from './ticking.js'; +export { default as Ticking } from './Ticking.js'; From 03e61eb466bd733a1da60b91832529b333c5e9d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Olivera?= Date: Tue, 17 Mar 2026 12:52:39 -0300 Subject: [PATCH 20/39] test(core): add tests for Effect base class Co-Authored-By: Claude Opus 4.6 (1M context) --- libs/core/src/Effect.test.ts | 44 ++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 libs/core/src/Effect.test.ts diff --git a/libs/core/src/Effect.test.ts b/libs/core/src/Effect.test.ts new file mode 100644 index 00000000..a2cad17a --- /dev/null +++ b/libs/core/src/Effect.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test, vi } from 'vitest'; + +import Effect from './Effect.js'; + +class TestEffect extends Effect { + readonly description = 'test effect'; + passTurn = vi.fn(); + trigger = vi.fn(); + + static with(config: { time: number }) { + return [TestEffect, config] as const; + } +} + +describe('Effect', () => { + test('stores unit reference', () => { + const unit = { log: vi.fn() }; + const effect = new TestEffect(unit); + expect(effect).toBeInstanceOf(Effect); + }); + + test('has description', () => { + const effect = new TestEffect({}); + expect(effect.description).toBe('test effect'); + }); + + test('passTurn can be called', () => { + const effect = new TestEffect({}); + effect.passTurn(); + expect(effect.passTurn).toHaveBeenCalled(); + }); + + test('trigger can be called', () => { + const effect = new TestEffect({}); + effect.trigger(); + expect(effect.trigger).toHaveBeenCalled(); + }); + + test('.with() returns an EffectBinding', () => { + const binding = TestEffect.with({ time: 5 }); + expect(binding[0]).toBe(TestEffect); + expect(binding[1]).toEqual({ time: 5 }); + }); +}); From 99a84c80525cd998edbdf41cc108537522d3777f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Olivera?= Date: Tue, 17 Mar 2026 12:53:45 -0300 Subject: [PATCH 21/39] fix(effects): fix Ticking import path in test Co-Authored-By: Claude Opus 4.6 (1M context) --- libs/effects/src/Ticking.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/effects/src/Ticking.test.ts b/libs/effects/src/Ticking.test.ts index d4cad087..ea316799 100644 --- a/libs/effects/src/Ticking.test.ts +++ b/libs/effects/src/Ticking.test.ts @@ -1,7 +1,7 @@ import { Effect } from '@warriorjs/core'; import { beforeEach, describe, expect, test, vi } from 'vitest'; -import Ticking from './ticking.js'; +import Ticking from './Ticking.js'; describe('Ticking', () => { let ticking: Ticking; From bd25f35109c7ae7a329df3c777184d4593913064 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Olivera?= Date: Tue, 17 Mar 2026 13:02:16 -0300 Subject: [PATCH 22/39] refactor: stop re-exporting core types from abilities and effects Remove re-exports of Ability, Action, Sense, AbilityBinding, AbilityMeta, Effect, and EffectBinding from @warriorjs/abilities and @warriorjs/effects. Consumers now import these directly from @warriorjs/core. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/cli/src/utils/renderTypes.test.ts | 3 +-- apps/cli/src/utils/renderTypes.ts | 3 +-- libs/abilities/src/index.ts | 2 -- libs/effects/src/index.ts | 2 -- 4 files changed, 2 insertions(+), 8 deletions(-) diff --git a/apps/cli/src/utils/renderTypes.test.ts b/apps/cli/src/utils/renderTypes.test.ts index bea8d145..8dc08c3f 100644 --- a/apps/cli/src/utils/renderTypes.test.ts +++ b/apps/cli/src/utils/renderTypes.test.ts @@ -1,5 +1,4 @@ -import type { AbilityMeta } from '@warriorjs/abilities'; -import { Action, Sense } from '@warriorjs/abilities'; +import { type AbilityMeta, Action, Sense } from '@warriorjs/core'; import { describe, expect, test } from 'vitest'; import renderTypes from './renderTypes.js'; diff --git a/apps/cli/src/utils/renderTypes.ts b/apps/cli/src/utils/renderTypes.ts index 5e856545..999f0ed8 100644 --- a/apps/cli/src/utils/renderTypes.ts +++ b/apps/cli/src/utils/renderTypes.ts @@ -1,5 +1,4 @@ -import { Action } from '@warriorjs/abilities'; -import type { LevelConfig } from '@warriorjs/core'; +import { Action, type LevelConfig } from '@warriorjs/core'; import type Profile from '../Profile.js'; interface MethodEntry { diff --git a/libs/abilities/src/index.ts b/libs/abilities/src/index.ts index 3333d08a..8ca35c55 100644 --- a/libs/abilities/src/index.ts +++ b/libs/abilities/src/index.ts @@ -1,5 +1,3 @@ -export type { AbilityBinding, AbilityMeta } from '@warriorjs/core'; -export { Ability, Action, Sense } from '@warriorjs/core'; export { default as Attack } from './Attack.js'; export { default as Bind } from './Bind.js'; export { default as Detonate } from './Detonate.js'; diff --git a/libs/effects/src/index.ts b/libs/effects/src/index.ts index a3e79333..4af8b403 100644 --- a/libs/effects/src/index.ts +++ b/libs/effects/src/index.ts @@ -1,3 +1 @@ -export type { EffectBinding } from '@warriorjs/core'; -export { Effect } from '@warriorjs/core'; export { default as Ticking } from './Ticking.js'; From fe74cd0fa2f631c7dede6d7738e097f768dec42e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Olivera?= Date: Tue, 17 Mar 2026 13:16:13 -0300 Subject: [PATCH 23/39] refactor(core): remove TowerFloorUnit and legacy unit loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace TowerFloorUnit with TowerUnitEntry and TowerWarriorEntry. Remove loadUnitFromConfig and the legacy factory paths in loadAbilities and loadEffects — units are always class instances now. Co-Authored-By: Claude Opus 4.6 (1M context) --- libs/core/src/getLevel.test.ts | 35 +++++++++--------- libs/core/src/index.ts | 2 +- libs/core/src/loadLevel.ts | 65 +++++++--------------------------- libs/core/src/runLevel.test.ts | 39 +++++++++++--------- libs/core/src/types.ts | 17 +++++---- 5 files changed, 63 insertions(+), 95 deletions(-) diff --git a/libs/core/src/getLevel.test.ts b/libs/core/src/getLevel.test.ts index 476d8029..43d2ba5a 100644 --- a/libs/core/src/getLevel.test.ts +++ b/libs/core/src/getLevel.test.ts @@ -5,6 +5,7 @@ import type { AbilityMeta } from './Ability.js'; import Action from './Action.js'; import getLevel from './getLevel.js'; import Sense from './Sense.js'; +import Unit from './Unit.js'; class TestWalk extends Action { readonly description = "Moves one space in the given direction (`'forward'` by default)."; @@ -63,23 +64,23 @@ const levelConfig = { }, units: [ { - name: 'Sludge', - character: 's', - color: '#d08770', - maxHealth: 12, - abilities: { - attack: TestAttack.with({ power: 3 }), - feel: TestFeel, - }, - playTurn(sludge: any) { - const playerDirection = RELATIVE_DIRECTIONS.find((direction) => { - const space = sludge.feel(direction); - return space.isUnit() && space.getUnit().isPlayer(); - }); - if (playerDirection) { - sludge.attack(playerDirection); - } - }, + unit: (() => { + const sludge = new Unit('Sludge', 's', '#d08770', 12); + (sludge as any).declaredAbilities = { + attack: TestAttack.with({ power: 3 }), + feel: TestFeel, + }; + sludge.playTurn = (turn: any) => { + const playerDirection = RELATIVE_DIRECTIONS.find((direction) => { + const space = turn.feel(direction); + return space.isUnit() && space.getUnit().isPlayer(); + }); + if (playerDirection) { + turn.attack(playerDirection); + } + }; + return sludge; + })(), position: { x: 4, y: 0, facing: WEST }, }, ], diff --git a/libs/core/src/index.ts b/libs/core/src/index.ts index bddf1e95..237cf2f6 100644 --- a/libs/core/src/index.ts +++ b/libs/core/src/index.ts @@ -11,9 +11,9 @@ export { default as Sense } from './Sense.js'; export type { LevelConfig, TowerDefinition, - TowerFloorUnit, TowerLevel, TowerUnitEntry, + TowerWarriorEntry, UnitConfig, } from './types.js'; export { default as Unit } from './Unit.js'; diff --git a/libs/core/src/loadLevel.ts b/libs/core/src/loadLevel.ts index 83c98f06..b6f36635 100644 --- a/libs/core/src/loadLevel.ts +++ b/libs/core/src/loadLevel.ts @@ -3,25 +3,19 @@ import type { AbilityBinding } from './Ability.js'; import Floor from './Floor.js'; import Level from './Level.js'; import loadPlayer from './loadPlayer.js'; -import type { LevelConfig, TowerUnitEntry, UnitConfig } from './types.js'; -import Unit from './Unit.js'; +import type { LevelConfig, TowerUnitEntry } from './types.js'; +import type Unit from './Unit.js'; import Warrior from './Warrior.js'; -type AbilityEntry = AbilityBinding | (new (unit: any) => Ability) | ((unit: Unit) => any); +type AbilityEntry = AbilityBinding | (new (unit: any) => Ability); function loadAbilities(unit: Unit, abilities: Record = {}): void { for (const [name, entry] of Object.entries(abilities)) { if (Array.isArray(entry)) { - // AbilityBinding: [Class, config] const [AbilityClass, config] = entry; unit.addAbility(name, new AbilityClass(unit, config)); - } else if (typeof entry === 'function' && entry.prototype?.perform) { - // Bare ability class (no config) - unit.addAbility(name, new (entry as new (unit: any) => Ability)(unit)); } else { - // Legacy factory function: (unit) => ability - const ability = (entry as (unit: Unit) => any)(unit); - unit.addAbility(name, ability); + unit.addAbility(name, new (entry as new (unit: any) => Ability)(unit)); } } } @@ -31,57 +25,26 @@ function loadEffects(unit: Unit, effects: Record = {}): void { if (Array.isArray(entry)) { const [EffectClass, config] = entry; unit.addEffect(name, new EffectClass(unit, config)); - } else if (typeof entry === 'function' && entry.prototype?.passTurn) { - unit.addEffect(name, new entry(unit)); } else { - const effect = entry(unit); - unit.addEffect(name, effect); + unit.addEffect(name, new entry(unit)); } } } function loadWarrior( - { name, character, color, maxHealth, abilities, effects, position }: UnitConfig, + warrior: LevelConfig['floor']['warrior'], floor: Floor, playerCode?: string, language: 'javascript' | 'typescript' = 'javascript', ): void { - const warrior = new Warrior(name, character, color, maxHealth); - loadAbilities(warrior, abilities); - loadEffects(warrior, effects); - warrior.playTurn = playerCode ? loadPlayer(playerCode, language) : () => {}; - floor.addWarrior(warrior, position); -} - -function isTowerUnitEntry(entry: UnitConfig | TowerUnitEntry): entry is TowerUnitEntry { - return 'unit' in entry && entry.unit instanceof Unit; -} - -function loadUnitFromConfig(config: UnitConfig, floor: Floor): void { - const { - name, - character, - color, - maxHealth, - reward, - enemy, - bound, - abilities, - effects, - playTurn, - position, - } = config; - const unit = new Unit(name, character, color, maxHealth, reward, enemy, bound); + const { name, character, color, maxHealth, abilities, position } = warrior; + const unit = new Warrior(name, character, color, maxHealth); loadAbilities(unit, abilities); - loadEffects(unit, effects); - if (playTurn) { - unit.playTurn = playTurn; - } - floor.addUnit(unit, position); + unit.playTurn = playerCode ? loadPlayer(playerCode, language) : () => {}; + floor.addWarrior(unit, position); } -function loadUnitFromInstance(entry: TowerUnitEntry, floor: Floor): void { - const { unit, effects, position } = entry; +function loadUnit({ unit, effects, position }: TowerUnitEntry, floor: Floor): void { const declaredAbilities = (unit as any).declaredAbilities; if (declaredAbilities) { loadAbilities(unit, declaredAbilities); @@ -103,11 +66,7 @@ function loadLevel( loadWarrior(warrior, floor, playerCode, language); for (const entry of units) { - if (isTowerUnitEntry(entry)) { - loadUnitFromInstance(entry, floor); - } else { - loadUnitFromConfig(entry, floor); - } + loadUnit(entry as TowerUnitEntry, floor); } return new Level(number!, description!, tip!, clue!, floor); diff --git a/libs/core/src/runLevel.test.ts b/libs/core/src/runLevel.test.ts index 96b0b1e7..8d88d216 100644 --- a/libs/core/src/runLevel.test.ts +++ b/libs/core/src/runLevel.test.ts @@ -5,6 +5,7 @@ import type { AbilityMeta } from './Ability.js'; import Action from './Action.js'; import runLevel from './runLevel.js'; import Sense from './Sense.js'; +import Unit from './Unit.js'; class TestWalk extends Action { readonly description = 'Walks forward'; @@ -61,6 +62,26 @@ class TestFeel extends Sense { } } +class TestSludge extends Unit { + declaredAbilities = { + attack: TestAttack.with({ power: 3 }), + feel: TestFeel, + }; + + constructor() { + super('Sludge', 's', '#d08770', 12); + this.playTurn = (turn: any) => { + const threatDirection = RELATIVE_DIRECTIONS.find((direction) => { + const unit = turn.feel(direction).getUnit(); + return unit?.isEnemy() && !unit.isBound(); + }); + if (threatDirection) { + turn.attack(threatDirection); + } + }; + } +} + const levelConfig = { floor: { size: { width: 8, height: 1 }, @@ -79,23 +100,7 @@ const levelConfig = { }, units: [ { - name: 'Sludge', - character: 's', - color: '#d08770', - maxHealth: 12, - abilities: { - attack: TestAttack.with({ power: 3 }), - feel: TestFeel, - }, - playTurn(sludge: any) { - const threatDirection = RELATIVE_DIRECTIONS.find((direction) => { - const unit = sludge.feel(direction).getUnit(); - return unit?.isEnemy() && !unit.isBound(); - }); - if (threatDirection) { - sludge.attack(threatDirection); - } - }, + unit: new TestSludge(), position: { x: 4, y: 0, facing: WEST }, }, ], diff --git a/libs/core/src/types.ts b/libs/core/src/types.ts index b23ab3c5..bac6dbf8 100644 --- a/libs/core/src/types.ts +++ b/libs/core/src/types.ts @@ -9,7 +9,7 @@ export interface UnitConfig { enemy?: boolean; bound?: boolean; abilities?: Record; - effects?: Record any>; + effects?: Record; playTurn?: (turn: any) => void; position: { x: number; y: number; facing: string }; } @@ -31,13 +31,16 @@ export interface LevelConfig { export interface TowerUnitEntry { unit: Unit; - effects?: Record any>; + effects?: Record; position: { x: number; y: number; facing: string }; } -export interface TowerFloorUnit { - [key: string]: unknown; - position: { x: number; y: number; facing?: string }; +export interface TowerWarriorEntry { + character: string; + color: string; + maxHealth: number; + abilities?: Record; + position: { x: number; y: number; facing: string }; } export interface TowerLevel { @@ -49,8 +52,8 @@ export interface TowerLevel { floor: { size: { width: number; height: number }; stairs: { x: number; y: number }; - warrior: TowerFloorUnit; - units: (TowerFloorUnit | TowerUnitEntry)[]; + warrior: TowerWarriorEntry; + units: TowerUnitEntry[]; }; } From 921fa1a1a42f0a51e8843251a050d65beceb5d9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Olivera?= Date: Tue, 17 Mar 2026 13:27:12 -0300 Subject: [PATCH 24/39] refactor: pass unit classes instead of instances in tower configs Tower configs now declare unit classes (e.g. unit: Sludge) instead of instances (unit: new Sludge()). The engine instantiates them at level load time, consistent with how ability classes are already handled. Co-Authored-By: Claude Opus 4.6 (1M context) --- libs/core/src/getLevel.test.ts | 38 ++++++++------- libs/core/src/loadLevel.ts | 3 +- libs/core/src/runLevel.test.ts | 2 +- libs/core/src/types.ts | 2 +- towers/the-narrow-path/src/index.ts | 54 +++++++++++----------- towers/the-powder-keep/src/index.ts | 72 ++++++++++++++--------------- 6 files changed, 88 insertions(+), 83 deletions(-) diff --git a/libs/core/src/getLevel.test.ts b/libs/core/src/getLevel.test.ts index 43d2ba5a..c88c8f17 100644 --- a/libs/core/src/getLevel.test.ts +++ b/libs/core/src/getLevel.test.ts @@ -42,6 +42,26 @@ class TestFeel extends Sense { perform() {} } +class TestSludge extends Unit { + declaredAbilities = { + attack: TestAttack.with({ power: 3 }), + feel: TestFeel, + }; + + constructor() { + super('Sludge', 's', '#d08770', 12); + this.playTurn = (turn: any) => { + const playerDirection = RELATIVE_DIRECTIONS.find((direction) => { + const space = turn.feel(direction); + return space.isUnit() && space.getUnit().isPlayer(); + }); + if (playerDirection) { + turn.attack(playerDirection); + } + }; + } +} + const levelConfig = { number: 2, description: "It's too dark to see anything, but you smell sludge nearby.", @@ -64,23 +84,7 @@ const levelConfig = { }, units: [ { - unit: (() => { - const sludge = new Unit('Sludge', 's', '#d08770', 12); - (sludge as any).declaredAbilities = { - attack: TestAttack.with({ power: 3 }), - feel: TestFeel, - }; - sludge.playTurn = (turn: any) => { - const playerDirection = RELATIVE_DIRECTIONS.find((direction) => { - const space = turn.feel(direction); - return space.isUnit() && space.getUnit().isPlayer(); - }); - if (playerDirection) { - turn.attack(playerDirection); - } - }; - return sludge; - })(), + unit: TestSludge, position: { x: 4, y: 0, facing: WEST }, }, ], diff --git a/libs/core/src/loadLevel.ts b/libs/core/src/loadLevel.ts index b6f36635..67887b0a 100644 --- a/libs/core/src/loadLevel.ts +++ b/libs/core/src/loadLevel.ts @@ -44,7 +44,8 @@ function loadWarrior( floor.addWarrior(unit, position); } -function loadUnit({ unit, effects, position }: TowerUnitEntry, floor: Floor): void { +function loadUnit({ unit: UnitClass, effects, position }: TowerUnitEntry, floor: Floor): void { + const unit = new UnitClass(); const declaredAbilities = (unit as any).declaredAbilities; if (declaredAbilities) { loadAbilities(unit, declaredAbilities); diff --git a/libs/core/src/runLevel.test.ts b/libs/core/src/runLevel.test.ts index 8d88d216..3a76d1ac 100644 --- a/libs/core/src/runLevel.test.ts +++ b/libs/core/src/runLevel.test.ts @@ -100,7 +100,7 @@ const levelConfig = { }, units: [ { - unit: new TestSludge(), + unit: TestSludge, position: { x: 4, y: 0, facing: WEST }, }, ], diff --git a/libs/core/src/types.ts b/libs/core/src/types.ts index bac6dbf8..db684c02 100644 --- a/libs/core/src/types.ts +++ b/libs/core/src/types.ts @@ -30,7 +30,7 @@ export interface LevelConfig { } export interface TowerUnitEntry { - unit: Unit; + unit: new () => Unit; effects?: Record; position: { x: number; y: number; facing: string }; } diff --git a/towers/the-narrow-path/src/index.ts b/towers/the-narrow-path/src/index.ts index d05ff22f..aee863d4 100644 --- a/towers/the-narrow-path/src/index.ts +++ b/towers/the-narrow-path/src/index.ts @@ -51,7 +51,7 @@ const tower: TowerDefinition = { abilities: { attack: Attack.with({ power: 5 }), feel: Feel }, position: { x: 0, y: 0, facing: EAST }, }, - units: [{ unit: new Sludge(), position: { x: 4, y: 0, facing: WEST } }], + units: [{ unit: Sludge, position: { x: 4, y: 0, facing: WEST } }], }, }, { @@ -70,10 +70,10 @@ const tower: TowerDefinition = { position: { x: 0, y: 0, facing: EAST }, }, units: [ - { unit: new Sludge(), position: { x: 2, y: 0, facing: WEST } }, - { unit: new Sludge(), position: { x: 4, y: 0, facing: WEST } }, - { unit: new Sludge(), position: { x: 5, y: 0, facing: WEST } }, - { unit: new Sludge(), position: { x: 7, y: 0, facing: WEST } }, + { unit: Sludge, position: { x: 2, y: 0, facing: WEST } }, + { unit: Sludge, position: { x: 4, y: 0, facing: WEST } }, + { unit: Sludge, position: { x: 5, y: 0, facing: WEST } }, + { unit: Sludge, position: { x: 7, y: 0, facing: WEST } }, ], }, }, @@ -89,9 +89,9 @@ const tower: TowerDefinition = { stairs: { x: 6, y: 0 }, warrior: { ...Warrior, position: { x: 0, y: 0, facing: EAST } }, units: [ - { unit: new ThickSludge(), position: { x: 2, y: 0, facing: WEST } }, - { unit: new Archer(), position: { x: 3, y: 0, facing: WEST } }, - { unit: new ThickSludge(), position: { x: 5, y: 0, facing: WEST } }, + { unit: ThickSludge, position: { x: 2, y: 0, facing: WEST } }, + { unit: Archer, position: { x: 3, y: 0, facing: WEST } }, + { unit: ThickSludge, position: { x: 5, y: 0, facing: WEST } }, ], }, }, @@ -110,11 +110,11 @@ const tower: TowerDefinition = { position: { x: 0, y: 0, facing: EAST }, }, units: [ - { unit: new Captive(), position: { x: 2, y: 0, facing: WEST } }, - { unit: new Archer(), position: { x: 3, y: 0, facing: WEST } }, - { unit: new Archer(), position: { x: 4, y: 0, facing: WEST } }, - { unit: new ThickSludge(), position: { x: 5, y: 0, facing: WEST } }, - { unit: new Captive(), position: { x: 6, y: 0, facing: WEST } }, + { unit: Captive, position: { x: 2, y: 0, facing: WEST } }, + { unit: Archer, position: { x: 3, y: 0, facing: WEST } }, + { unit: Archer, position: { x: 4, y: 0, facing: WEST } }, + { unit: ThickSludge, position: { x: 5, y: 0, facing: WEST } }, + { unit: Captive, position: { x: 6, y: 0, facing: WEST } }, ], }, }, @@ -130,10 +130,10 @@ const tower: TowerDefinition = { stairs: { x: 7, y: 0 }, warrior: { ...Warrior, position: { x: 2, y: 0, facing: EAST } }, units: [ - { unit: new Captive(), position: { x: 0, y: 0, facing: EAST } }, - { unit: new ThickSludge(), position: { x: 4, y: 0, facing: WEST } }, - { unit: new Archer(), position: { x: 6, y: 0, facing: WEST } }, - { unit: new Archer(), position: { x: 7, y: 0, facing: WEST } }, + { unit: Captive, position: { x: 0, y: 0, facing: EAST } }, + { unit: ThickSludge, position: { x: 4, y: 0, facing: WEST } }, + { unit: Archer, position: { x: 6, y: 0, facing: WEST } }, + { unit: Archer, position: { x: 7, y: 0, facing: WEST } }, ], }, }, @@ -152,8 +152,8 @@ const tower: TowerDefinition = { position: { x: 5, y: 0, facing: EAST }, }, units: [ - { unit: new Archer(), position: { x: 1, y: 0, facing: EAST } }, - { unit: new ThickSludge(), position: { x: 3, y: 0, facing: EAST } }, + { unit: Archer, position: { x: 1, y: 0, facing: EAST } }, + { unit: ThickSludge, position: { x: 3, y: 0, facing: EAST } }, ], }, }, @@ -173,9 +173,9 @@ const tower: TowerDefinition = { abilities: { look: Look.with({ range: 3 }), shoot: Shoot.with({ power: 3, range: 3 }) }, }, units: [ - { unit: new Captive(), position: { x: 2, y: 0, facing: WEST } }, - { unit: new Wizard(), position: { x: 3, y: 0, facing: WEST } }, - { unit: new Wizard(), position: { x: 4, y: 0, facing: WEST } }, + { unit: Captive, position: { x: 2, y: 0, facing: WEST } }, + { unit: Wizard, position: { x: 3, y: 0, facing: WEST } }, + { unit: Wizard, position: { x: 4, y: 0, facing: WEST } }, ], }, }, @@ -191,11 +191,11 @@ const tower: TowerDefinition = { stairs: { x: 0, y: 0 }, warrior: { ...Warrior, position: { x: 5, y: 0, facing: EAST } }, units: [ - { unit: new Captive(), position: { x: 1, y: 0, facing: EAST } }, - { unit: new Archer(), position: { x: 2, y: 0, facing: EAST } }, - { unit: new ThickSludge(), position: { x: 7, y: 0, facing: WEST } }, - { unit: new Wizard(), position: { x: 9, y: 0, facing: WEST } }, - { unit: new Captive(), position: { x: 10, y: 0, facing: WEST } }, + { unit: Captive, position: { x: 1, y: 0, facing: EAST } }, + { unit: Archer, position: { x: 2, y: 0, facing: EAST } }, + { unit: ThickSludge, position: { x: 7, y: 0, facing: WEST } }, + { unit: Wizard, position: { x: 9, y: 0, facing: WEST } }, + { unit: Captive, position: { x: 10, y: 0, facing: WEST } }, ], }, }, diff --git a/towers/the-powder-keep/src/index.ts b/towers/the-powder-keep/src/index.ts index 9a402979..cbf6b0ee 100644 --- a/towers/the-powder-keep/src/index.ts +++ b/towers/the-powder-keep/src/index.ts @@ -88,7 +88,7 @@ const tower: TowerDefinition = { }, units: [ { - unit: new Sludge(), + unit: Sludge, position: { x: 1, y: 0, @@ -96,7 +96,7 @@ const tower: TowerDefinition = { }, }, { - unit: new ThickSludge(), + unit: ThickSludge, position: { x: 2, y: 1, @@ -104,7 +104,7 @@ const tower: TowerDefinition = { }, }, { - unit: new Sludge(), + unit: Sludge, position: { x: 1, y: 1, @@ -143,7 +143,7 @@ const tower: TowerDefinition = { }, units: [ { - unit: new Sludge(), + unit: Sludge, position: { x: 1, y: 0, @@ -151,7 +151,7 @@ const tower: TowerDefinition = { }, }, { - unit: new Captive(), + unit: Captive, position: { x: 1, y: 2, @@ -159,7 +159,7 @@ const tower: TowerDefinition = { }, }, { - unit: new Sludge(), + unit: Sludge, position: { x: 0, y: 1, @@ -167,7 +167,7 @@ const tower: TowerDefinition = { }, }, { - unit: new Sludge(), + unit: Sludge, position: { x: 2, y: 1, @@ -207,7 +207,7 @@ const tower: TowerDefinition = { }, units: [ { - unit: new Captive(), + unit: Captive, position: { x: 0, y: 0, @@ -215,7 +215,7 @@ const tower: TowerDefinition = { }, }, { - unit: new Captive(), + unit: Captive, position: { x: 0, y: 2, @@ -223,7 +223,7 @@ const tower: TowerDefinition = { }, }, { - unit: new Sludge(), + unit: Sludge, position: { x: 2, y: 0, @@ -231,7 +231,7 @@ const tower: TowerDefinition = { }, }, { - unit: new ThickSludge(), + unit: ThickSludge, position: { x: 3, y: 1, @@ -239,7 +239,7 @@ const tower: TowerDefinition = { }, }, { - unit: new Sludge(), + unit: Sludge, position: { x: 2, y: 2, @@ -275,7 +275,7 @@ const tower: TowerDefinition = { }, units: [ { - unit: new ThickSludge(), + unit: ThickSludge, position: { x: 4, y: 0, @@ -283,7 +283,7 @@ const tower: TowerDefinition = { }, }, { - unit: new ThickSludge(), + unit: ThickSludge, position: { x: 3, y: 1, @@ -291,7 +291,7 @@ const tower: TowerDefinition = { }, }, { - unit: new Captive(), + unit: Captive, position: { x: 4, y: 1, @@ -327,7 +327,7 @@ const tower: TowerDefinition = { }, units: [ { - unit: new Sludge(), + unit: Sludge, position: { x: 1, y: 0, @@ -335,7 +335,7 @@ const tower: TowerDefinition = { }, }, { - unit: new Sludge(), + unit: Sludge, position: { x: 3, y: 1, @@ -343,7 +343,7 @@ const tower: TowerDefinition = { }, }, { - unit: new Captive(), + unit: Captive, position: { x: 0, y: 0, @@ -351,7 +351,7 @@ const tower: TowerDefinition = { }, }, { - unit: new Captive(), + unit: Captive, effects: { ticking: Ticking.with({ time: 7 }), }, @@ -390,7 +390,7 @@ const tower: TowerDefinition = { }, units: [ { - unit: new Sludge(), + unit: Sludge, position: { x: 1, y: 0, @@ -398,7 +398,7 @@ const tower: TowerDefinition = { }, }, { - unit: new Sludge(), + unit: Sludge, position: { x: 1, y: 2, @@ -406,7 +406,7 @@ const tower: TowerDefinition = { }, }, { - unit: new Captive(), + unit: Captive, position: { x: 2, y: 1, @@ -414,7 +414,7 @@ const tower: TowerDefinition = { }, }, { - unit: new Captive(), + unit: Captive, effects: { ticking: Ticking.with({ time: 10 }), }, @@ -425,7 +425,7 @@ const tower: TowerDefinition = { }, }, { - unit: new Captive(), + unit: Captive, position: { x: 2, y: 0, @@ -465,7 +465,7 @@ const tower: TowerDefinition = { }, units: [ { - unit: new Captive(), + unit: Captive, effects: { ticking: Ticking.with({ time: 9 }), }, @@ -476,7 +476,7 @@ const tower: TowerDefinition = { }, }, { - unit: new ThickSludge(), + unit: ThickSludge, position: { x: 2, y: 0, @@ -484,7 +484,7 @@ const tower: TowerDefinition = { }, }, { - unit: new Sludge(), + unit: Sludge, position: { x: 3, y: 0, @@ -523,7 +523,7 @@ const tower: TowerDefinition = { }, units: [ { - unit: new Captive(), + unit: Captive, effects: { ticking: Ticking.with({ time: 20 }), }, @@ -534,7 +534,7 @@ const tower: TowerDefinition = { }, }, { - unit: new Captive(), + unit: Captive, position: { x: 2, y: 2, @@ -542,7 +542,7 @@ const tower: TowerDefinition = { }, }, { - unit: new Sludge(), + unit: Sludge, position: { x: 0, y: 0, @@ -550,7 +550,7 @@ const tower: TowerDefinition = { }, }, { - unit: new Sludge(), + unit: Sludge, position: { x: 1, y: 0, @@ -558,7 +558,7 @@ const tower: TowerDefinition = { }, }, { - unit: new Sludge(), + unit: Sludge, position: { x: 1, y: 1, @@ -566,7 +566,7 @@ const tower: TowerDefinition = { }, }, { - unit: new Sludge(), + unit: Sludge, position: { x: 2, y: 1, @@ -574,7 +574,7 @@ const tower: TowerDefinition = { }, }, { - unit: new Sludge(), + unit: Sludge, position: { x: 3, y: 1, @@ -582,7 +582,7 @@ const tower: TowerDefinition = { }, }, { - unit: new Sludge(), + unit: Sludge, position: { x: 0, y: 2, @@ -590,7 +590,7 @@ const tower: TowerDefinition = { }, }, { - unit: new Sludge(), + unit: Sludge, position: { x: 1, y: 2, From 1180fa0d893f0ed564e0f7df66091fe34361b004 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Olivera?= Date: Tue, 17 Mar 2026 14:23:16 -0300 Subject: [PATCH 25/39] style(towers): expand Narrow Path tower config to multi-line Consistent with the Powder Keep style. Co-Authored-By: Claude Opus 4.6 (1M context) --- towers/the-narrow-path/src/index.ts | 427 ++++++++++++++++++++++++---- 1 file changed, 367 insertions(+), 60 deletions(-) diff --git a/towers/the-narrow-path/src/index.ts b/towers/the-narrow-path/src/index.ts index aee863d4..3f2f4bd0 100644 --- a/towers/the-narrow-path/src/index.ts +++ b/towers/the-narrow-path/src/index.ts @@ -26,12 +26,25 @@ const tower: TowerDefinition = { timeBonus: 15, aceScore: 10, floor: { - size: { width: 8, height: 1 }, - stairs: { x: 7, y: 0 }, + size: { + width: 8, + height: 1, + }, + stairs: { + x: 7, + y: 0, + }, warrior: { ...Warrior, - abilities: { think: Think, walk: Walk }, - position: { x: 0, y: 0, facing: EAST }, + abilities: { + think: Think, + walk: Walk, + }, + position: { + x: 0, + y: 0, + facing: EAST, + }, }, units: [], }, @@ -44,14 +57,36 @@ const tower: TowerDefinition = { timeBonus: 20, aceScore: 26, floor: { - size: { width: 8, height: 1 }, - stairs: { x: 7, y: 0 }, + size: { + width: 8, + height: 1, + }, + stairs: { + x: 7, + y: 0, + }, warrior: { ...Warrior, - abilities: { attack: Attack.with({ power: 5 }), feel: Feel }, - position: { x: 0, y: 0, facing: EAST }, + abilities: { + attack: Attack.with({ power: 5 }), + feel: Feel, + }, + position: { + x: 0, + y: 0, + facing: EAST, + }, }, - units: [{ unit: Sludge, position: { x: 4, y: 0, facing: WEST } }], + units: [ + { + unit: Sludge, + position: { + x: 4, + y: 0, + facing: WEST, + }, + }, + ], }, }, { @@ -62,18 +97,60 @@ const tower: TowerDefinition = { timeBonus: 35, aceScore: 71, floor: { - size: { width: 9, height: 1 }, - stairs: { x: 8, y: 0 }, + size: { + width: 9, + height: 1, + }, + stairs: { + x: 8, + y: 0, + }, warrior: { ...Warrior, - abilities: { health: Health, maxHealth: MaxHealth, rest: Rest.with({ healthGain: 0.1 }) }, - position: { x: 0, y: 0, facing: EAST }, + abilities: { + health: Health, + maxHealth: MaxHealth, + rest: Rest.with({ healthGain: 0.1 }), + }, + position: { + x: 0, + y: 0, + facing: EAST, + }, }, units: [ - { unit: Sludge, position: { x: 2, y: 0, facing: WEST } }, - { unit: Sludge, position: { x: 4, y: 0, facing: WEST } }, - { unit: Sludge, position: { x: 5, y: 0, facing: WEST } }, - { unit: Sludge, position: { x: 7, y: 0, facing: WEST } }, + { + unit: Sludge, + position: { + x: 2, + y: 0, + facing: WEST, + }, + }, + { + unit: Sludge, + position: { + x: 4, + y: 0, + facing: WEST, + }, + }, + { + unit: Sludge, + position: { + x: 5, + y: 0, + facing: WEST, + }, + }, + { + unit: Sludge, + position: { + x: 7, + y: 0, + facing: WEST, + }, + }, ], }, }, @@ -85,13 +162,47 @@ const tower: TowerDefinition = { timeBonus: 45, aceScore: 90, floor: { - size: { width: 7, height: 1 }, - stairs: { x: 6, y: 0 }, - warrior: { ...Warrior, position: { x: 0, y: 0, facing: EAST } }, + size: { + width: 7, + height: 1, + }, + stairs: { + x: 6, + y: 0, + }, + warrior: { + ...Warrior, + position: { + x: 0, + y: 0, + facing: EAST, + }, + }, units: [ - { unit: ThickSludge, position: { x: 2, y: 0, facing: WEST } }, - { unit: Archer, position: { x: 3, y: 0, facing: WEST } }, - { unit: ThickSludge, position: { x: 5, y: 0, facing: WEST } }, + { + unit: ThickSludge, + position: { + x: 2, + y: 0, + facing: WEST, + }, + }, + { + unit: Archer, + position: { + x: 3, + y: 0, + facing: WEST, + }, + }, + { + unit: ThickSludge, + position: { + x: 5, + y: 0, + facing: WEST, + }, + }, ], }, }, @@ -102,19 +213,66 @@ const tower: TowerDefinition = { timeBonus: 45, aceScore: 123, floor: { - size: { width: 7, height: 1 }, - stairs: { x: 6, y: 0 }, + size: { + width: 7, + height: 1, + }, + stairs: { + x: 6, + y: 0, + }, warrior: { ...Warrior, - abilities: { rescue: Rescue }, - position: { x: 0, y: 0, facing: EAST }, + abilities: { + rescue: Rescue, + }, + position: { + x: 0, + y: 0, + facing: EAST, + }, }, units: [ - { unit: Captive, position: { x: 2, y: 0, facing: WEST } }, - { unit: Archer, position: { x: 3, y: 0, facing: WEST } }, - { unit: Archer, position: { x: 4, y: 0, facing: WEST } }, - { unit: ThickSludge, position: { x: 5, y: 0, facing: WEST } }, - { unit: Captive, position: { x: 6, y: 0, facing: WEST } }, + { + unit: Captive, + position: { + x: 2, + y: 0, + facing: WEST, + }, + }, + { + unit: Archer, + position: { + x: 3, + y: 0, + facing: WEST, + }, + }, + { + unit: Archer, + position: { + x: 4, + y: 0, + facing: WEST, + }, + }, + { + unit: ThickSludge, + position: { + x: 5, + y: 0, + facing: WEST, + }, + }, + { + unit: Captive, + position: { + x: 6, + y: 0, + facing: WEST, + }, + }, ], }, }, @@ -126,14 +284,55 @@ const tower: TowerDefinition = { timeBonus: 55, aceScore: 105, floor: { - size: { width: 8, height: 1 }, - stairs: { x: 7, y: 0 }, - warrior: { ...Warrior, position: { x: 2, y: 0, facing: EAST } }, + size: { + width: 8, + height: 1, + }, + stairs: { + x: 7, + y: 0, + }, + warrior: { + ...Warrior, + position: { + x: 2, + y: 0, + facing: EAST, + }, + }, units: [ - { unit: Captive, position: { x: 0, y: 0, facing: EAST } }, - { unit: ThickSludge, position: { x: 4, y: 0, facing: WEST } }, - { unit: Archer, position: { x: 6, y: 0, facing: WEST } }, - { unit: Archer, position: { x: 7, y: 0, facing: WEST } }, + { + unit: Captive, + position: { + x: 0, + y: 0, + facing: EAST, + }, + }, + { + unit: ThickSludge, + position: { + x: 4, + y: 0, + facing: WEST, + }, + }, + { + unit: Archer, + position: { + x: 6, + y: 0, + facing: WEST, + }, + }, + { + unit: Archer, + position: { + x: 7, + y: 0, + facing: WEST, + }, + }, ], }, }, @@ -144,16 +343,42 @@ const tower: TowerDefinition = { timeBonus: 30, aceScore: 50, floor: { - size: { width: 6, height: 1 }, - stairs: { x: 0, y: 0 }, + size: { + width: 6, + height: 1, + }, + stairs: { + x: 0, + y: 0, + }, warrior: { ...Warrior, - abilities: { pivot: Pivot }, - position: { x: 5, y: 0, facing: EAST }, + abilities: { + pivot: Pivot, + }, + position: { + x: 5, + y: 0, + facing: EAST, + }, }, units: [ - { unit: Archer, position: { x: 1, y: 0, facing: EAST } }, - { unit: ThickSludge, position: { x: 3, y: 0, facing: EAST } }, + { + unit: Archer, + position: { + x: 1, + y: 0, + facing: EAST, + }, + }, + { + unit: ThickSludge, + position: { + x: 3, + y: 0, + facing: EAST, + }, + }, ], }, }, @@ -165,17 +390,51 @@ const tower: TowerDefinition = { timeBonus: 20, aceScore: 46, floor: { - size: { width: 6, height: 1 }, - stairs: { x: 5, y: 0 }, + size: { + width: 6, + height: 1, + }, + stairs: { + x: 5, + y: 0, + }, warrior: { ...Warrior, - position: { x: 0, y: 0, facing: EAST }, - abilities: { look: Look.with({ range: 3 }), shoot: Shoot.with({ power: 3, range: 3 }) }, + abilities: { + look: Look.with({ range: 3 }), + shoot: Shoot.with({ power: 3, range: 3 }), + }, + position: { + x: 0, + y: 0, + facing: EAST, + }, }, units: [ - { unit: Captive, position: { x: 2, y: 0, facing: WEST } }, - { unit: Wizard, position: { x: 3, y: 0, facing: WEST } }, - { unit: Wizard, position: { x: 4, y: 0, facing: WEST } }, + { + unit: Captive, + position: { + x: 2, + y: 0, + facing: WEST, + }, + }, + { + unit: Wizard, + position: { + x: 3, + y: 0, + facing: WEST, + }, + }, + { + unit: Wizard, + position: { + x: 4, + y: 0, + facing: WEST, + }, + }, ], }, }, @@ -187,15 +446,63 @@ const tower: TowerDefinition = { timeBonus: 40, aceScore: 100, floor: { - size: { width: 11, height: 1 }, - stairs: { x: 0, y: 0 }, - warrior: { ...Warrior, position: { x: 5, y: 0, facing: EAST } }, + size: { + width: 11, + height: 1, + }, + stairs: { + x: 0, + y: 0, + }, + warrior: { + ...Warrior, + position: { + x: 5, + y: 0, + facing: EAST, + }, + }, units: [ - { unit: Captive, position: { x: 1, y: 0, facing: EAST } }, - { unit: Archer, position: { x: 2, y: 0, facing: EAST } }, - { unit: ThickSludge, position: { x: 7, y: 0, facing: WEST } }, - { unit: Wizard, position: { x: 9, y: 0, facing: WEST } }, - { unit: Captive, position: { x: 10, y: 0, facing: WEST } }, + { + unit: Captive, + position: { + x: 1, + y: 0, + facing: EAST, + }, + }, + { + unit: Archer, + position: { + x: 2, + y: 0, + facing: EAST, + }, + }, + { + unit: ThickSludge, + position: { + x: 7, + y: 0, + facing: WEST, + }, + }, + { + unit: Wizard, + position: { + x: 9, + y: 0, + facing: WEST, + }, + }, + { + unit: Captive, + position: { + x: 10, + y: 0, + facing: WEST, + }, + }, ], }, }, From b70520ddc8fd4726101f64ace464d78db4f9410a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Olivera?= Date: Tue, 17 Mar 2026 14:52:12 -0300 Subject: [PATCH 26/39] refactor: move warrior config to towers and unify config types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove Warrior from @warriorjs/units — each tower defines its own sharedWarriorConfig typed as Pick - Replace UnitConfig and TowerWarriorEntry with WarriorConfig - Rename TowerUnitEntry to UnitConfig (the natural name now that the legacy plain-object format is gone) Co-Authored-By: Claude Opus 4.6 (1M context) --- libs/core/src/index.ts | 3 +-- libs/core/src/loadLevel.ts | 6 ++--- libs/core/src/types.ts | 35 +++++++++-------------------- libs/units/src/Warrior.test.ts | 17 -------------- libs/units/src/Warrior.ts | 7 ------ libs/units/src/index.ts | 1 - towers/the-narrow-path/src/index.ts | 28 ++++++++++++++--------- towers/the-powder-keep/src/index.ts | 28 ++++++++++++++--------- 8 files changed, 49 insertions(+), 76 deletions(-) delete mode 100644 libs/units/src/Warrior.test.ts delete mode 100644 libs/units/src/Warrior.ts diff --git a/libs/core/src/index.ts b/libs/core/src/index.ts index 237cf2f6..84259f46 100644 --- a/libs/core/src/index.ts +++ b/libs/core/src/index.ts @@ -12,8 +12,7 @@ export type { LevelConfig, TowerDefinition, TowerLevel, - TowerUnitEntry, - TowerWarriorEntry, UnitConfig, + WarriorConfig, } from './types.js'; export { default as Unit } from './Unit.js'; diff --git a/libs/core/src/loadLevel.ts b/libs/core/src/loadLevel.ts index 67887b0a..2049ace7 100644 --- a/libs/core/src/loadLevel.ts +++ b/libs/core/src/loadLevel.ts @@ -3,7 +3,7 @@ import type { AbilityBinding } from './Ability.js'; import Floor from './Floor.js'; import Level from './Level.js'; import loadPlayer from './loadPlayer.js'; -import type { LevelConfig, TowerUnitEntry } from './types.js'; +import type { LevelConfig, UnitConfig } from './types.js'; import type Unit from './Unit.js'; import Warrior from './Warrior.js'; @@ -44,7 +44,7 @@ function loadWarrior( floor.addWarrior(unit, position); } -function loadUnit({ unit: UnitClass, effects, position }: TowerUnitEntry, floor: Floor): void { +function loadUnit({ unit: UnitClass, effects, position }: UnitConfig, floor: Floor): void { const unit = new UnitClass(); const declaredAbilities = (unit as any).declaredAbilities; if (declaredAbilities) { @@ -67,7 +67,7 @@ function loadLevel( loadWarrior(warrior, floor, playerCode, language); for (const entry of units) { - loadUnit(entry as TowerUnitEntry, floor); + loadUnit(entry as UnitConfig, floor); } return new Level(number!, description!, tip!, clue!, floor); diff --git a/libs/core/src/types.ts b/libs/core/src/types.ts index db684c02..674c0992 100644 --- a/libs/core/src/types.ts +++ b/libs/core/src/types.ts @@ -1,16 +1,17 @@ import type Unit from './Unit.js'; -export interface UnitConfig { - name: string; +export interface WarriorConfig { + name?: string; character: string; color: string; maxHealth: number; - reward?: number; - enemy?: boolean; - bound?: boolean; abilities?: Record; + position: { x: number; y: number; facing: string }; +} + +export interface UnitConfig { + unit: new () => Unit; effects?: Record; - playTurn?: (turn: any) => void; position: { x: number; y: number; facing: string }; } @@ -24,25 +25,11 @@ export interface LevelConfig { floor: { size: { width: number; height: number }; stairs: { x: number; y: number }; - warrior: UnitConfig; - units?: (UnitConfig | TowerUnitEntry)[]; + warrior: WarriorConfig; + units?: UnitConfig[]; }; } -export interface TowerUnitEntry { - unit: new () => Unit; - effects?: Record; - position: { x: number; y: number; facing: string }; -} - -export interface TowerWarriorEntry { - character: string; - color: string; - maxHealth: number; - abilities?: Record; - position: { x: number; y: number; facing: string }; -} - export interface TowerLevel { description: string; tip: string; @@ -52,8 +39,8 @@ export interface TowerLevel { floor: { size: { width: number; height: number }; stairs: { x: number; y: number }; - warrior: TowerWarriorEntry; - units: TowerUnitEntry[]; + warrior: WarriorConfig; + units: UnitConfig[]; }; } diff --git a/libs/units/src/Warrior.test.ts b/libs/units/src/Warrior.test.ts deleted file mode 100644 index a86260a2..00000000 --- a/libs/units/src/Warrior.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, test } from 'vitest'; - -import Warrior from './Warrior.js'; - -describe('Warrior', () => { - test("appears as '@' on map", () => { - expect(Warrior.character).toBe('@'); - }); - - test('has #8fbcbb color', () => { - expect(Warrior.color).toBe('#8fbcbb'); - }); - - test('has 20 max health', () => { - expect(Warrior.maxHealth).toBe(20); - }); -}); diff --git a/libs/units/src/Warrior.ts b/libs/units/src/Warrior.ts deleted file mode 100644 index 510381e5..00000000 --- a/libs/units/src/Warrior.ts +++ /dev/null @@ -1,7 +0,0 @@ -const Warrior = { - character: '@', - color: '#8fbcbb', - maxHealth: 20, -}; - -export default Warrior; diff --git a/libs/units/src/index.ts b/libs/units/src/index.ts index 4c8f9f1f..8102f3bc 100644 --- a/libs/units/src/index.ts +++ b/libs/units/src/index.ts @@ -4,5 +4,4 @@ export { default as MeleeUnit } from './MeleeUnit.js'; export { default as RangedUnit } from './RangedUnit.js'; export { default as Sludge } from './Sludge.js'; export { default as ThickSludge } from './ThickSludge.js'; -export { default as Warrior } from './Warrior.js'; export { default as Wizard } from './Wizard.js'; diff --git a/towers/the-narrow-path/src/index.ts b/towers/the-narrow-path/src/index.ts index 3f2f4bd0..bdf91174 100644 --- a/towers/the-narrow-path/src/index.ts +++ b/towers/the-narrow-path/src/index.ts @@ -11,9 +11,15 @@ import { Think, Walk, } from '@warriorjs/abilities'; -import type { TowerDefinition } from '@warriorjs/core'; +import type { TowerDefinition, WarriorConfig } from '@warriorjs/core'; import { EAST, WEST } from '@warriorjs/spatial'; -import { Archer, Captive, Sludge, ThickSludge, Warrior, Wizard } from '@warriorjs/units'; +import { Archer, Captive, Sludge, ThickSludge, Wizard } from '@warriorjs/units'; + +const sharedWarriorConfig: Pick = { + character: '@', + color: '#8fbcbb', + maxHealth: 20, +}; const tower: TowerDefinition = { name: 'The Narrow Path', @@ -35,7 +41,7 @@ const tower: TowerDefinition = { y: 0, }, warrior: { - ...Warrior, + ...sharedWarriorConfig, abilities: { think: Think, walk: Walk, @@ -66,7 +72,7 @@ const tower: TowerDefinition = { y: 0, }, warrior: { - ...Warrior, + ...sharedWarriorConfig, abilities: { attack: Attack.with({ power: 5 }), feel: Feel, @@ -106,7 +112,7 @@ const tower: TowerDefinition = { y: 0, }, warrior: { - ...Warrior, + ...sharedWarriorConfig, abilities: { health: Health, maxHealth: MaxHealth, @@ -171,7 +177,7 @@ const tower: TowerDefinition = { y: 0, }, warrior: { - ...Warrior, + ...sharedWarriorConfig, position: { x: 0, y: 0, @@ -222,7 +228,7 @@ const tower: TowerDefinition = { y: 0, }, warrior: { - ...Warrior, + ...sharedWarriorConfig, abilities: { rescue: Rescue, }, @@ -293,7 +299,7 @@ const tower: TowerDefinition = { y: 0, }, warrior: { - ...Warrior, + ...sharedWarriorConfig, position: { x: 2, y: 0, @@ -352,7 +358,7 @@ const tower: TowerDefinition = { y: 0, }, warrior: { - ...Warrior, + ...sharedWarriorConfig, abilities: { pivot: Pivot, }, @@ -399,7 +405,7 @@ const tower: TowerDefinition = { y: 0, }, warrior: { - ...Warrior, + ...sharedWarriorConfig, abilities: { look: Look.with({ range: 3 }), shoot: Shoot.with({ power: 3, range: 3 }), @@ -455,7 +461,7 @@ const tower: TowerDefinition = { y: 0, }, warrior: { - ...Warrior, + ...sharedWarriorConfig, position: { x: 5, y: 0, diff --git a/towers/the-powder-keep/src/index.ts b/towers/the-powder-keep/src/index.ts index cbf6b0ee..dedfbce5 100644 --- a/towers/the-powder-keep/src/index.ts +++ b/towers/the-powder-keep/src/index.ts @@ -15,10 +15,16 @@ import { Think, Walk, } from '@warriorjs/abilities'; -import type { TowerDefinition } from '@warriorjs/core'; +import type { TowerDefinition, WarriorConfig } from '@warriorjs/core'; import { Ticking } from '@warriorjs/effects'; import { EAST, NORTH, SOUTH, WEST } from '@warriorjs/spatial'; -import { Captive, Sludge, ThickSludge, Warrior } from '@warriorjs/units'; +import { Captive, Sludge, ThickSludge } from '@warriorjs/units'; + +const sharedWarriorConfig: Pick = { + character: '@', + color: '#8fbcbb', + maxHealth: 20, +}; const tower: TowerDefinition = { name: 'The Powder Keep', @@ -40,7 +46,7 @@ const tower: TowerDefinition = { y: 3, }, warrior: { - ...Warrior, + ...sharedWarriorConfig, abilities: { directionOfStairs: DirectionOfStairs, think: Think, @@ -72,7 +78,7 @@ const tower: TowerDefinition = { y: 1, }, warrior: { - ...Warrior, + ...sharedWarriorConfig, abilities: { attack: Attack.with({ power: 5 }), feel: Feel, @@ -130,7 +136,7 @@ const tower: TowerDefinition = { y: 0, }, warrior: { - ...Warrior, + ...sharedWarriorConfig, position: { x: 1, y: 1, @@ -194,7 +200,7 @@ const tower: TowerDefinition = { y: 2, }, warrior: { - ...Warrior, + ...sharedWarriorConfig, position: { x: 1, y: 1, @@ -266,7 +272,7 @@ const tower: TowerDefinition = { y: 1, }, warrior: { - ...Warrior, + ...sharedWarriorConfig, position: { x: 0, y: 1, @@ -318,7 +324,7 @@ const tower: TowerDefinition = { y: 0, }, warrior: { - ...Warrior, + ...sharedWarriorConfig, position: { x: 0, y: 1, @@ -381,7 +387,7 @@ const tower: TowerDefinition = { y: 0, }, warrior: { - ...Warrior, + ...sharedWarriorConfig, position: { x: 0, y: 1, @@ -452,7 +458,7 @@ const tower: TowerDefinition = { y: 0, }, warrior: { - ...Warrior, + ...sharedWarriorConfig, position: { x: 0, y: 0, @@ -511,7 +517,7 @@ const tower: TowerDefinition = { y: 0, }, warrior: { - ...Warrior, + ...sharedWarriorConfig, position: { x: 0, y: 1, From 64b834f8cc83e21d2900e791b36e39063fb44f03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Olivera?= Date: Tue, 17 Mar 2026 17:10:55 -0300 Subject: [PATCH 27/39] refactor(core): add tower-level warrior and level definition types Add WarriorDefinition, WarriorOverrides, and LevelDefinition types for the tower definition format. Update getLevelConfig to accept TowerDefinition and merge tower.warrior with level.warrior at config resolution time. Co-Authored-By: Claude Opus 4.6 (1M context) --- libs/core/src/getLevelConfig.test.ts | 112 ++++++++++++++------------- libs/core/src/getLevelConfig.ts | 18 ++--- libs/core/src/index.ts | 4 +- libs/core/src/runLevel.ts | 3 +- libs/core/src/types.ts | 23 ++++-- 5 files changed, 91 insertions(+), 69 deletions(-) diff --git a/libs/core/src/getLevelConfig.test.ts b/libs/core/src/getLevelConfig.test.ts index 88c660eb..ab84467d 100644 --- a/libs/core/src/getLevelConfig.test.ts +++ b/libs/core/src/getLevelConfig.test.ts @@ -3,71 +3,79 @@ import { expect, test } from 'vitest'; import getLevelConfig from './getLevelConfig.js'; const tower = { - id: 'foo', name: 'Foo', + description: 'A test tower', + warrior: { + character: '@', + color: '#fff', + maxHealth: 20, + }, levels: [ - { floor: { warrior: { abilities: { a: 1 }, bar: 'baz' }, foo: 42 } }, - { floor: { warrior: { abilities: { b: 2, c: 3 } } } }, - { floor: { warrior: {} } }, - { floor: { warrior: { abilities: { a: 4 } } } }, - ], -}; - -test('returns level config', () => { - expect(getLevelConfig(tower, 1, 'Joe', false)).toEqual({ - number: 1, - floor: { - foo: 42, - warrior: { - bar: 'baz', - name: 'Joe', - abilities: { a: 1 }, + { + floor: { + warrior: { abilities: { a: 1 }, position: { x: 0, y: 0, facing: 'east' } }, + size: { width: 1, height: 1 }, + stairs: { x: 0, y: 0 }, + units: [], }, }, - }); -}); - -test('gets abilities from all levels if epic', () => { - expect(getLevelConfig(tower, 1, 'Joe', true)).toEqual({ - number: 1, - floor: { - foo: 42, - warrior: { - bar: 'baz', - name: 'Joe', - abilities: { a: 4, b: 2, c: 3 }, + { + floor: { + warrior: { abilities: { b: 2, c: 3 }, position: { x: 0, y: 0, facing: 'east' } }, + size: { width: 1, height: 1 }, + stairs: { x: 0, y: 0 }, + units: [], }, }, + { + floor: { + warrior: { position: { x: 0, y: 0, facing: 'east' } }, + size: { width: 1, height: 1 }, + stairs: { x: 0, y: 0 }, + units: [], + }, + }, + { + floor: { + warrior: { abilities: { a: 4 }, position: { x: 0, y: 0, facing: 'east' } }, + size: { width: 1, height: 1 }, + stairs: { x: 0, y: 0 }, + units: [], + }, + }, + ], +} as any; + +test('merges tower warrior with level warrior', () => { + const config = getLevelConfig(tower, 1, 'Joe', false); + expect(config).not.toBeNull(); + expect(config!.floor.warrior).toEqual({ + character: '@', + color: '#fff', + maxHealth: 20, + name: 'Joe', + abilities: { a: 1 }, + position: { x: 0, y: 0, facing: 'east' }, }); }); -test('returns null for non-existent level', () => { - expect(getLevelConfig(tower, 5, 'Joe', false)).toBeNull(); +test('accumulates abilities from all levels if epic', () => { + const config = getLevelConfig(tower, 1, 'Joe', true); + expect(config!.floor.warrior.abilities).toEqual({ a: 4, b: 2, c: 3 }); }); -test('preserves functions in cloned config', () => { - const abilityFn = () => ({ action: true, description: 'test' }); - const playTurn = () => {}; - const towerWithFns = { - levels: [ - { - floor: { - warrior: { abilities: { walk: abilityFn } }, - units: [{ playTurn }], - }, - }, - ], - }; - const config = getLevelConfig(towerWithFns, 1, 'Joe', false); - expect(config).not.toBeNull(); - expect(config!.floor.units[0].playTurn).toBe(playTurn); +test('accumulates abilities up to current level', () => { + const config = getLevelConfig(tower, 2, 'Joe', false); + expect(config!.floor.warrior.abilities).toEqual({ a: 1, b: 2, c: 3 }); +}); + +test('returns null for non-existent level', () => { + expect(getLevelConfig(tower, 5, 'Joe', false)).toBeNull(); }); test('does not mutate original tower config', () => { - const towerCopy = { - levels: [{ floor: { warrior: { abilities: { a: 1 } } } }], - }; - const config = getLevelConfig(towerCopy, 1, 'Joe', false); + const config = getLevelConfig(tower, 1, 'Joe', false); config!.floor.warrior.name = 'Modified'; - expect(towerCopy.levels[0].floor.warrior).not.toHaveProperty('name'); + expect(tower.warrior).not.toHaveProperty('name'); + expect(tower.levels[0].floor.warrior).not.toHaveProperty('name'); }); diff --git a/libs/core/src/getLevelConfig.ts b/libs/core/src/getLevelConfig.ts index c75ac747..73e68457 100644 --- a/libs/core/src/getLevelConfig.ts +++ b/libs/core/src/getLevelConfig.ts @@ -1,8 +1,4 @@ -import type { LevelConfig } from './types.js'; - -interface Tower { - levels: LevelConfig[]; -} +import type { LevelConfig, TowerDefinition } from './types.js'; function deepClone(obj: T): T { if (obj === null || typeof obj !== 'object') { @@ -36,7 +32,7 @@ function deepClone(obj: T): T { * @returns The level config. */ function getLevelConfig( - tower: Tower, + tower: TowerDefinition, levelNumber: number, warriorName: string, epic: boolean, @@ -46,7 +42,7 @@ function getLevelConfig( return null; } - const levelConfig = deepClone(level); + const levelConfig = deepClone(level) as unknown as LevelConfig; const levels = epic ? tower.levels : tower.levels.slice(0, levelNumber); const warriorAbilities = Object.assign( @@ -61,8 +57,12 @@ function getLevelConfig( ); levelConfig.number = levelNumber; - levelConfig.floor.warrior.name = warriorName; - levelConfig.floor.warrior.abilities = warriorAbilities; + levelConfig.floor.warrior = { + ...tower.warrior, + ...levelConfig.floor.warrior, + name: warriorName, + abilities: warriorAbilities, + }; return levelConfig; } diff --git a/libs/core/src/index.ts b/libs/core/src/index.ts index 84259f46..320ab941 100644 --- a/libs/core/src/index.ts +++ b/libs/core/src/index.ts @@ -10,9 +10,11 @@ export { default as runLevel } from './runLevel.js'; export { default as Sense } from './Sense.js'; export type { LevelConfig, + LevelDefinition, TowerDefinition, - TowerLevel, UnitConfig, WarriorConfig, + WarriorDefinition, + WarriorOverrides, } from './types.js'; export { default as Unit } from './Unit.js'; diff --git a/libs/core/src/runLevel.ts b/libs/core/src/runLevel.ts index 0c37164c..07cb4fac 100644 --- a/libs/core/src/runLevel.ts +++ b/libs/core/src/runLevel.ts @@ -7,8 +7,7 @@ function runLevel( playerCode: string, language: 'javascript' | 'typescript' = 'javascript', ): { passed: boolean; turns: TurnEvent[][]; initialState: TurnEvent | null } { - const level = loadLevel(levelConfig, playerCode, language); - return level.play(); + return loadLevel(levelConfig, playerCode, language).play(); } export default runLevel; diff --git a/libs/core/src/types.ts b/libs/core/src/types.ts index 674c0992..8745b1b5 100644 --- a/libs/core/src/types.ts +++ b/libs/core/src/types.ts @@ -5,14 +5,14 @@ export interface WarriorConfig { character: string; color: string; maxHealth: number; - abilities?: Record; position: { x: number; y: number; facing: string }; + abilities?: Record; } export interface UnitConfig { unit: new () => Unit; - effects?: Record; position: { x: number; y: number; facing: string }; + effects?: Record; } export interface LevelConfig { @@ -30,7 +30,19 @@ export interface LevelConfig { }; } -export interface TowerLevel { +export interface WarriorDefinition { + character: string; + color: string; + maxHealth: number; +} + +export interface WarriorOverrides { + position: { x: number; y: number; facing: string }; + abilities?: Record; + maxHealth?: number; +} + +export interface LevelDefinition { description: string; tip: string; clue?: string; @@ -39,7 +51,7 @@ export interface TowerLevel { floor: { size: { width: number; height: number }; stairs: { x: number; y: number }; - warrior: WarriorConfig; + warrior: WarriorOverrides; units: UnitConfig[]; }; } @@ -47,5 +59,6 @@ export interface TowerLevel { export interface TowerDefinition { name: string; description: string; - levels: TowerLevel[]; + warrior: WarriorDefinition; + levels: LevelDefinition[]; } From 4aef7b27ee8948fa6a93cbc59b80c665e14563bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Olivera?= Date: Tue, 17 Mar 2026 17:11:02 -0300 Subject: [PATCH 28/39] refactor(cli): update Tower class for tower-level warrior config Tower constructor now accepts warrior (WarriorDefinition) and uses LevelDefinition for levels. Update loadTowers to extract warrior from tower modules. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/cli/src/Tower.test.ts | 6 +++++- apps/cli/src/Tower.ts | 16 +++++++++++---- apps/cli/src/loadTowers.test.ts | 36 ++++++++++++++++++++++++--------- apps/cli/src/loadTowers.ts | 4 ++-- 4 files changed, 45 insertions(+), 17 deletions(-) diff --git a/apps/cli/src/Tower.test.ts b/apps/cli/src/Tower.test.ts index d36217c6..5db0425b 100644 --- a/apps/cli/src/Tower.test.ts +++ b/apps/cli/src/Tower.test.ts @@ -6,7 +6,7 @@ describe('Tower', () => { let tower: Tower; beforeEach(() => { - tower = new Tower('foo', 'Foo', 'bar baz', ['level1', 'level2']); + tower = new Tower('foo', 'Foo', 'bar baz', 'warrior' as any, ['level1', 'level2'] as any); }); test('has an id', () => { @@ -21,6 +21,10 @@ describe('Tower', () => { expect(tower.description).toBe('bar baz'); }); + test('has a warrior', () => { + expect(tower.warrior).toEqual('warrior'); + }); + test('has some levels', () => { expect(tower.levels).toEqual(['level1', 'level2']); }); diff --git a/apps/cli/src/Tower.ts b/apps/cli/src/Tower.ts index 6e2e8910..11962dac 100644 --- a/apps/cli/src/Tower.ts +++ b/apps/cli/src/Tower.ts @@ -1,16 +1,24 @@ -import type { LevelConfig } from '@warriorjs/core'; +import type { LevelDefinition, WarriorDefinition } from '@warriorjs/core'; /** Class representing a tower. */ class Tower { id: string; name: string; description: string; - levels: LevelConfig[]; + warrior: WarriorDefinition; + levels: LevelDefinition[]; - constructor(id: string, name: string, description: string, levels: LevelConfig[]) { + constructor( + id: string, + name: string, + description: string, + warrior: WarriorDefinition, + levels: LevelDefinition[], + ) { this.id = id; this.name = name; this.description = description; + this.warrior = warrior; this.levels = levels; } @@ -18,7 +26,7 @@ class Tower { return !!this.getLevel(levelNumber); } - getLevel(levelNumber: number): LevelConfig | undefined { + getLevel(levelNumber: number): LevelDefinition | undefined { return this.levels[levelNumber - 1]; } diff --git a/apps/cli/src/loadTowers.test.ts b/apps/cli/src/loadTowers.test.ts index 48a4b48f..5806f78a 100644 --- a/apps/cli/src/loadTowers.test.ts +++ b/apps/cli/src/loadTowers.test.ts @@ -25,6 +25,7 @@ test('loads internal towers', () => { mockRequire.mockReturnValue({ name: 'The Narrow Path', description: 'A corridor of stone where the only way out is forward', + warrior: 'warrior', levels: ['level1', 'level2'], }); mock({ '/path/to/node_modules/@warriorjs/cli': {} }); @@ -34,6 +35,7 @@ test('loads internal towers', () => { 'the-narrow-path', 'The Narrow Path', 'A corridor of stone where the only way out is forward', + 'warrior', ['level1', 'level2'], ); }); @@ -41,11 +43,17 @@ test('loads internal towers', () => { test('loads external official towers', () => { mockRequire.mockImplementation((path: string) => { if (path.includes('tower-foo')) { - return { name: 'Foo', description: 'bar', levels: ['level1', 'level2'] }; + return { + name: 'Foo', + description: 'bar', + warrior: 'warrior', + levels: ['level1', 'level2'], + }; } return { name: 'The Narrow Path', description: 'A corridor of stone where the only way out is forward', + warrior: 'warrior', levels: ['level1', 'level2'], }; }); @@ -56,24 +64,30 @@ test('loads external official towers', () => { 'tower-foo': { 'package.json': '', 'index.js': - "module.exports = { name: 'Foo', description: 'bar', levels: ['level1', 'level2'] }", + "module.exports = { name: 'Foo', description: 'bar', warrior: 'warrior, levels: ['level1', 'level2'] }", }, }, }, }); loadTowers(); mock.restore(); - expect(Tower).toHaveBeenCalledWith('foo', 'Foo', 'bar', ['level1', 'level2']); + expect(Tower).toHaveBeenCalledWith('foo', 'Foo', 'bar', 'warrior', ['level1', 'level2']); }); test('loads external community towers', () => { mockRequire.mockImplementation((path: string) => { if (path.includes('warriorjs-tower-foo')) { - return { name: 'Foo', description: 'bar', levels: ['level1', 'level2'] }; + return { + name: 'Foo', + description: 'bar', + warrior: 'warrior', + levels: ['level1', 'level2'], + }; } return { name: 'The Narrow Path', description: 'A corridor of stone where the only way out is forward', + warrior: 'warrior', levels: ['level1', 'level2'], }; }); @@ -85,19 +99,20 @@ test('loads external community towers', () => { 'warriorjs-tower-foo': { 'package.json': '', 'index.js': - "module.exports = { name: 'Foo', description: 'bar', levels: ['level1', 'level2'] }", + "module.exports = { name: 'Foo', description: 'bar', warrior: 'warrior, levels: ['level1', 'level2'] }", }, }, }); loadTowers(); mock.restore(); - expect(Tower).toHaveBeenCalledWith('foo', 'Foo', 'bar', ['level1', 'level2']); + expect(Tower).toHaveBeenCalledWith('foo', 'Foo', 'bar', 'warrior', ['level1', 'level2']); }); test("ignores directories that are seemingly towers but don't have a package.json", () => { mockRequire.mockReturnValue({ name: 'The Narrow Path', description: 'A corridor of stone where the only way out is forward', + warrior: 'warrior', levels: ['level1', 'level2'], }); mock({ @@ -106,25 +121,26 @@ test("ignores directories that are seemingly towers but don't have a package.jso cli: {}, 'tower-foo': { 'index.js': - "module.exports = { name: 'Foo', description: 'baz', levels: ['level1', 'level2'] }", + "module.exports = { name: 'Foo', description: 'baz', warrior: 'warrior, levels: ['level1', 'level2'] }", }, }, 'warriorjs-tower-bar': { 'index.js': - "module.exports = { name: 'Bar', description: 'baz', levels: ['level1', 'level2'] }", + "module.exports = { name: 'Bar', description: 'baz', warrior: 'warrior, levels: ['level1', 'level2'] }", }, }, }); loadTowers(); mock.restore(); - expect(Tower).not.toHaveBeenCalledWith('foo', 'Foo', 'baz', ['level1', 'level2']); - expect(Tower).not.toHaveBeenCalledWith('bar', 'Bar', 'baz', ['level1', 'level2']); + expect(Tower).not.toHaveBeenCalledWith('foo', 'Foo', 'baz', 'warrior', ['level1', 'level2']); + expect(Tower).not.toHaveBeenCalledWith('bar', 'Bar', 'baz', 'warrior', ['level1', 'level2']); }); test("doesn't throw when @warriorjs/cli doesn't exist", async () => { mockRequire.mockReturnValue({ name: 'The Narrow Path', description: 'A corridor of stone where the only way out is forward', + warrior: 'warrior', levels: ['level1', 'level2'], }); const { findUpSync } = await import('find-up'); diff --git a/apps/cli/src/loadTowers.ts b/apps/cli/src/loadTowers.ts index bd7eca3f..1db627b9 100644 --- a/apps/cli/src/loadTowers.ts +++ b/apps/cli/src/loadTowers.ts @@ -62,8 +62,8 @@ function loadTowers(): Tower[] { const uniqueInfo = [...new Map(allInfo.map((item) => [item.id, item])).values()]; return uniqueInfo.map(({ id, requirePath }) => { const mod = require(requirePath); - const { name, description, levels } = mod.default || mod; - return new Tower(id, name, description, levels); + const { name, description, warrior, levels } = mod.default || mod; + return new Tower(id, name, description, warrior, levels); }); } From 5f840ef8318e83dd8db86538c1dc9eacaa8f6407 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Olivera?= Date: Tue, 17 Mar 2026 17:11:18 -0300 Subject: [PATCH 29/39] refactor(towers): move warrior config to tower top level Define warrior identity (character, color, maxHealth) once at the tower level instead of spreading sharedWarriorConfig into every level. Each level now only declares abilities and position. Co-Authored-By: Claude Opus 4.6 (1M context) --- towers/the-narrow-path/src/index.ts | 22 ++++++---------------- towers/the-powder-keep/src/index.ts | 22 ++++++---------------- 2 files changed, 12 insertions(+), 32 deletions(-) diff --git a/towers/the-narrow-path/src/index.ts b/towers/the-narrow-path/src/index.ts index bdf91174..f7efc20a 100644 --- a/towers/the-narrow-path/src/index.ts +++ b/towers/the-narrow-path/src/index.ts @@ -11,19 +11,18 @@ import { Think, Walk, } from '@warriorjs/abilities'; -import type { TowerDefinition, WarriorConfig } from '@warriorjs/core'; +import type { TowerDefinition } from '@warriorjs/core'; import { EAST, WEST } from '@warriorjs/spatial'; import { Archer, Captive, Sludge, ThickSludge, Wizard } from '@warriorjs/units'; -const sharedWarriorConfig: Pick = { - character: '@', - color: '#8fbcbb', - maxHealth: 20, -}; - const tower: TowerDefinition = { name: 'The Narrow Path', description: 'A corridor of stone where the only way out is forward', + warrior: { + character: '@', + color: '#8fbcbb', + maxHealth: 20, + }, levels: [ { description: @@ -41,7 +40,6 @@ const tower: TowerDefinition = { y: 0, }, warrior: { - ...sharedWarriorConfig, abilities: { think: Think, walk: Walk, @@ -72,7 +70,6 @@ const tower: TowerDefinition = { y: 0, }, warrior: { - ...sharedWarriorConfig, abilities: { attack: Attack.with({ power: 5 }), feel: Feel, @@ -112,7 +109,6 @@ const tower: TowerDefinition = { y: 0, }, warrior: { - ...sharedWarriorConfig, abilities: { health: Health, maxHealth: MaxHealth, @@ -177,7 +173,6 @@ const tower: TowerDefinition = { y: 0, }, warrior: { - ...sharedWarriorConfig, position: { x: 0, y: 0, @@ -228,7 +223,6 @@ const tower: TowerDefinition = { y: 0, }, warrior: { - ...sharedWarriorConfig, abilities: { rescue: Rescue, }, @@ -299,7 +293,6 @@ const tower: TowerDefinition = { y: 0, }, warrior: { - ...sharedWarriorConfig, position: { x: 2, y: 0, @@ -358,7 +351,6 @@ const tower: TowerDefinition = { y: 0, }, warrior: { - ...sharedWarriorConfig, abilities: { pivot: Pivot, }, @@ -405,7 +397,6 @@ const tower: TowerDefinition = { y: 0, }, warrior: { - ...sharedWarriorConfig, abilities: { look: Look.with({ range: 3 }), shoot: Shoot.with({ power: 3, range: 3 }), @@ -461,7 +452,6 @@ const tower: TowerDefinition = { y: 0, }, warrior: { - ...sharedWarriorConfig, position: { x: 5, y: 0, diff --git a/towers/the-powder-keep/src/index.ts b/towers/the-powder-keep/src/index.ts index dedfbce5..67b693ed 100644 --- a/towers/the-powder-keep/src/index.ts +++ b/towers/the-powder-keep/src/index.ts @@ -15,20 +15,19 @@ import { Think, Walk, } from '@warriorjs/abilities'; -import type { TowerDefinition, WarriorConfig } from '@warriorjs/core'; +import type { TowerDefinition } from '@warriorjs/core'; import { Ticking } from '@warriorjs/effects'; import { EAST, NORTH, SOUTH, WEST } from '@warriorjs/spatial'; import { Captive, Sludge, ThickSludge } from '@warriorjs/units'; -const sharedWarriorConfig: Pick = { - character: '@', - color: '#8fbcbb', - maxHealth: 20, -}; - const tower: TowerDefinition = { name: 'The Powder Keep', description: 'An old fortress where something ticks beneath the floor', + warrior: { + character: '@', + color: '#8fbcbb', + maxHealth: 20, + }, levels: [ { description: @@ -46,7 +45,6 @@ const tower: TowerDefinition = { y: 3, }, warrior: { - ...sharedWarriorConfig, abilities: { directionOfStairs: DirectionOfStairs, think: Think, @@ -78,7 +76,6 @@ const tower: TowerDefinition = { y: 1, }, warrior: { - ...sharedWarriorConfig, abilities: { attack: Attack.with({ power: 5 }), feel: Feel, @@ -136,7 +133,6 @@ const tower: TowerDefinition = { y: 0, }, warrior: { - ...sharedWarriorConfig, position: { x: 1, y: 1, @@ -200,7 +196,6 @@ const tower: TowerDefinition = { y: 2, }, warrior: { - ...sharedWarriorConfig, position: { x: 1, y: 1, @@ -272,7 +267,6 @@ const tower: TowerDefinition = { y: 1, }, warrior: { - ...sharedWarriorConfig, position: { x: 0, y: 1, @@ -324,7 +318,6 @@ const tower: TowerDefinition = { y: 0, }, warrior: { - ...sharedWarriorConfig, position: { x: 0, y: 1, @@ -387,7 +380,6 @@ const tower: TowerDefinition = { y: 0, }, warrior: { - ...sharedWarriorConfig, position: { x: 0, y: 1, @@ -458,7 +450,6 @@ const tower: TowerDefinition = { y: 0, }, warrior: { - ...sharedWarriorConfig, position: { x: 0, y: 0, @@ -517,7 +508,6 @@ const tower: TowerDefinition = { y: 0, }, warrior: { - ...sharedWarriorConfig, position: { x: 0, y: 1, From c99ca8fe2976dbac1da685d17a15e377002fac7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Olivera?= Date: Tue, 17 Mar 2026 17:35:21 -0300 Subject: [PATCH 30/39] refactor: make declaredAbilities a static field on Unit Add optional static declaredAbilities to the Unit base class and a UnitClass interface for the constructor + static shape. The engine reads abilities from the class at load time, removing the any cast. Co-Authored-By: Claude Opus 4.6 (1M context) --- libs/core/src/Unit.ts | 1 + libs/core/src/getLevel.test.ts | 2 +- libs/core/src/loadLevel.ts | 5 ++--- libs/core/src/runLevel.test.ts | 2 +- libs/core/src/types.ts | 17 +++++++++++------ libs/units/src/Archer.test.ts | 4 ++-- libs/units/src/Archer.ts | 2 +- libs/units/src/Sludge.test.ts | 4 ++-- libs/units/src/Sludge.ts | 2 +- libs/units/src/ThickSludge.test.ts | 4 ++-- libs/units/src/ThickSludge.ts | 2 +- libs/units/src/Wizard.test.ts | 4 ++-- libs/units/src/Wizard.ts | 2 +- 13 files changed, 28 insertions(+), 23 deletions(-) diff --git a/libs/core/src/Unit.ts b/libs/core/src/Unit.ts index c2b3d5a7..f4033ac0 100644 --- a/libs/core/src/Unit.ts +++ b/libs/core/src/Unit.ts @@ -13,6 +13,7 @@ interface Turn { /** Class representing a unit. */ class Unit { + static declaredAbilities?: Record; name: string; character: string; color: string; diff --git a/libs/core/src/getLevel.test.ts b/libs/core/src/getLevel.test.ts index c88c8f17..2580ce37 100644 --- a/libs/core/src/getLevel.test.ts +++ b/libs/core/src/getLevel.test.ts @@ -43,7 +43,7 @@ class TestFeel extends Sense { } class TestSludge extends Unit { - declaredAbilities = { + static declaredAbilities = { attack: TestAttack.with({ power: 3 }), feel: TestFeel, }; diff --git a/libs/core/src/loadLevel.ts b/libs/core/src/loadLevel.ts index 2049ace7..69cdf216 100644 --- a/libs/core/src/loadLevel.ts +++ b/libs/core/src/loadLevel.ts @@ -46,9 +46,8 @@ function loadWarrior( function loadUnit({ unit: UnitClass, effects, position }: UnitConfig, floor: Floor): void { const unit = new UnitClass(); - const declaredAbilities = (unit as any).declaredAbilities; - if (declaredAbilities) { - loadAbilities(unit, declaredAbilities); + if (UnitClass.declaredAbilities) { + loadAbilities(unit, UnitClass.declaredAbilities); } if (effects) { loadEffects(unit, effects); diff --git a/libs/core/src/runLevel.test.ts b/libs/core/src/runLevel.test.ts index 3a76d1ac..92944d14 100644 --- a/libs/core/src/runLevel.test.ts +++ b/libs/core/src/runLevel.test.ts @@ -63,7 +63,7 @@ class TestFeel extends Sense { } class TestSludge extends Unit { - declaredAbilities = { + static declaredAbilities = { attack: TestAttack.with({ power: 3 }), feel: TestFeel, }; diff --git a/libs/core/src/types.ts b/libs/core/src/types.ts index 8745b1b5..4152b4a8 100644 --- a/libs/core/src/types.ts +++ b/libs/core/src/types.ts @@ -1,5 +1,16 @@ import type Unit from './Unit.js'; +export interface UnitClass { + new (): Unit; + declaredAbilities?: Record; +} + +export interface UnitConfig { + unit: UnitClass; + position: { x: number; y: number; facing: string }; + effects?: Record; +} + export interface WarriorConfig { name?: string; character: string; @@ -9,12 +20,6 @@ export interface WarriorConfig { abilities?: Record; } -export interface UnitConfig { - unit: new () => Unit; - position: { x: number; y: number; facing: string }; - effects?: Record; -} - export interface LevelConfig { number?: number; description?: string; diff --git a/libs/units/src/Archer.test.ts b/libs/units/src/Archer.test.ts index b3b9f07b..5196efe0 100644 --- a/libs/units/src/Archer.test.ts +++ b/libs/units/src/Archer.test.ts @@ -28,11 +28,11 @@ describe('Archer', () => { }); test('has shoot ability', () => { - expect(archer.declaredAbilities).toHaveProperty('shoot'); + expect(Archer.declaredAbilities).toHaveProperty('shoot'); }); test('has look ability', () => { - expect(archer.declaredAbilities).toHaveProperty('look'); + expect(Archer.declaredAbilities).toHaveProperty('look'); }); describe('playing turn', () => { diff --git a/libs/units/src/Archer.ts b/libs/units/src/Archer.ts index 4763c27e..d6338b0c 100644 --- a/libs/units/src/Archer.ts +++ b/libs/units/src/Archer.ts @@ -3,7 +3,7 @@ import { Look, Shoot } from '@warriorjs/abilities'; import RangedUnit from './RangedUnit.js'; class Archer extends RangedUnit { - declaredAbilities = { + static declaredAbilities = { look: Look.with({ range: 3 }), shoot: Shoot.with({ range: 3, power: 3 }), }; diff --git a/libs/units/src/Sludge.test.ts b/libs/units/src/Sludge.test.ts index e2570185..28867ae3 100644 --- a/libs/units/src/Sludge.test.ts +++ b/libs/units/src/Sludge.test.ts @@ -28,11 +28,11 @@ describe('Sludge', () => { }); test('has attack ability', () => { - expect(sludge.declaredAbilities).toHaveProperty('attack'); + expect(Sludge.declaredAbilities).toHaveProperty('attack'); }); test('has feel ability', () => { - expect(sludge.declaredAbilities).toHaveProperty('feel'); + expect(Sludge.declaredAbilities).toHaveProperty('feel'); }); describe('playing turn', () => { diff --git a/libs/units/src/Sludge.ts b/libs/units/src/Sludge.ts index 7ac19c1a..7d19faa2 100644 --- a/libs/units/src/Sludge.ts +++ b/libs/units/src/Sludge.ts @@ -3,7 +3,7 @@ import { Attack, Feel } from '@warriorjs/abilities'; import MeleeUnit from './MeleeUnit.js'; class Sludge extends MeleeUnit { - declaredAbilities = { + static declaredAbilities = { attack: Attack.with({ power: 3 }), feel: Feel, }; diff --git a/libs/units/src/ThickSludge.test.ts b/libs/units/src/ThickSludge.test.ts index 2a75d42c..55d15f82 100644 --- a/libs/units/src/ThickSludge.test.ts +++ b/libs/units/src/ThickSludge.test.ts @@ -28,11 +28,11 @@ describe('ThickSludge', () => { }); test('has attack ability', () => { - expect(thickSludge.declaredAbilities).toHaveProperty('attack'); + expect(ThickSludge.declaredAbilities).toHaveProperty('attack'); }); test('has feel ability', () => { - expect(thickSludge.declaredAbilities).toHaveProperty('feel'); + expect(ThickSludge.declaredAbilities).toHaveProperty('feel'); }); describe('playing turn', () => { diff --git a/libs/units/src/ThickSludge.ts b/libs/units/src/ThickSludge.ts index f58ccee1..a3db6418 100644 --- a/libs/units/src/ThickSludge.ts +++ b/libs/units/src/ThickSludge.ts @@ -3,7 +3,7 @@ import { Attack, Feel } from '@warriorjs/abilities'; import MeleeUnit from './MeleeUnit.js'; class ThickSludge extends MeleeUnit { - declaredAbilities = { + static declaredAbilities = { attack: Attack.with({ power: 3 }), feel: Feel, }; diff --git a/libs/units/src/Wizard.test.ts b/libs/units/src/Wizard.test.ts index 6ae6f25b..1e4a2b09 100644 --- a/libs/units/src/Wizard.test.ts +++ b/libs/units/src/Wizard.test.ts @@ -28,11 +28,11 @@ describe('Wizard', () => { }); test('has shoot ability', () => { - expect(wizard.declaredAbilities).toHaveProperty('shoot'); + expect(Wizard.declaredAbilities).toHaveProperty('shoot'); }); test('has look ability', () => { - expect(wizard.declaredAbilities).toHaveProperty('look'); + expect(Wizard.declaredAbilities).toHaveProperty('look'); }); describe('playing turn', () => { diff --git a/libs/units/src/Wizard.ts b/libs/units/src/Wizard.ts index 0243e927..60a42f3c 100644 --- a/libs/units/src/Wizard.ts +++ b/libs/units/src/Wizard.ts @@ -3,7 +3,7 @@ import { Look, Shoot } from '@warriorjs/abilities'; import RangedUnit from './RangedUnit.js'; class Wizard extends RangedUnit { - declaredAbilities = { + static declaredAbilities = { look: Look.with({ range: 3 }), shoot: Shoot.with({ range: 3, power: 11 }), }; From 900eb0c16979fdd6674f57b938bb7aeac2a0d20b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Olivera?= Date: Tue, 17 Mar 2026 17:53:35 -0300 Subject: [PATCH 31/39] refactor(core): replace any with typed class interfaces Add AbilityClass, EffectClass interfaces alongside AbilityBinding and EffectBinding. Export AbilityEntry and EffectEntry union types. Move UnitClass interface to Unit.ts. All Record in config types are now properly typed. Co-Authored-By: Claude Opus 4.6 (1M context) --- libs/core/src/Ability.ts | 8 +++++++- libs/core/src/Effect.ts | 8 +++++++- libs/core/src/Unit.ts | 8 +++++++- libs/core/src/index.ts | 11 +++++++++-- libs/core/src/loadLevel.ts | 14 +++++++------- libs/core/src/types.ts | 15 ++++++--------- 6 files changed, 43 insertions(+), 21 deletions(-) diff --git a/libs/core/src/Ability.ts b/libs/core/src/Ability.ts index 633bf8bf..10202309 100644 --- a/libs/core/src/Ability.ts +++ b/libs/core/src/Ability.ts @@ -10,7 +10,13 @@ export interface AbilityMeta { returns: 'void' | 'number' | 'string' | 'Direction' | 'Space' | 'Space[]'; } -export type AbilityBinding = [new (unit: any, config: any) => Ability, object]; +export interface AbilityClass { + new (unit: any, config?: any): Ability; +} + +export type AbilityBinding = [AbilityClass, object]; + +export type AbilityEntry = AbilityBinding | AbilityClass; abstract class Ability { protected unit: any; diff --git a/libs/core/src/Effect.ts b/libs/core/src/Effect.ts index e640afe5..9c54ae29 100644 --- a/libs/core/src/Effect.ts +++ b/libs/core/src/Effect.ts @@ -1,4 +1,10 @@ -export type EffectBinding = [new (unit: any, config: any) => Effect, object]; +export interface EffectClass { + new (unit: any, config?: any): Effect; +} + +export type EffectBinding = [EffectClass, object]; + +export type EffectEntry = EffectBinding | EffectClass; abstract class Effect { protected unit: any; diff --git a/libs/core/src/Unit.ts b/libs/core/src/Unit.ts index f4033ac0..86c14c9e 100644 --- a/libs/core/src/Unit.ts +++ b/libs/core/src/Unit.ts @@ -1,4 +1,5 @@ import type Ability from './Ability.js'; +import type { AbilityEntry } from './Ability.js'; import Action from './Action.js'; import type Effect from './Effect.js'; import Logger from './Logger.js'; @@ -11,9 +12,14 @@ interface Turn { [key: string]: any; } +export interface UnitClass { + new (): Unit; + declaredAbilities?: Record; +} + /** Class representing a unit. */ class Unit { - static declaredAbilities?: Record; + static declaredAbilities?: Record; name: string; character: string; color: string; diff --git a/libs/core/src/index.ts b/libs/core/src/index.ts index 320ab941..226a7fbd 100644 --- a/libs/core/src/index.ts +++ b/libs/core/src/index.ts @@ -1,7 +1,13 @@ -export type { AbilityBinding, AbilityMeta, AbilityParam } from './Ability.js'; +export type { + AbilityBinding, + AbilityClass, + AbilityEntry, + AbilityMeta, + AbilityParam, +} from './Ability.js'; export { default as Ability } from './Ability.js'; export { default as Action } from './Action.js'; -export type { EffectBinding } from './Effect.js'; +export type { EffectBinding, EffectClass, EffectEntry } from './Effect.js'; export { default as Effect } from './Effect.js'; export { default as getLevel } from './getLevel.js'; export { default as getLevelConfig } from './getLevelConfig.js'; @@ -17,4 +23,5 @@ export type { WarriorDefinition, WarriorOverrides, } from './types.js'; +export type { UnitClass } from './Unit.js'; export { default as Unit } from './Unit.js'; diff --git a/libs/core/src/loadLevel.ts b/libs/core/src/loadLevel.ts index 69cdf216..bf9147c7 100644 --- a/libs/core/src/loadLevel.ts +++ b/libs/core/src/loadLevel.ts @@ -1,5 +1,5 @@ -import type Ability from './Ability.js'; -import type { AbilityBinding } from './Ability.js'; +import type { AbilityEntry } from './Ability.js'; +import type { EffectEntry } from './Effect.js'; import Floor from './Floor.js'; import Level from './Level.js'; import loadPlayer from './loadPlayer.js'; @@ -7,26 +7,26 @@ import type { LevelConfig, UnitConfig } from './types.js'; import type Unit from './Unit.js'; import Warrior from './Warrior.js'; -type AbilityEntry = AbilityBinding | (new (unit: any) => Ability); - function loadAbilities(unit: Unit, abilities: Record = {}): void { for (const [name, entry] of Object.entries(abilities)) { if (Array.isArray(entry)) { const [AbilityClass, config] = entry; unit.addAbility(name, new AbilityClass(unit, config)); } else { - unit.addAbility(name, new (entry as new (unit: any) => Ability)(unit)); + const AbilityClass = entry; + unit.addAbility(name, new AbilityClass(unit)); } } } -function loadEffects(unit: Unit, effects: Record = {}): void { +function loadEffects(unit: Unit, effects: Record = {}): void { for (const [name, entry] of Object.entries(effects)) { if (Array.isArray(entry)) { const [EffectClass, config] = entry; unit.addEffect(name, new EffectClass(unit, config)); } else { - unit.addEffect(name, new entry(unit)); + const EffectClass = entry; + unit.addEffect(name, new EffectClass(unit)); } } } diff --git a/libs/core/src/types.ts b/libs/core/src/types.ts index 4152b4a8..56c7f273 100644 --- a/libs/core/src/types.ts +++ b/libs/core/src/types.ts @@ -1,14 +1,11 @@ -import type Unit from './Unit.js'; - -export interface UnitClass { - new (): Unit; - declaredAbilities?: Record; -} +import type { AbilityEntry } from './Ability.js'; +import type { EffectEntry } from './Effect.js'; +import type { UnitClass } from './Unit.js'; export interface UnitConfig { unit: UnitClass; position: { x: number; y: number; facing: string }; - effects?: Record; + effects?: Record; } export interface WarriorConfig { @@ -17,7 +14,7 @@ export interface WarriorConfig { color: string; maxHealth: number; position: { x: number; y: number; facing: string }; - abilities?: Record; + abilities?: Record; } export interface LevelConfig { @@ -43,7 +40,7 @@ export interface WarriorDefinition { export interface WarriorOverrides { position: { x: number; y: number; facing: string }; - abilities?: Record; + abilities?: Record; maxHealth?: number; } From 71032f311537f4806462a15823b24325bcea35f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Olivera?= Date: Tue, 17 Mar 2026 17:59:13 -0300 Subject: [PATCH 32/39] refactor(units): don't export abstract unit classes --- libs/units/src/index.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/libs/units/src/index.ts b/libs/units/src/index.ts index 8102f3bc..34fb5e5a 100644 --- a/libs/units/src/index.ts +++ b/libs/units/src/index.ts @@ -1,7 +1,5 @@ export { default as Archer } from './Archer.js'; export { default as Captive } from './Captive.js'; -export { default as MeleeUnit } from './MeleeUnit.js'; -export { default as RangedUnit } from './RangedUnit.js'; export { default as Sludge } from './Sludge.js'; export { default as ThickSludge } from './ThickSludge.js'; export { default as Wizard } from './Wizard.js'; From 4a2503dedaf2a618dff38d1b15d88343e4e4ba50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Olivera?= Date: Tue, 17 Mar 2026 18:03:48 -0300 Subject: [PATCH 33/39] refactor(cli): consistency --- apps/cli/src/utils/renderTypes.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/utils/renderTypes.ts b/apps/cli/src/utils/renderTypes.ts index 999f0ed8..04851c4e 100644 --- a/apps/cli/src/utils/renderTypes.ts +++ b/apps/cli/src/utils/renderTypes.ts @@ -64,7 +64,8 @@ function instantiateAbility(entry: any): any { } if (typeof entry === 'function' && entry.prototype?.perform) { // Bare ability class - return new entry({} as any); + const AbilityClass = entry; + return new AbilityClass({} as any); } // Legacy factory return entry({} as any); From 2a94ec7255caf912423ec32fec9a436731945303 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Olivera?= Date: Tue, 17 Mar 2026 18:06:48 -0300 Subject: [PATCH 34/39] refactor(core): deepClone cleanup --- libs/core/src/getLevelConfig.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/libs/core/src/getLevelConfig.ts b/libs/core/src/getLevelConfig.ts index 73e68457..68da3b31 100644 --- a/libs/core/src/getLevelConfig.ts +++ b/libs/core/src/getLevelConfig.ts @@ -1,7 +1,7 @@ import type { LevelConfig, TowerDefinition } from './types.js'; function deepClone(obj: T): T { - if (obj === null || typeof obj !== 'object') { + if (obj === null || typeof obj !== 'object' || obj.constructor !== Object) { return obj; } @@ -9,11 +9,6 @@ function deepClone(obj: T): T { return obj.map((item) => deepClone(item)) as T; } - // Preserve class instances (Unit subclasses, etc.) — don't deep-clone them. - if (obj.constructor !== Object) { - return obj; - } - const clone = {} as Record; for (const key of Object.keys(obj)) { clone[key] = deepClone((obj as Record)[key]); From 5c13f90f2a8c8c441b310da7e4bcdcea43fd11a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Olivera?= Date: Tue, 17 Mar 2026 18:17:12 -0300 Subject: [PATCH 35/39] refactor(cli): stop handling legacy factories --- apps/cli/src/utils/renderTypes.test.ts | 21 --------------------- apps/cli/src/utils/renderTypes.ts | 21 ++++++--------------- 2 files changed, 6 insertions(+), 36 deletions(-) diff --git a/apps/cli/src/utils/renderTypes.test.ts b/apps/cli/src/utils/renderTypes.test.ts index 8dc08c3f..fe7cc604 100644 --- a/apps/cli/src/utils/renderTypes.test.ts +++ b/apps/cli/src/utils/renderTypes.test.ts @@ -124,27 +124,6 @@ describe('renderTypes', () => { ); }); - test('skips abilities without meta', () => { - class NoMetaAbility extends Sense { - readonly description = 'No meta'; - readonly meta = undefined as any; - perform() {} - } - expect(renderTypes(profile, makeLevelConfig({ walk: MockWalk, legacy: NoMetaAbility }))).toBe( - [ - '// @generated — Auto-generated each level. Do not edit.', - '', - "export type Direction = 'forward' | 'right' | 'backward' | 'left';", - '', - 'export interface Warrior {', - ' /** Walks forward */', - ' walk(direction?: Direction): void;', - '}', - '', - ].join('\n'), - ); - }); - test('handles rest parameters', () => { class RestAction extends Action { readonly description = 'Does something with rest params'; diff --git a/apps/cli/src/utils/renderTypes.ts b/apps/cli/src/utils/renderTypes.ts index 04851c4e..401cf6ce 100644 --- a/apps/cli/src/utils/renderTypes.ts +++ b/apps/cli/src/utils/renderTypes.ts @@ -1,4 +1,4 @@ -import { Action, type LevelConfig } from '@warriorjs/core'; +import { type Ability, type AbilityEntry, Action, type LevelConfig } from '@warriorjs/core'; import type Profile from '../Profile.js'; interface MethodEntry { @@ -56,19 +56,13 @@ function renderWarriorInterface(methods: MethodEntry[]): string { return `export interface Warrior {\n${body}\n}`; } -function instantiateAbility(entry: any): any { +function instantiateAbility(entry: AbilityEntry): Ability { if (Array.isArray(entry)) { - // AbilityBinding: [Class, config] const [AbilityClass, config] = entry; return new AbilityClass({} as any, config); } - if (typeof entry === 'function' && entry.prototype?.perform) { - // Bare ability class - const AbilityClass = entry; - return new AbilityClass({} as any); - } - // Legacy factory - return entry({} as any); + const AbilityClass = entry; + return new AbilityClass({} as any); } function renderTypes(_profile: Profile, levelConfig: LevelConfig): string { @@ -79,11 +73,8 @@ function renderTypes(_profile: Profile, levelConfig: LevelConfig): string { for (const [name, entry] of Object.entries(abilities)) { const ability = instantiateAbility(entry); - if (!ability.meta) { - continue; - } - const { meta } = ability; + const { description, meta } = ability; const params: string[] = meta.params.map((param: any) => { const tsType = param.type; @@ -106,8 +97,8 @@ function renderTypes(_profile: Profile, levelConfig: LevelConfig): string { methods.push({ name, + description, action: ability instanceof Action, - description: ability.description, signature: `${name}(${params.join(', ')}): ${returnType}`, }); } From 10254f65def02b4e6e8c2bfeb9d6c4209f966af6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Olivera?= Date: Tue, 17 Mar 2026 18:31:14 -0300 Subject: [PATCH 36/39] refactor(core): remove internal AbilityClass and EffectClass from exports These are implementation details used only within types.ts. External consumers use AbilityBinding/AbilityEntry and EffectBinding/EffectEntry. Co-Authored-By: Claude Opus 4.6 (1M context) --- libs/core/src/index.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/libs/core/src/index.ts b/libs/core/src/index.ts index 226a7fbd..4ed83215 100644 --- a/libs/core/src/index.ts +++ b/libs/core/src/index.ts @@ -1,13 +1,7 @@ -export type { - AbilityBinding, - AbilityClass, - AbilityEntry, - AbilityMeta, - AbilityParam, -} from './Ability.js'; +export type { AbilityBinding, AbilityEntry, AbilityMeta, AbilityParam } from './Ability.js'; export { default as Ability } from './Ability.js'; export { default as Action } from './Action.js'; -export type { EffectBinding, EffectClass, EffectEntry } from './Effect.js'; +export type { EffectBinding, EffectEntry } from './Effect.js'; export { default as Effect } from './Effect.js'; export { default as getLevel } from './getLevel.js'; export { default as getLevelConfig } from './getLevelConfig.js'; From f6ae6e10f7b1c7f9f9dedc9a456270c327d5f343 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Olivera?= Date: Tue, 17 Mar 2026 18:45:36 -0300 Subject: [PATCH 37/39] refactor: convert playTurn to method and add Turn type Convert playTurn from a constructor-assigned arrow function to a class method on Unit, MeleeUnit, and RangedUnit. Add exported Turn type for the public turn interface and internal TurnState for engine tracking. Initialize turn as null instead of empty object. Co-Authored-By: Claude Opus 4.6 (1M context) --- libs/core/src/Unit.test.ts | 4 ++-- libs/core/src/Unit.ts | 21 +++++++++++---------- libs/core/src/index.ts | 2 +- libs/core/src/loadPlayer.ts | 5 +++-- libs/units/src/MeleeUnit.ts | 27 +++++++++------------------ libs/units/src/RangedUnit.ts | 27 +++++++++------------------ 6 files changed, 35 insertions(+), 51 deletions(-) diff --git a/libs/core/src/Unit.test.ts b/libs/core/src/Unit.test.ts index f42afda6..25681dd6 100644 --- a/libs/core/src/Unit.test.ts +++ b/libs/core/src/Unit.test.ts @@ -90,8 +90,8 @@ describe('Unit', () => { expect(unit.effects.size).toBe(0); }); - test('has a turn which starts as an empty object', () => { - expect(unit.turn).toEqual({}); + test('has a turn which starts as null', () => { + expect(unit.turn).toBeNull(); }); describe('next turn', () => { diff --git a/libs/core/src/Unit.ts b/libs/core/src/Unit.ts index 86c14c9e..8665aaad 100644 --- a/libs/core/src/Unit.ts +++ b/libs/core/src/Unit.ts @@ -7,7 +7,9 @@ import type Position from './Position.js'; import type { SensedSpace, SensedUnit } from './Space.js'; import Space from './Space.js'; -interface Turn { +export type Turn = Record any>; + +interface TurnState { action: [string, any[]] | null; [key: string]: any; } @@ -32,8 +34,7 @@ class Unit { score: number; abilities: Map; effects: Map; - turn: Turn | Record; - playTurn: (turn: any) => void; + turn: TurnState | null; constructor( name?: string, @@ -56,12 +57,11 @@ class Unit { this.score = 0; this.abilities = new Map(); this.effects = new Map(); - this.turn = {}; - this.playTurn = () => {}; + this.turn = null; } - getNextTurn(): Turn { - const turn: Turn = { action: null }; + getNextTurn(): TurnState { + const turn: TurnState = { action: null }; this.abilities.forEach((ability, name) => { if (ability instanceof Action) { Object.defineProperty(turn, name, { @@ -82,6 +82,8 @@ class Unit { return turn; } + playTurn(_turn: Turn): void {} + prepareTurn(): void { this.turn = this.getNextTurn(); this.playTurn(this.turn); @@ -90,9 +92,8 @@ class Unit { performTurn(): void { if (this.isAlive()) { this.effects.forEach((effect) => effect.passTurn()); - const turn = this.turn as Turn; - if (turn.action && !this.isBound()) { - const [name, args] = turn.action; + if (this.turn?.action && !this.isBound()) { + const [name, args] = this.turn.action; this.abilities.get(name)?.perform(...args); } } diff --git a/libs/core/src/index.ts b/libs/core/src/index.ts index 4ed83215..efae9893 100644 --- a/libs/core/src/index.ts +++ b/libs/core/src/index.ts @@ -17,5 +17,5 @@ export type { WarriorDefinition, WarriorOverrides, } from './types.js'; -export type { UnitClass } from './Unit.js'; +export type { Turn, UnitClass } from './Unit.js'; export { default as Unit } from './Unit.js'; diff --git a/libs/core/src/loadPlayer.ts b/libs/core/src/loadPlayer.ts index 15767c13..70e5e17e 100644 --- a/libs/core/src/loadPlayer.ts +++ b/libs/core/src/loadPlayer.ts @@ -1,13 +1,14 @@ import assert from 'node:assert'; import vm from 'node:vm'; import { transformSync } from 'esbuild'; +import type { Turn } from './Unit.js'; const playerCodeTimeout = 3000; function loadPlayer( playerCode: string, language: 'javascript' | 'typescript' = 'javascript', -): (turn: any) => void { +): (turn: Turn) => void { const playerCodeFilename = language === 'typescript' ? 'Player.ts' : 'Player.js'; const loader = language === 'typescript' ? 'ts' : 'js'; @@ -44,7 +45,7 @@ function loadPlayer( timeout: playerCodeTimeout, }); assert(typeof player.playTurn === 'function', 'playTurn is not defined'); - const playTurn = (turn: any): void => { + const playTurn = (turn: Turn): void => { try { player.playTurn(turn); } catch (err: any) { diff --git a/libs/units/src/MeleeUnit.ts b/libs/units/src/MeleeUnit.ts index fdf366ab..f9ce8dfa 100644 --- a/libs/units/src/MeleeUnit.ts +++ b/libs/units/src/MeleeUnit.ts @@ -1,24 +1,15 @@ -import { Unit } from '@warriorjs/core'; +import { type Turn, Unit } from '@warriorjs/core'; import { RELATIVE_DIRECTIONS } from '@warriorjs/spatial'; abstract class MeleeUnit extends Unit { - constructor( - name: string, - character: string, - color: string, - maxHealth: number, - reward?: number | null, - ) { - super(name, character, color, maxHealth, reward); - this.playTurn = (turn: any) => { - const threatDirection = RELATIVE_DIRECTIONS.find((direction) => { - const unit = turn.feel(direction).getUnit(); - return unit?.isEnemy() && !unit.isBound(); - }); - if (threatDirection) { - turn.attack(threatDirection); - } - }; + playTurn(turn: Turn) { + const threatDirection = RELATIVE_DIRECTIONS.find((direction) => { + const unit = turn.feel(direction).getUnit(); + return unit?.isEnemy() && !unit.isBound(); + }); + if (threatDirection) { + turn.attack(threatDirection); + } } } diff --git a/libs/units/src/RangedUnit.ts b/libs/units/src/RangedUnit.ts index ee4c8b23..52b1bbda 100644 --- a/libs/units/src/RangedUnit.ts +++ b/libs/units/src/RangedUnit.ts @@ -1,24 +1,15 @@ -import { Unit } from '@warriorjs/core'; +import { type Turn, Unit } from '@warriorjs/core'; import { RELATIVE_DIRECTIONS } from '@warriorjs/spatial'; abstract class RangedUnit extends Unit { - constructor( - name: string, - character: string, - color: string, - maxHealth: number, - reward?: number | null, - ) { - super(name, character, color, maxHealth, reward); - this.playTurn = (turn: any) => { - const threatDirection = RELATIVE_DIRECTIONS.find((direction) => { - const spaceWithUnit = turn.look(direction).find((space: any) => space.isUnit()); - return spaceWithUnit?.getUnit().isEnemy() && !spaceWithUnit.getUnit().isBound(); - }); - if (threatDirection) { - turn.shoot(threatDirection); - } - }; + playTurn(turn: Turn) { + const threatDirection = RELATIVE_DIRECTIONS.find((direction) => { + const spaceWithUnit = turn.look(direction).find((space: any) => space.isUnit()); + return spaceWithUnit?.getUnit().isEnemy() && !spaceWithUnit.getUnit().isBound(); + }); + if (threatDirection) { + turn.shoot(threatDirection); + } } } From a3a59c43182b11ffc7ebb1f25e0b92c1c94723e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Olivera?= Date: Tue, 17 Mar 2026 18:46:53 -0300 Subject: [PATCH 38/39] test(units): add tests for MeleeUnit and RangedUnit base classes Co-Authored-By: Claude Opus 4.6 (1M context) --- libs/units/src/MeleeUnit.test.ts | 71 +++++++++++++++++++++++++++++ libs/units/src/RangedUnit.test.ts | 74 +++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 libs/units/src/MeleeUnit.test.ts create mode 100644 libs/units/src/RangedUnit.test.ts diff --git a/libs/units/src/MeleeUnit.test.ts b/libs/units/src/MeleeUnit.test.ts new file mode 100644 index 00000000..24a539fb --- /dev/null +++ b/libs/units/src/MeleeUnit.test.ts @@ -0,0 +1,71 @@ +import { BACKWARD, FORWARD, LEFT, RIGHT } from '@warriorjs/spatial'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import MeleeUnit from './MeleeUnit.js'; + +class TestMeleeUnit extends MeleeUnit { + constructor() { + super('Melee', 'm', '#aaa', 10); + } +} + +describe('MeleeUnit', () => { + let unit: TestMeleeUnit; + let turn: any; + let space: any; + + beforeEach(() => { + unit = new TestMeleeUnit(); + space = { getUnit: () => undefined }; + turn = { + attack: vi.fn(), + feel: vi.fn(() => space), + }; + }); + + test('feels in all directions looking for threats', () => { + unit.playTurn(turn); + expect(turn.feel).toHaveBeenCalledWith(FORWARD); + expect(turn.feel).toHaveBeenCalledWith(RIGHT); + expect(turn.feel).toHaveBeenCalledWith(BACKWARD); + expect(turn.feel).toHaveBeenCalledWith(LEFT); + }); + + test('attacks the first enemy it finds', () => { + turn.feel.mockReturnValueOnce({ + getUnit: () => ({ isEnemy: () => true, isBound: () => false }), + }); + unit.playTurn(turn); + expect(turn.attack).toHaveBeenCalledWith(FORWARD); + }); + + test('does not attack if no enemies found', () => { + unit.playTurn(turn); + expect(turn.attack).not.toHaveBeenCalled(); + }); + + test('does not attack bound enemies', () => { + turn.feel.mockReturnValue({ + getUnit: () => ({ isEnemy: () => true, isBound: () => true }), + }); + unit.playTurn(turn); + expect(turn.attack).not.toHaveBeenCalled(); + }); + + test('does not attack non-enemies', () => { + turn.feel.mockReturnValue({ + getUnit: () => ({ isEnemy: () => false, isBound: () => false }), + }); + unit.playTurn(turn); + expect(turn.attack).not.toHaveBeenCalled(); + }); + + test('stops looking once it finds a threat', () => { + turn.feel.mockReturnValueOnce({ getUnit: () => undefined }).mockReturnValueOnce({ + getUnit: () => ({ isEnemy: () => true, isBound: () => false }), + }); + unit.playTurn(turn); + expect(turn.feel).toHaveBeenCalledTimes(2); + expect(turn.attack).toHaveBeenCalledWith(RIGHT); + }); +}); diff --git a/libs/units/src/RangedUnit.test.ts b/libs/units/src/RangedUnit.test.ts new file mode 100644 index 00000000..11c9581f --- /dev/null +++ b/libs/units/src/RangedUnit.test.ts @@ -0,0 +1,74 @@ +import { BACKWARD, FORWARD, LEFT, RIGHT } from '@warriorjs/spatial'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import RangedUnit from './RangedUnit.js'; + +class TestRangedUnit extends RangedUnit { + constructor() { + super('Ranged', 'r', '#bbb', 8); + } +} + +describe('RangedUnit', () => { + let unit: TestRangedUnit; + let turn: any; + let emptySpaces: any[]; + + beforeEach(() => { + unit = new TestRangedUnit(); + emptySpaces = [{ isUnit: () => false }, { isUnit: () => false }]; + turn = { + shoot: vi.fn(), + look: vi.fn(() => emptySpaces), + }; + }); + + test('looks in all directions for threats', () => { + unit.playTurn(turn); + expect(turn.look).toHaveBeenCalledWith(FORWARD); + expect(turn.look).toHaveBeenCalledWith(RIGHT); + expect(turn.look).toHaveBeenCalledWith(BACKWARD); + expect(turn.look).toHaveBeenCalledWith(LEFT); + }); + + test('shoots the first direction with an enemy', () => { + turn.look.mockReturnValueOnce([ + { isUnit: () => false }, + { isUnit: () => true, getUnit: () => ({ isEnemy: () => true, isBound: () => false }) }, + ]); + unit.playTurn(turn); + expect(turn.shoot).toHaveBeenCalledWith(FORWARD); + }); + + test('does not shoot if no enemies found', () => { + unit.playTurn(turn); + expect(turn.shoot).not.toHaveBeenCalled(); + }); + + test('does not shoot bound enemies', () => { + turn.look.mockReturnValue([ + { isUnit: () => true, getUnit: () => ({ isEnemy: () => true, isBound: () => true }) }, + ]); + unit.playTurn(turn); + expect(turn.shoot).not.toHaveBeenCalled(); + }); + + test('does not shoot non-enemies', () => { + turn.look.mockReturnValue([ + { isUnit: () => true, getUnit: () => ({ isEnemy: () => false, isBound: () => false }) }, + ]); + unit.playTurn(turn); + expect(turn.shoot).not.toHaveBeenCalled(); + }); + + test('stops looking once it finds a threat', () => { + turn.look + .mockReturnValueOnce([{ isUnit: () => false }]) + .mockReturnValueOnce([ + { isUnit: () => true, getUnit: () => ({ isEnemy: () => true, isBound: () => false }) }, + ]); + unit.playTurn(turn); + expect(turn.look).toHaveBeenCalledTimes(2); + expect(turn.shoot).toHaveBeenCalledWith(RIGHT); + }); +}); From bb50e42c5775bfd5ed971675f2ac473665def277 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Olivera?= Date: Tue, 17 Mar 2026 18:55:00 -0300 Subject: [PATCH 39/39] test(units): simplify concrete unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove duplicated playTurn behavior tests from Sludge, ThickSludge, Archer, and Wizard — now covered by MeleeUnit and RangedUnit tests. Concrete tests focus on identity and declared abilities only. Co-Authored-By: Claude Opus 4.6 (1M context) --- libs/units/src/Archer.test.ts | 74 +----------------------------- libs/units/src/Sludge.test.ts | 44 +----------------- libs/units/src/ThickSludge.test.ts | 44 +----------------- libs/units/src/Wizard.test.ts | 74 +----------------------------- 4 files changed, 4 insertions(+), 232 deletions(-) diff --git a/libs/units/src/Archer.test.ts b/libs/units/src/Archer.test.ts index 5196efe0..e3e82266 100644 --- a/libs/units/src/Archer.test.ts +++ b/libs/units/src/Archer.test.ts @@ -1,5 +1,4 @@ -import { BACKWARD, FORWARD, LEFT, RIGHT } from '@warriorjs/spatial'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { beforeEach, describe, expect, test } from 'vitest'; import Archer from './Archer.js'; import RangedUnit from './RangedUnit.js'; @@ -34,75 +33,4 @@ describe('Archer', () => { test('has look ability', () => { expect(Archer.declaredAbilities).toHaveProperty('look'); }); - - describe('playing turn', () => { - let turn: any; - let space: any; - - beforeEach(() => { - space = { isUnit: () => false }; - turn = { - shoot: vi.fn(), - look: vi.fn(() => [space, space, space]), - }; - }); - - test('looks for player in all directions', () => { - archer.playTurn(turn); - expect(turn.look).toHaveBeenCalledWith(FORWARD); - expect(turn.look).toHaveBeenCalledWith(RIGHT); - expect(turn.look).toHaveBeenCalledWith(BACKWARD); - expect(turn.look).toHaveBeenCalledWith(LEFT); - }); - - test('stops looking in direction if it finds a space with a unit', () => { - const anotherSpace = { isUnit: vi.fn() }; - turn.look.mockReturnValue([ - space, - { - isUnit: () => true, - getUnit: () => ({ isEnemy: () => false }), - }, - anotherSpace, - ]); - archer.playTurn(turn); - expect(anotherSpace.isUnit).not.toHaveBeenCalled(); - }); - - test('stops looking if it finds threat', () => { - turn.look.mockReturnValueOnce([space, space, space]).mockReturnValueOnce([ - space, - { - isUnit: () => true, - getUnit: () => ({ - isBound: () => false, - isEnemy: () => true, - }), - }, - space, - ]); - archer.playTurn(turn); - expect(turn.look).toHaveBeenCalledWith(FORWARD); - expect(turn.look).toHaveBeenCalledWith(RIGHT); - expect(turn.look).not.toHaveBeenCalledWith(BACKWARD); - expect(turn.look).not.toHaveBeenCalledWith(LEFT); - expect(turn.shoot).toHaveBeenCalledWith(RIGHT); - }); - - test("does nothing if it doesn't find threat", () => { - turn.look.mockReturnValueOnce([ - space, - space, - { - isUnit: () => true, - getUnit: () => ({ - isBound: () => true, - isEnemy: () => true, - }), - }, - ]); - archer.playTurn(turn); - expect(turn.shoot).not.toHaveBeenCalled(); - }); - }); }); diff --git a/libs/units/src/Sludge.test.ts b/libs/units/src/Sludge.test.ts index 28867ae3..54b4bf5c 100644 --- a/libs/units/src/Sludge.test.ts +++ b/libs/units/src/Sludge.test.ts @@ -1,5 +1,4 @@ -import { BACKWARD, FORWARD, LEFT, RIGHT } from '@warriorjs/spatial'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { beforeEach, describe, expect, test } from 'vitest'; import MeleeUnit from './MeleeUnit.js'; import Sludge from './Sludge.js'; @@ -34,45 +33,4 @@ describe('Sludge', () => { test('has feel ability', () => { expect(Sludge.declaredAbilities).toHaveProperty('feel'); }); - - describe('playing turn', () => { - let turn: any; - let space: any; - - beforeEach(() => { - space = { getUnit: () => undefined }; - turn = { - attack: vi.fn(), - feel: vi.fn(() => space), - }; - }); - - test('looks for player in all directions', () => { - sludge.playTurn(turn); - expect(turn.feel).toHaveBeenCalledWith(FORWARD); - expect(turn.feel).toHaveBeenCalledWith(RIGHT); - expect(turn.feel).toHaveBeenCalledWith(BACKWARD); - expect(turn.feel).toHaveBeenCalledWith(LEFT); - }); - - test('stops looking if it finds threat', () => { - turn.feel.mockReturnValueOnce({ getUnit: () => undefined }).mockReturnValueOnce({ - getUnit: () => ({ - isBound: () => false, - isEnemy: () => true, - }), - }); - sludge.playTurn(turn); - expect(turn.feel).toHaveBeenCalledWith(FORWARD); - expect(turn.feel).toHaveBeenCalledWith(RIGHT); - expect(turn.feel).not.toHaveBeenCalledWith(BACKWARD); - expect(turn.feel).not.toHaveBeenCalledWith(LEFT); - expect(turn.attack).toHaveBeenCalledWith(RIGHT); - }); - - test("does nothing if it doesn't find threat", () => { - sludge.playTurn(turn); - expect(turn.attack).not.toHaveBeenCalled(); - }); - }); }); diff --git a/libs/units/src/ThickSludge.test.ts b/libs/units/src/ThickSludge.test.ts index 55d15f82..ab65f570 100644 --- a/libs/units/src/ThickSludge.test.ts +++ b/libs/units/src/ThickSludge.test.ts @@ -1,5 +1,4 @@ -import { BACKWARD, FORWARD, LEFT, RIGHT } from '@warriorjs/spatial'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { beforeEach, describe, expect, test } from 'vitest'; import MeleeUnit from './MeleeUnit.js'; import ThickSludge from './ThickSludge.js'; @@ -34,45 +33,4 @@ describe('ThickSludge', () => { test('has feel ability', () => { expect(ThickSludge.declaredAbilities).toHaveProperty('feel'); }); - - describe('playing turn', () => { - let turn: any; - let space: any; - - beforeEach(() => { - space = { getUnit: () => undefined }; - turn = { - attack: vi.fn(), - feel: vi.fn(() => space), - }; - }); - - test('looks for player in all directions', () => { - thickSludge.playTurn(turn); - expect(turn.feel).toHaveBeenCalledWith(FORWARD); - expect(turn.feel).toHaveBeenCalledWith(RIGHT); - expect(turn.feel).toHaveBeenCalledWith(BACKWARD); - expect(turn.feel).toHaveBeenCalledWith(LEFT); - }); - - test('stops looking if it finds threat', () => { - turn.feel.mockReturnValueOnce({ getUnit: () => undefined }).mockReturnValueOnce({ - getUnit: () => ({ - isBound: () => false, - isEnemy: () => true, - }), - }); - thickSludge.playTurn(turn); - expect(turn.feel).toHaveBeenCalledWith(FORWARD); - expect(turn.feel).toHaveBeenCalledWith(RIGHT); - expect(turn.feel).not.toHaveBeenCalledWith(BACKWARD); - expect(turn.feel).not.toHaveBeenCalledWith(LEFT); - expect(turn.attack).toHaveBeenCalledWith(RIGHT); - }); - - test("does nothing if it doesn't find threat", () => { - thickSludge.playTurn(turn); - expect(turn.attack).not.toHaveBeenCalled(); - }); - }); }); diff --git a/libs/units/src/Wizard.test.ts b/libs/units/src/Wizard.test.ts index 1e4a2b09..c5af5a40 100644 --- a/libs/units/src/Wizard.test.ts +++ b/libs/units/src/Wizard.test.ts @@ -1,5 +1,4 @@ -import { BACKWARD, FORWARD, LEFT, RIGHT } from '@warriorjs/spatial'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { beforeEach, describe, expect, test } from 'vitest'; import RangedUnit from './RangedUnit.js'; import Wizard from './Wizard.js'; @@ -34,75 +33,4 @@ describe('Wizard', () => { test('has look ability', () => { expect(Wizard.declaredAbilities).toHaveProperty('look'); }); - - describe('playing turn', () => { - let turn: any; - let space: any; - - beforeEach(() => { - space = { isUnit: () => false }; - turn = { - shoot: vi.fn(), - look: vi.fn(() => [space, space, space]), - }; - }); - - test('looks for player in all directions', () => { - wizard.playTurn(turn); - expect(turn.look).toHaveBeenCalledWith(FORWARD); - expect(turn.look).toHaveBeenCalledWith(RIGHT); - expect(turn.look).toHaveBeenCalledWith(BACKWARD); - expect(turn.look).toHaveBeenCalledWith(LEFT); - }); - - test('stops looking in direction if it finds a space with a unit', () => { - const anotherSpace = { isUnit: vi.fn() }; - turn.look.mockReturnValue([ - space, - { - isUnit: () => true, - getUnit: () => ({ isEnemy: () => false }), - }, - anotherSpace, - ]); - wizard.playTurn(turn); - expect(anotherSpace.isUnit).not.toHaveBeenCalled(); - }); - - test('stops looking if it finds threat', () => { - turn.look.mockReturnValueOnce([space, space, space]).mockReturnValueOnce([ - space, - { - isUnit: () => true, - getUnit: () => ({ - isBound: () => false, - isEnemy: () => true, - }), - }, - space, - ]); - wizard.playTurn(turn); - expect(turn.look).toHaveBeenCalledWith(FORWARD); - expect(turn.look).toHaveBeenCalledWith(RIGHT); - expect(turn.look).not.toHaveBeenCalledWith(BACKWARD); - expect(turn.look).not.toHaveBeenCalledWith(LEFT); - expect(turn.shoot).toHaveBeenCalledWith(RIGHT); - }); - - test("does nothing if it doesn't find threat", () => { - turn.look.mockReturnValueOnce([ - space, - space, - { - isUnit: () => true, - getUnit: () => ({ - isBound: () => true, - isEnemy: () => true, - }), - }, - ]); - wizard.playTurn(turn); - expect(turn.shoot).not.toHaveBeenCalled(); - }); - }); });