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/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); }); } diff --git a/apps/cli/src/utils/renderTypes.test.ts b/apps/cli/src/utils/renderTypes.test.ts index e3ceeb5b..fe7cc604 100644 --- a/apps/cli/src/utils/renderTypes.test.ts +++ b/apps/cli/src/utils/renderTypes.test.ts @@ -1,34 +1,34 @@ +import { type AbilityMeta, Action, Sense } from '@warriorjs/core'; 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 +38,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 +58,9 @@ describe('renderTypes', () => { renderTypes( profile, makeLevelConfig({ - health: mockAbilities.health, - walk: mockAbilities.walk, - feel: mockAbilities.feel, + health: MockHealth, + walk: MockWalk, + feel: MockFeel, }), ), ).toBe( @@ -107,12 +107,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.', '', @@ -129,39 +124,16 @@ describe('renderTypes', () => { ); }); - test('skips abilities without meta', () => { - const noMetaAbility = () => ({ - description: 'No meta', - perform() {}, - }); - expect( - renderTypes(profile, makeLevelConfig({ walk: mockAbilities.walk, 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', () => { - 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 +141,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..401cf6ce 100644 --- a/apps/cli/src/utils/renderTypes.ts +++ b/apps/cli/src/utils/renderTypes.ts @@ -1,4 +1,4 @@ -import 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,25 @@ function renderWarriorInterface(methods: MethodEntry[]): string { return `export interface Warrior {\n${body}\n}`; } +function instantiateAbility(entry: AbilityEntry): Ability { + if (Array.isArray(entry)) { + const [AbilityClass, config] = entry; + return new AbilityClass({} as any, config); + } + const AbilityClass = entry; + return new AbilityClass({} 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); - if (!ability.meta) { - continue; - } + for (const [name, entry] of Object.entries(abilities)) { + const ability = instantiateAbility(entry); - const { meta } = ability; + const { description, meta } = ability; const params: string[] = meta.params.map((param: any) => { const tsType = param.type; @@ -91,8 +97,8 @@ function renderTypes(_profile: Profile, levelConfig: LevelConfig): string { methods.push({ name, - action: !!ability.action, - description: ability.description, + description, + action: ability instanceof Action, signature: `${name}(${params.join(', ')}): ${returnType}`, }); } 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/attack.test.ts b/libs/abilities/src/Attack.test.ts similarity index 84% rename from libs/abilities/src/attack.test.ts rename to libs/abilities/src/Attack.test.ts index 109db294..20e78027 100644 --- a/libs/abilities/src/attack.test.ts +++ b/libs/abilities/src/Attack.test.ts @@ -1,10 +1,10 @@ +import { Action } from '@warriorjs/core'; import { BACKWARD, FORWARD, LEFT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; +import Attack from './Attack.js'; -import attackCreator from './attack.js'; - -describe('attack', () => { - let attack: ReturnType>; +describe('Attack', () => { + let attack: Attack; let unit: any; beforeEach(() => { @@ -12,11 +12,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 +32,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 new file mode 100644 index 00000000..1087dc9a --- /dev/null +++ b/libs/abilities/src/Attack.ts @@ -0,0 +1,45 @@ +import type { AbilityBinding } from '@warriorjs/core'; + +import { Action } from '@warriorjs/core'; +import { BACKWARD, FORWARD, type RelativeDirection } from '@warriorjs/spatial'; +import type { AbilityMeta, Unit } from './types.js'; + +const defaultDirection = FORWARD; + +interface AttackConfig { + power: number; +} + +class Attack extends Action { + 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.description = `Attacks a unit in the given direction (\`'${defaultDirection}'\` by default), dealing ${power} HP of damage.`; + this.power = power; + } + + 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]; + } +} + +export default Attack; diff --git a/libs/abilities/src/bind.test.ts b/libs/abilities/src/Bind.test.ts similarity index 90% rename from libs/abilities/src/bind.test.ts rename to libs/abilities/src/Bind.test.ts index 3d30419f..541c6575 100644 --- a/libs/abilities/src/bind.test.ts +++ b/libs/abilities/src/Bind.test.ts @@ -1,19 +1,19 @@ +import { Action } from '@warriorjs/core'; import { FORWARD, LEFT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; +import Bind from './Bind.js'; -import bindCreator 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 new file mode 100644 index 00000000..3e679996 --- /dev/null +++ b/libs/abilities/src/Bind.ts @@ -0,0 +1,26 @@ +import { Action } from '@warriorjs/core'; +import { FORWARD, type RelativeDirection } from '@warriorjs/spatial'; +import type { AbilityMeta } from './types.js'; + +const defaultDirection = FORWARD; + +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; diff --git a/libs/abilities/src/detonate.test.ts b/libs/abilities/src/Detonate.test.ts similarity index 84% rename from libs/abilities/src/detonate.test.ts rename to libs/abilities/src/Detonate.test.ts index 9919a759..a611dd05 100644 --- a/libs/abilities/src/detonate.test.ts +++ b/libs/abilities/src/Detonate.test.ts @@ -1,10 +1,10 @@ +import { Action } from '@warriorjs/core'; import { FORWARD, LEFT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; +import Detonate from './Detonate.js'; -import detonateCreator from './detonate.js'; - -describe('detonate', () => { - let detonate: ReturnType>; +describe('Detonate', () => { + let detonate: Detonate; let unit: any; beforeEach(() => { @@ -13,11 +13,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 +33,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 +75,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 new file mode 100644 index 00000000..c6f46194 --- /dev/null +++ b/libs/abilities/src/Detonate.ts @@ -0,0 +1,64 @@ +import type { AbilityBinding } from '@warriorjs/core'; + +import { Action } from '@warriorjs/core'; +import { FORWARD, type RelativeDirection } from '@warriorjs/spatial'; +import type { AbilityMeta, Space, Unit } from './types.js'; + +const defaultDirection = FORWARD; +const surroundingOffsets: [number, number][] = [ + [1, 1], + [1, -1], + [2, 0], + [0, 0], +]; + +interface DetonateConfig { + targetPower: number; + surroundingPower: number; +} + +class Detonate extends Action { + 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; + } + + 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'); + } + } + } + + static with(config: DetonateConfig): AbilityBinding { + return [Detonate, config]; + } +} + +export default Detonate; diff --git a/libs/abilities/src/directionOf.test.ts b/libs/abilities/src/DirectionOf.test.ts similarity index 73% rename from libs/abilities/src/directionOf.test.ts rename to libs/abilities/src/DirectionOf.test.ts index 495b98f3..e561395e 100644 --- a/libs/abilities/src/directionOf.test.ts +++ b/libs/abilities/src/DirectionOf.test.ts @@ -1,19 +1,19 @@ +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 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 new file mode 100644 index 00000000..d92d81b6 --- /dev/null +++ b/libs/abilities/src/DirectionOf.ts @@ -0,0 +1,18 @@ +import { Sense } from '@warriorjs/core'; +import { BACKWARD, FORWARD, LEFT, RIGHT } from '@warriorjs/spatial'; +import type { AbilityMeta } from './types.js'; + +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; diff --git a/libs/abilities/src/directionOfStairs.test.ts b/libs/abilities/src/DirectionOfStairs.test.ts similarity index 70% rename from libs/abilities/src/directionOfStairs.test.ts rename to libs/abilities/src/DirectionOfStairs.test.ts index 34f25a6c..cfa2496c 100644 --- a/libs/abilities/src/directionOfStairs.test.ts +++ b/libs/abilities/src/DirectionOfStairs.test.ts @@ -1,19 +1,19 @@ +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 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 new file mode 100644 index 00000000..58ae7764 --- /dev/null +++ b/libs/abilities/src/DirectionOfStairs.ts @@ -0,0 +1,18 @@ +import { Sense } from '@warriorjs/core'; +import { BACKWARD, FORWARD, LEFT, RIGHT } from '@warriorjs/spatial'; +import type { AbilityMeta } from './types.js'; + +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; diff --git a/libs/abilities/src/distanceOf.test.ts b/libs/abilities/src/DistanceOf.test.ts similarity index 71% rename from libs/abilities/src/distanceOf.test.ts rename to libs/abilities/src/DistanceOf.test.ts index 1668c66e..48bd052c 100644 --- a/libs/abilities/src/distanceOf.test.ts +++ b/libs/abilities/src/DistanceOf.test.ts @@ -1,18 +1,18 @@ +import { Sense } from '@warriorjs/core'; import { beforeEach, describe, expect, test, vi } from 'vitest'; +import DistanceOf from './DistanceOf.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 new file mode 100644 index 00000000..80f1009d --- /dev/null +++ b/libs/abilities/src/DistanceOf.ts @@ -0,0 +1,16 @@ +import { Sense } from '@warriorjs/core'; +import type { AbilityMeta } from './types.js'; + +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; diff --git a/libs/abilities/src/feel.test.ts b/libs/abilities/src/Feel.test.ts similarity index 82% rename from libs/abilities/src/feel.test.ts rename to libs/abilities/src/Feel.test.ts index 7b435f25..61016b29 100644 --- a/libs/abilities/src/feel.test.ts +++ b/libs/abilities/src/Feel.test.ts @@ -1,19 +1,19 @@ +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 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 new file mode 100644 index 00000000..d9510c73 --- /dev/null +++ b/libs/abilities/src/Feel.ts @@ -0,0 +1,20 @@ +import { Sense } from '@warriorjs/core'; +import { FORWARD, type RelativeDirection } from '@warriorjs/spatial'; +import type { AbilityMeta } from './types.js'; + +const defaultDirection = FORWARD; + +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; diff --git a/libs/abilities/src/health.test.ts b/libs/abilities/src/Health.test.ts similarity index 69% rename from libs/abilities/src/health.test.ts rename to libs/abilities/src/Health.test.ts index 9affe4ee..061497c3 100644 --- a/libs/abilities/src/health.test.ts +++ b/libs/abilities/src/Health.test.ts @@ -1,18 +1,18 @@ +import { Sense } from '@warriorjs/core'; import { beforeEach, describe, expect, test } from 'vitest'; +import Health from './Health.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 new file mode 100644 index 00000000..44d7390c --- /dev/null +++ b/libs/abilities/src/Health.ts @@ -0,0 +1,16 @@ +import { Sense } from '@warriorjs/core'; +import type { AbilityMeta } from './types.js'; + +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; diff --git a/libs/abilities/src/listen.test.ts b/libs/abilities/src/Listen.test.ts similarity index 82% rename from libs/abilities/src/listen.test.ts rename to libs/abilities/src/Listen.test.ts index f0c2b3b5..f6ebdfd6 100644 --- a/libs/abilities/src/listen.test.ts +++ b/libs/abilities/src/Listen.test.ts @@ -1,10 +1,10 @@ +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 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 new file mode 100644 index 00000000..2e8ae4a9 --- /dev/null +++ b/libs/abilities/src/Listen.ts @@ -0,0 +1,29 @@ +import { Sense } from '@warriorjs/core'; +import { FORWARD, getRelativeOffset } from '@warriorjs/spatial'; +import type { AbilityMeta } from './types.js'; + +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: any) => + getRelativeOffset( + anotherUnit.getSpace().location, + this.unit.position.location, + this.unit.position.orientation, + ), + ) + .map(([forward, right]: [number, number]) => + this.unit.getSensedSpaceAt(FORWARD, forward, right), + ); + } +} + +export default Listen; diff --git a/libs/abilities/src/look.test.ts b/libs/abilities/src/Look.test.ts similarity index 84% rename from libs/abilities/src/look.test.ts rename to libs/abilities/src/Look.test.ts index 2ac7fd20..c127e62b 100644 --- a/libs/abilities/src/look.test.ts +++ b/libs/abilities/src/Look.test.ts @@ -1,19 +1,19 @@ +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 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 new file mode 100644 index 00000000..21683448 --- /dev/null +++ b/libs/abilities/src/Look.ts @@ -0,0 +1,40 @@ +import type { AbilityBinding } from '@warriorjs/core'; + +import { Sense } from '@warriorjs/core'; +import { FORWARD, type RelativeDirection } from '@warriorjs/spatial'; +import type { AbilityMeta, Unit } from './types.js'; + +const defaultDirection = FORWARD; + +interface LookConfig { + range: number; +} + +class Look extends Sense { + 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.description = `Returns an array of up to ${range} spaces in the given direction (\`'${defaultDirection}'\` by default).`; + this.range = range; + } + + 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]; + } +} + +export default Look; diff --git a/libs/abilities/src/maxHealth.test.ts b/libs/abilities/src/MaxHealth.test.ts similarity index 68% rename from libs/abilities/src/maxHealth.test.ts rename to libs/abilities/src/MaxHealth.test.ts index 456c7ff8..03331a35 100644 --- a/libs/abilities/src/maxHealth.test.ts +++ b/libs/abilities/src/MaxHealth.test.ts @@ -1,18 +1,18 @@ +import { Sense } from '@warriorjs/core'; import { beforeEach, describe, expect, test } from 'vitest'; +import MaxHealth from './MaxHealth.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 new file mode 100644 index 00000000..4f81dca9 --- /dev/null +++ b/libs/abilities/src/MaxHealth.ts @@ -0,0 +1,16 @@ +import { Sense } from '@warriorjs/core'; +import type { AbilityMeta } from './types.js'; + +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; diff --git a/libs/abilities/src/pivot.test.ts b/libs/abilities/src/Pivot.test.ts similarity index 84% rename from libs/abilities/src/pivot.test.ts rename to libs/abilities/src/Pivot.test.ts index caeb388e..88505bea 100644 --- a/libs/abilities/src/pivot.test.ts +++ b/libs/abilities/src/Pivot.test.ts @@ -1,10 +1,10 @@ +import { Action } from '@warriorjs/core'; import { BACKWARD, RIGHT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; +import Pivot from './Pivot.js'; -import pivotCreator from './pivot.js'; - -describe('pivot', () => { - let pivot: ReturnType>; +describe('Pivot', () => { + let pivot: Pivot; let unit: any; beforeEach(() => { @@ -12,11 +12,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 new file mode 100644 index 00000000..3f2967a8 --- /dev/null +++ b/libs/abilities/src/Pivot.ts @@ -0,0 +1,20 @@ +import { Action } from '@warriorjs/core'; +import { BACKWARD, type RelativeDirection } from '@warriorjs/spatial'; +import type { AbilityMeta } from './types.js'; + +const defaultDirection = BACKWARD; + +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; diff --git a/libs/abilities/src/rescue.test.ts b/libs/abilities/src/Rescue.test.ts similarity index 91% rename from libs/abilities/src/rescue.test.ts rename to libs/abilities/src/Rescue.test.ts index ab907bc6..b8ee836d 100644 --- a/libs/abilities/src/rescue.test.ts +++ b/libs/abilities/src/Rescue.test.ts @@ -1,10 +1,10 @@ +import { Action } from '@warriorjs/core'; import { FORWARD, RIGHT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; +import Rescue from './Rescue.js'; -import rescueCreator from './rescue.js'; - -describe('rescue', () => { - let rescue: ReturnType>; +describe('Rescue', () => { + let rescue: Rescue; let unit: any; beforeEach(() => { @@ -12,11 +12,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 new file mode 100644 index 00000000..f72b327b --- /dev/null +++ b/libs/abilities/src/Rescue.ts @@ -0,0 +1,26 @@ +import { Action } from '@warriorjs/core'; +import { FORWARD, type RelativeDirection } from '@warriorjs/spatial'; +import type { AbilityMeta } from './types.js'; + +const defaultDirection = FORWARD; + +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; diff --git a/libs/abilities/src/rest.test.ts b/libs/abilities/src/Rest.test.ts similarity index 71% rename from libs/abilities/src/rest.test.ts rename to libs/abilities/src/Rest.test.ts index 5a575f23..e065179a 100644 --- a/libs/abilities/src/rest.test.ts +++ b/libs/abilities/src/Rest.test.ts @@ -1,9 +1,9 @@ +import { Action } from '@warriorjs/core'; import { beforeEach, describe, expect, test, vi } from 'vitest'; +import Rest from './Rest.js'; -import restCreator from './rest.js'; - -describe('rest', () => { - let rest: ReturnType>; +describe('Rest', () => { + let rest: Rest; let unit: any; beforeEach(() => { @@ -13,11 +13,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 +31,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 new file mode 100644 index 00000000..dad95935 --- /dev/null +++ b/libs/abilities/src/Rest.ts @@ -0,0 +1,39 @@ +import type { AbilityBinding } from '@warriorjs/core'; +import { Action } from '@warriorjs/core'; +import type { AbilityMeta, Unit } from './types.js'; + +interface RestConfig { + healthGain: number; +} + +class Rest extends Action { + 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; + } + + 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]; + } +} + +export default Rest; diff --git a/libs/abilities/src/shoot.test.ts b/libs/abilities/src/Shoot.test.ts similarity index 86% rename from libs/abilities/src/shoot.test.ts rename to libs/abilities/src/Shoot.test.ts index f542544c..24c926f5 100644 --- a/libs/abilities/src/shoot.test.ts +++ b/libs/abilities/src/Shoot.test.ts @@ -1,10 +1,10 @@ +import { Action } from '@warriorjs/core'; import { FORWARD, LEFT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; +import Shoot from './Shoot.js'; -import shootCreator from './shoot.js'; - -describe('shoot', () => { - let shoot: ReturnType>; +describe('Shoot', () => { + let shoot: Shoot; let unit: any; beforeEach(() => { @@ -12,11 +12,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 +32,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 new file mode 100644 index 00000000..49ebe981 --- /dev/null +++ b/libs/abilities/src/Shoot.ts @@ -0,0 +1,49 @@ +import type { AbilityBinding } from '@warriorjs/core'; + +import { Action } from '@warriorjs/core'; +import { FORWARD, type RelativeDirection } from '@warriorjs/spatial'; +import type { AbilityMeta, Unit } from './types.js'; + +const defaultDirection = FORWARD; + +interface ShootConfig { + power: number; + range: number; +} + +class Shoot extends Action { + 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; + } + + 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]; + } +} + +export default Shoot; diff --git a/libs/abilities/src/think.test.ts b/libs/abilities/src/Think.test.ts similarity index 80% rename from libs/abilities/src/think.test.ts rename to libs/abilities/src/Think.test.ts index 0317bee1..deba0642 100644 --- a/libs/abilities/src/think.test.ts +++ b/libs/abilities/src/Think.test.ts @@ -1,18 +1,18 @@ +import { Sense } from '@warriorjs/core'; import { beforeEach, describe, expect, test, vi } from 'vitest'; +import Think from './Think.js'; -import thinkCreator 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 new file mode 100644 index 00000000..b13618a9 --- /dev/null +++ b/libs/abilities/src/Think.ts @@ -0,0 +1,19 @@ +import util from 'node:util'; + +import { Sense } from '@warriorjs/core'; +import type { AbilityMeta } from './types.js'; + +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; diff --git a/libs/abilities/src/walk.test.ts b/libs/abilities/src/Walk.test.ts similarity index 86% rename from libs/abilities/src/walk.test.ts rename to libs/abilities/src/Walk.test.ts index 5fd477df..26801bdb 100644 --- a/libs/abilities/src/walk.test.ts +++ b/libs/abilities/src/Walk.test.ts @@ -1,10 +1,10 @@ +import { Action } from '@warriorjs/core'; import { FORWARD, RIGHT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; +import Walk from './Walk.js'; -import walkCreator from './walk.js'; - -describe('walk', () => { - let walk: ReturnType>; +describe('Walk', () => { + let walk: Walk; let unit: any; beforeEach(() => { @@ -12,11 +12,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 +55,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 new file mode 100644 index 00000000..25073f90 --- /dev/null +++ b/libs/abilities/src/Walk.ts @@ -0,0 +1,26 @@ +import { Action } from '@warriorjs/core'; +import { FORWARD, type RelativeDirection } from '@warriorjs/spatial'; +import type { AbilityMeta } from './types.js'; + +const defaultDirection = FORWARD; + +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; diff --git a/libs/abilities/src/attack.ts b/libs/abilities/src/attack.ts deleted file mode 100644 index c5d8022c..00000000 --- a/libs/abilities/src/attack.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { BACKWARD, FORWARD, type RelativeDirection } from '@warriorjs/spatial'; - -import type { 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, - }, - }); -} - -export default attack; diff --git a/libs/abilities/src/bind.ts b/libs/abilities/src/bind.ts deleted file mode 100644 index f71a42ac..00000000 --- a/libs/abilities/src/bind.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { FORWARD, type RelativeDirection } from '@warriorjs/spatial'; - -import type { Unit } 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, - }, - }); -} - -export default bind; diff --git a/libs/abilities/src/detonate.ts b/libs/abilities/src/detonate.ts deleted file mode 100644 index be9fd04f..00000000 --- a/libs/abilities/src/detonate.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { FORWARD, type RelativeDirection } from '@warriorjs/spatial'; - -import type { Space, Unit } from './types.js'; - -const defaultDirection = FORWARD; -const surroundingOffsets: [number, number][] = [ - [1, 1], - [1, -1], - [2, 0], - [0, 0], -]; - -function detonate({ - targetPower, - surroundingPower, -}: { - 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'); - } - } - }, - meta: { - params: [{ name: 'direction', type: 'Direction' as const, optional: true }], - returns: 'void' as const, - }, - }); -} - -export default detonate; diff --git a/libs/abilities/src/directionOf.ts b/libs/abilities/src/directionOf.ts deleted file mode 100644 index a84ce7d6..00000000 --- a/libs/abilities/src/directionOf.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { BACKWARD, FORWARD, LEFT, RIGHT } from '@warriorjs/spatial'; - -import type { Unit } 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, - }, - }); -} - -export default directionOf; diff --git a/libs/abilities/src/directionOfStairs.ts b/libs/abilities/src/directionOfStairs.ts deleted file mode 100644 index c02e4972..00000000 --- a/libs/abilities/src/directionOfStairs.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { BACKWARD, FORWARD, LEFT, RIGHT } from '@warriorjs/spatial'; - -import type { Unit } 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, - }, - }); -} - -export default directionOfStairs; diff --git a/libs/abilities/src/distanceOf.ts b/libs/abilities/src/distanceOf.ts deleted file mode 100644 index 5f97c45c..00000000 --- a/libs/abilities/src/distanceOf.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { Unit } 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, - }, - }); -} - -export default distanceOf; diff --git a/libs/abilities/src/feel.ts b/libs/abilities/src/feel.ts deleted file mode 100644 index 57d277bb..00000000 --- a/libs/abilities/src/feel.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { FORWARD, type RelativeDirection } from '@warriorjs/spatial'; - -import type { Unit } 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, - }, - }); -} - -export default feel; diff --git a/libs/abilities/src/health.ts b/libs/abilities/src/health.ts deleted file mode 100644 index 543553b4..00000000 --- a/libs/abilities/src/health.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { Unit } 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, - }, - }); -} - -export default health; diff --git a/libs/abilities/src/index.ts b/libs/abilities/src/index.ts index f7467bee..8ca35c55 100644 --- a/libs/abilities/src/index.ts +++ b/libs/abilities/src/index.ts @@ -1,18 +1,18 @@ -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 shoot } from './shoot.js'; -export { default as think } from './think.js'; -export type { Ability, AbilityCreator, SensedSpace, Space, Unit } from './types.js'; -export { default as walk } from './walk.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 Shoot } from './Shoot.js'; +export { default as Think } from './Think.js'; +export type { SensedSpace, Space, Unit } from './types.js'; +export { default as Walk } from './Walk.js'; diff --git a/libs/abilities/src/listen.ts b/libs/abilities/src/listen.ts deleted file mode 100644 index 7e20d75a..00000000 --- a/libs/abilities/src/listen.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { FORWARD, getRelativeOffset } from '@warriorjs/spatial'; - -import type { Unit } 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, - }, - }); -} - -export default listen; diff --git a/libs/abilities/src/look.ts b/libs/abilities/src/look.ts deleted file mode 100644 index fc6f78d0..00000000 --- a/libs/abilities/src/look.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { FORWARD, type RelativeDirection } from '@warriorjs/spatial'; - -import type { 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, - }, - }); -} - -export default look; diff --git a/libs/abilities/src/maxHealth.ts b/libs/abilities/src/maxHealth.ts deleted file mode 100644 index 985db0dc..00000000 --- a/libs/abilities/src/maxHealth.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { Unit } 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, - }, - }); -} - -export default maxHealth; diff --git a/libs/abilities/src/pivot.ts b/libs/abilities/src/pivot.ts deleted file mode 100644 index 270ffffc..00000000 --- a/libs/abilities/src/pivot.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { BACKWARD, type RelativeDirection } from '@warriorjs/spatial'; - -import type { Unit } 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, - }, - }); -} - -export default pivot; diff --git a/libs/abilities/src/rescue.ts b/libs/abilities/src/rescue.ts deleted file mode 100644 index ea57f805..00000000 --- a/libs/abilities/src/rescue.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { FORWARD, type RelativeDirection } from '@warriorjs/spatial'; - -import type { Unit } 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, - }, - }); -} - -export default rescue; diff --git a/libs/abilities/src/rest.ts b/libs/abilities/src/rest.ts deleted file mode 100644 index 25779260..00000000 --- a/libs/abilities/src/rest.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { 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, - }, - }); -} - -export default rest; diff --git a/libs/abilities/src/shoot.ts b/libs/abilities/src/shoot.ts deleted file mode 100644 index aaa6cba7..00000000 --- a/libs/abilities/src/shoot.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { FORWARD, type RelativeDirection } from '@warriorjs/spatial'; - -import type { 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, - }, - }); -} - -export default shoot; diff --git a/libs/abilities/src/think.ts b/libs/abilities/src/think.ts deleted file mode 100644 index c513ff51..00000000 --- a/libs/abilities/src/think.ts +++ /dev/null @@ -1,19 +0,0 @@ -import util from 'node:util'; - -import type { Unit } 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, - }, - }); -} - -export default think; 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; diff --git a/libs/abilities/src/walk.ts b/libs/abilities/src/walk.ts deleted file mode 100644 index df8254e1..00000000 --- a/libs/abilities/src/walk.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { FORWARD, type RelativeDirection } from '@warriorjs/spatial'; - -import type { Unit } 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, - }, - }); -} - -export default walk; diff --git a/libs/core/src/Ability.test.ts b/libs/core/src/Ability.test.ts new file mode 100644 index 00000000..58364455 --- /dev/null +++ b/libs/core/src/Ability.test.ts @@ -0,0 +1,30 @@ +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'; + +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/core/src/Ability.ts b/libs/core/src/Ability.ts new file mode 100644 index 00000000..10202309 --- /dev/null +++ b/libs/core/src/Ability.ts @@ -0,0 +1,34 @@ +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 interface AbilityClass { + new (unit: any, config?: any): Ability; +} + +export type AbilityBinding = [AbilityClass, object]; + +export type AbilityEntry = AbilityBinding | AbilityClass; + +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/core/src/Action.test.ts b/libs/core/src/Action.test.ts new file mode 100644 index 00000000..093a1baa --- /dev/null +++ b/libs/core/src/Action.test.ts @@ -0,0 +1,46 @@ +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'; + +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/core/src/Action.ts b/libs/core/src/Action.ts new file mode 100644 index 00000000..51a4a901 --- /dev/null +++ b/libs/core/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/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 }); + }); +}); diff --git a/libs/core/src/Effect.ts b/libs/core/src/Effect.ts new file mode 100644 index 00000000..9c54ae29 --- /dev/null +++ b/libs/core/src/Effect.ts @@ -0,0 +1,22 @@ +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; + + 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/Sense.test.ts b/libs/core/src/Sense.test.ts new file mode 100644 index 00000000..8bc9c999 --- /dev/null +++ b/libs/core/src/Sense.test.ts @@ -0,0 +1,35 @@ +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'; + +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); + }); +}); diff --git a/libs/core/src/Sense.ts b/libs/core/src/Sense.ts new file mode 100644 index 00000000..ae70d0ff --- /dev/null +++ b/libs/core/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/core/src/Unit.test.ts b/libs/core/src/Unit.test.ts index 722e3f42..25681dd6 100644 --- a/libs/core/src/Unit.test.ts +++ b/libs/core/src/Unit.test.ts @@ -1,9 +1,22 @@ 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 { + 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; @@ -77,21 +90,18 @@ 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', () => { 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..8665aaad 100644 --- a/libs/core/src/Unit.ts +++ b/libs/core/src/Unit.ts @@ -1,26 +1,27 @@ +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'; 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; -} - -interface Effect { - passTurn(): void; - trigger(): void; -} +export type Turn = Record any>; -interface Turn { +interface TurnState { action: [string, any[]] | null; [key: string]: any; } +export interface UnitClass { + new (): Unit; + declaredAbilities?: Record; +} + /** Class representing a unit. */ class Unit { + static declaredAbilities?: Record; name: string; character: string; color: string; @@ -33,8 +34,7 @@ class Unit { score: number; abilities: Map; effects: Map; - turn: Turn | Record; - playTurn: (turn: any) => void; + turn: TurnState | null; constructor( name?: string, @@ -57,14 +57,13 @@ 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.action) { + if (ability instanceof Action) { Object.defineProperty(turn, name, { value: (...args: any[]) => { if (turn.action) { @@ -83,6 +82,8 @@ class Unit { return turn; } + playTurn(_turn: Turn): void {} + prepareTurn(): void { this.turn = this.getNextTurn(); this.playTurn(this.turn); @@ -91,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/Warrior.test.ts b/libs/core/src/Warrior.test.ts index d2f63f9a..1baade9e 100644 --- a/libs/core/src/Warrior.test.ts +++ b/libs/core/src/Warrior.test.ts @@ -1,14 +1,36 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; +import Action from './Action.js'; +import Sense from './Sense.js'; 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..7665ab6b 100644 --- a/libs/core/src/Warrior.ts +++ b/libs/core/src/Warrior.ts @@ -1,8 +1,9 @@ +import Action from './Action.js'; import Unit from './Unit.js'; interface AbilityInfo { name: string; - action?: boolean; + isAction: boolean; description?: string; } @@ -31,21 +32,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..2580ce37 100644 --- a/libs/core/src/getLevel.test.ts +++ b/libs/core/src/getLevel.test.ts @@ -1,7 +1,66 @@ -import { EAST, FORWARD, RELATIVE_DIRECTIONS, WEST } from '@warriorjs/spatial'; +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'; +import Unit from './Unit.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() {} +} + +class TestSludge extends Unit { + static 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, @@ -9,67 +68,24 @@ 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: () => ({ - 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).`, - }), - }, - position: { - x: 0, - y: 0, - facing: EAST, + walk: TestWalk, + attack: TestAttack.with({ power: 5 }), + feel: TestFeel, }, + position: { x: 0, y: 0, facing: EAST }, }, units: [ { - name: 'Sludge', - character: 's', - 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).`, - }), - }, - 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); - } - }, - position: { - x: 4, - y: 0, - facing: WEST, - }, + unit: TestSludge, + position: { x: 4, y: 0, facing: WEST }, }, ], }, @@ -98,22 +114,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: ' ' }, @@ -133,10 +141,7 @@ test('returns level', () => { { character: '\u255d' }, ], ], - warriorStatus: { - health: 20, - score: 0, - }, + warriorStatus: { health: 20, score: 0 }, warriorAbilities: { actions: [ { 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 712d4566..68da3b31 100644 --- a/libs/core/src/getLevelConfig.ts +++ b/libs/core/src/getLevelConfig.ts @@ -1,11 +1,7 @@ -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') { + if (obj === null || typeof obj !== 'object' || obj.constructor !== Object) { return obj; } @@ -31,7 +27,7 @@ function deepClone(obj: T): T { * @returns The level config. */ function getLevelConfig( - tower: Tower, + tower: TowerDefinition, levelNumber: number, warriorName: string, epic: boolean, @@ -41,7 +37,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( @@ -56,8 +52,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 02facd01..efae9893 100644 --- a/libs/core/src/index.ts +++ b/libs/core/src/index.ts @@ -1,11 +1,21 @@ +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, 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'; export type { TurnEvent } from './Logger.js'; export { default as runLevel } from './runLevel.js'; +export { default as Sense } from './Sense.js'; export type { LevelConfig, + LevelDefinition, TowerDefinition, - TowerFloorUnit, - TowerLevel, UnitConfig, + WarriorConfig, + WarriorDefinition, + WarriorOverrides, } from './types.js'; +export type { Turn, 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 0683181d..bf9147c7 100644 --- a/libs/core/src/loadLevel.ts +++ b/libs/core/src/loadLevel.ts @@ -1,58 +1,56 @@ +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'; import type { LevelConfig, UnitConfig } from './types.js'; -import Unit from './Unit.js'; +import type 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); - }); +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 { + const AbilityClass = entry; + unit.addAbility(name, new AbilityClass(unit)); + } + } } -function loadEffects(unit: Unit, effects: Record any> = {}): void { - Object.entries(effects).forEach(([effectName, effectCreator]) => { - 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 { + const EffectClass = entry; + unit.addEffect(name, new EffectClass(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); + const { name, character, color, maxHealth, abilities, position } = warrior; + const unit = new Warrior(name, character, color, maxHealth); + loadAbilities(unit, abilities); + unit.playTurn = playerCode ? loadPlayer(playerCode, language) : () => {}; + floor.addWarrior(unit, position); } -function loadUnit( - { - name, - character, - color, - maxHealth, - reward, - enemy, - bound, - abilities, - effects, - playTurn, - position, - }: UnitConfig, - floor: Floor, -): void { - const unit = new Unit(name, character, color, maxHealth, reward, enemy, bound); - loadAbilities(unit, abilities); - loadEffects(unit, effects); - if (playTurn) { - unit.playTurn = playTurn; +function loadUnit({ unit: UnitClass, effects, position }: UnitConfig, floor: Floor): void { + const unit = new UnitClass(); + if (UnitClass.declaredAbilities) { + loadAbilities(unit, UnitClass.declaredAbilities); + } + if (effects) { + loadEffects(unit, effects); } floor.addUnit(unit, position); } @@ -67,7 +65,9 @@ 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) { + loadUnit(entry as UnitConfig, floor); + } return new Level(number!, description!, tip!, clue!, floor); } 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/core/src/runLevel.test.ts b/libs/core/src/runLevel.test.ts index 6ce3db2d..92944d14 100644 --- a/libs/core/src/runLevel.test.ts +++ b/libs/core/src/runLevel.test.ts @@ -1,101 +1,107 @@ 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'; +import Unit from './Unit.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); + } +} + +class TestSludge extends Unit { + static 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, - }, - 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: (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); - }, - }), - }, - position: { - x: 0, - y: 0, - facing: EAST, + walk: TestWalk, + attack: TestAttack.with({ power: 5 }), + feel: TestFeel, }, + position: { x: 0, y: 0, facing: EAST }, }, units: [ { - name: 'Sludge', - 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); - }, - }), - }, - 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); - } - }, - position: { - x: 4, - y: 0, - facing: WEST, - }, + unit: TestSludge, + position: { x: 4, y: 0, facing: WEST }, }, ], }, 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 bdb5f94f..56c7f273 100644 --- a/libs/core/src/types.ts +++ b/libs/core/src/types.ts @@ -1,15 +1,20 @@ +import type { AbilityEntry } from './Ability.js'; +import type { EffectEntry } from './Effect.js'; +import type { UnitClass } from './Unit.js'; + export interface UnitConfig { - name: string; + unit: UnitClass; + position: { x: number; y: number; facing: string }; + effects?: Record; +} + +export interface WarriorConfig { + name?: string; character: string; color: string; maxHealth: number; - reward?: number; - enemy?: boolean; - bound?: boolean; - abilities?: Record any>; - effects?: Record any>; - playTurn?: (turn: any) => void; position: { x: number; y: number; facing: string }; + abilities?: Record; } export interface LevelConfig { @@ -22,17 +27,24 @@ export interface LevelConfig { floor: { size: { width: number; height: number }; stairs: { x: number; y: number }; - warrior: UnitConfig; + warrior: WarriorConfig; units?: UnitConfig[]; }; } -export interface TowerFloorUnit { - [key: string]: unknown; - position: { x: number; y: number; facing?: string }; +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 TowerLevel { +export interface LevelDefinition { description: string; tip: string; clue?: string; @@ -41,13 +53,14 @@ export interface TowerLevel { floor: { size: { width: number; height: number }; stairs: { x: number; y: number }; - warrior: TowerFloorUnit; - units: TowerFloorUnit[]; + warrior: WarriorOverrides; + units: UnitConfig[]; }; } export interface TowerDefinition { name: string; description: string; - levels: TowerLevel[]; + warrior: WarriorDefinition; + levels: LevelDefinition[]; } 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/ticking.test.ts b/libs/effects/src/Ticking.test.ts similarity index 79% rename from libs/effects/src/ticking.test.ts rename to libs/effects/src/Ticking.test.ts index 7bf6a31b..ea316799 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 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 f6047f19..4af8b403 100644 --- a/libs/effects/src/index.ts +++ b/libs/effects/src/index.ts @@ -1 +1 @@ -export { default as ticking } from './ticking.js'; // eslint-disable-line import/prefer-default-export +export { default as Ticking } from './Ticking.js'; diff --git a/libs/effects/src/ticking.ts b/libs/effects/src/ticking.ts deleted file mode 100644 index 9e0183cb..00000000 --- a/libs/effects/src/ticking.ts +++ /dev/null @@ -1,39 +0,0 @@ -interface Unit { - health: number; - takeDamage(amount: number): void; - log(message: string): void; - getOtherUnits(): Unit[]; -} - -interface TickingEffect { - 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), - ); - }, - }); -} - -export default ticking; 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..e3e82266 100644 --- a/libs/units/src/Archer.test.ts +++ b/libs/units/src/Archer.test.ts @@ -1,99 +1,36 @@ -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'; - -vi.mock('@warriorjs/abilities'); +import RangedUnit from './RangedUnit.js'; describe('Archer', () => { - test("appears as 'a' on map", () => { - expect(Archer.character).toBe('a'); - }); + let archer: Archer; - test('has #ebcb8b color', () => { - expect(Archer.color).toBe('#ebcb8b'); + beforeEach(() => { + archer = new Archer(); }); - test('has 7 max health', () => { - expect(Archer.maxHealth).toBe(7); + test('extends RangedUnit', () => { + expect(archer).toBeInstanceOf(RangedUnit); }); - test('has shoot ability with power 3 and range 3', () => { - expect(Archer.abilities).toHaveProperty('shoot'); + test("appears as 'a' on map", () => { + expect(archer.character).toBe('a'); }); - test('has look ability with range 3', () => { - expect(Archer.abilities).toHaveProperty('look'); + test('has #ebcb8b color', () => { + expect(archer.color).toBe('#ebcb8b'); }); - 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('has 7 max health', () => { + expect(archer.maxHealth).toBe(7); + }); - 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('has shoot ability', () => { + expect(Archer.declaredAbilities).toHaveProperty('shoot'); + }); - 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(); - }); + test('has look ability', () => { + expect(Archer.declaredAbilities).toHaveProperty('look'); }); }); diff --git a/libs/units/src/Archer.ts b/libs/units/src/Archer.ts index 623089a5..d6338b0c 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'; +import { Look, Shoot } from '@warriorjs/abilities'; -export interface UnitTurn { - look(direction: string): Array<{ - isUnit(): boolean; - getUnit(): { isEnemy(): boolean; isBound(): boolean }; - }>; - shoot(direction: string): void; -} +import RangedUnit from './RangedUnit.js'; + +class Archer extends RangedUnit { + static declaredAbilities = { + look: Look.with({ range: 3 }), + shoot: Shoot.with({ range: 3, power: 3 }), + }; -const Archer = { - name: 'Archer', - character: 'a', - color: '#ebcb8b', - maxHealth: 7, - abilities: { - look: look({ range: 3 }), - shoot: shoot({ 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.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/MeleeUnit.ts b/libs/units/src/MeleeUnit.ts new file mode 100644 index 00000000..f9ce8dfa --- /dev/null +++ b/libs/units/src/MeleeUnit.ts @@ -0,0 +1,16 @@ +import { type Turn, Unit } from '@warriorjs/core'; +import { RELATIVE_DIRECTIONS } from '@warriorjs/spatial'; + +abstract class MeleeUnit extends Unit { + 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); + } + } +} + +export default MeleeUnit; 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); + }); +}); diff --git a/libs/units/src/RangedUnit.ts b/libs/units/src/RangedUnit.ts new file mode 100644 index 00000000..52b1bbda --- /dev/null +++ b/libs/units/src/RangedUnit.ts @@ -0,0 +1,16 @@ +import { type Turn, Unit } from '@warriorjs/core'; +import { RELATIVE_DIRECTIONS } from '@warriorjs/spatial'; + +abstract class RangedUnit extends Unit { + 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); + } + } +} + +export default RangedUnit; diff --git a/libs/units/src/Sludge.test.ts b/libs/units/src/Sludge.test.ts index 22fd4d13..54b4bf5c 100644 --- a/libs/units/src/Sludge.test.ts +++ b/libs/units/src/Sludge.test.ts @@ -1,69 +1,36 @@ -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'; -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'); - }); - - 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(); - }); + expect(Sludge.declaredAbilities).toHaveProperty('feel'); }); }); diff --git a/libs/units/src/Sludge.ts b/libs/units/src/Sludge.ts index 9e5c7e2e..7d19faa2 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'; +import { Attack, Feel } from '@warriorjs/abilities'; -export interface UnitTurn { - feel(direction: string): { - getUnit(): { isEnemy(): boolean; isBound(): boolean } | undefined; +import MeleeUnit from './MeleeUnit.js'; + +class Sludge extends MeleeUnit { + static declaredAbilities = { + attack: Attack.with({ power: 3 }), + feel: Feel, }; - attack(direction: string): void; -} -const Sludge = { - name: 'Sludge', - character: 's', - color: '#d08770', - maxHealth: 12, - abilities: { - attack: attack({ 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..ab65f570 100644 --- a/libs/units/src/ThickSludge.test.ts +++ b/libs/units/src/ThickSludge.test.ts @@ -1,69 +1,36 @@ -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'; -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'); - }); - - 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(); - }); + expect(ThickSludge.declaredAbilities).toHaveProperty('feel'); }); }); diff --git a/libs/units/src/ThickSludge.ts b/libs/units/src/ThickSludge.ts index dc2d3feb..a3db6418 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 { + static declaredAbilities = { + attack: Attack.with({ power: 3 }), + feel: Feel, + }; + + constructor() { + super('Thick Sludge', 'S', '#bf616a', 24); + } +} export default ThickSludge; 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/Wizard.test.ts b/libs/units/src/Wizard.test.ts index f5dac4b7..c5af5a40 100644 --- a/libs/units/src/Wizard.test.ts +++ b/libs/units/src/Wizard.test.ts @@ -1,99 +1,36 @@ -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'; -vi.mock('@warriorjs/abilities'); - describe('Wizard', () => { - test("appears as 'w' on map", () => { - expect(Wizard.character).toBe('w'); - }); + let wizard: Wizard; - test('has #b48ead color', () => { - expect(Wizard.color).toBe('#b48ead'); + beforeEach(() => { + wizard = new Wizard(); }); - test('has 3 max health', () => { - expect(Wizard.maxHealth).toBe(3); + test('extends RangedUnit', () => { + expect(wizard).toBeInstanceOf(RangedUnit); }); - test('has shoot ability with power 11 and range 3', () => { - expect(Wizard.abilities).toHaveProperty('shoot'); + test("appears as 'w' on map", () => { + expect(wizard.character).toBe('w'); }); - test('has look ability with range 3', () => { - expect(Wizard.abilities).toHaveProperty('look'); + test('has #b48ead color', () => { + expect(wizard.color).toBe('#b48ead'); }); - 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('has 3 max health', () => { + expect(wizard.maxHealth).toBe(3); + }); - 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('has shoot ability', () => { + expect(Wizard.declaredAbilities).toHaveProperty('shoot'); + }); - 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(); - }); + test('has look ability', () => { + expect(Wizard.declaredAbilities).toHaveProperty('look'); }); }); diff --git a/libs/units/src/Wizard.ts b/libs/units/src/Wizard.ts index 54627775..60a42f3c 100644 --- a/libs/units/src/Wizard.ts +++ b/libs/units/src/Wizard.ts @@ -1,17 +1,16 @@ -import { look, shoot } from '@warriorjs/abilities'; +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: { - look: look({ range: 3 }), - shoot: shoot({ range: 3, power: 11 }), - }, -}; +class Wizard extends RangedUnit { + static 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..34fb5e5a 100644 --- a/libs/units/src/index.ts +++ b/libs/units/src/index.ts @@ -2,5 +2,4 @@ export { default as Archer } from './Archer.js'; export { default as Captive } from './Captive.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/pnpm-lock.yaml b/pnpm-lock.yaml index e8a2ff46..d26049d1 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 @@ -85,6 +88,9 @@ importers: libs/abilities: dependencies: + '@warriorjs/core': + specifier: workspace:^ + version: link:../core '@warriorjs/spatial': specifier: workspace:^ version: link:../spatial @@ -98,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: {} @@ -109,6 +119,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 57f6a206..f7efc20a 100644 --- a/towers/the-narrow-path/src/index.ts +++ b/towers/the-narrow-path/src/index.ts @@ -1,23 +1,28 @@ 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'; -import { Archer, Captive, Sludge, ThickSludge, Warrior, Wizard } from '@warriorjs/units'; +import { Archer, Captive, Sludge, ThickSludge, Wizard } from '@warriorjs/units'; 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: @@ -35,10 +40,9 @@ const tower: TowerDefinition = { y: 0, }, warrior: { - ...Warrior, abilities: { - think: think(), - walk: walk(), + think: Think, + walk: Walk, }, position: { x: 0, @@ -66,10 +70,9 @@ const tower: TowerDefinition = { y: 0, }, warrior: { - ...Warrior, abilities: { - attack: attack({ power: 5 }), - feel: feel(), + attack: Attack.with({ power: 5 }), + feel: Feel, }, position: { x: 0, @@ -79,7 +82,7 @@ const tower: TowerDefinition = { }, units: [ { - ...Sludge, + unit: Sludge, position: { x: 4, y: 0, @@ -106,11 +109,10 @@ const tower: TowerDefinition = { y: 0, }, 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, @@ -120,7 +122,7 @@ const tower: TowerDefinition = { }, units: [ { - ...Sludge, + unit: Sludge, position: { x: 2, y: 0, @@ -128,7 +130,7 @@ const tower: TowerDefinition = { }, }, { - ...Sludge, + unit: Sludge, position: { x: 4, y: 0, @@ -136,7 +138,7 @@ const tower: TowerDefinition = { }, }, { - ...Sludge, + unit: Sludge, position: { x: 5, y: 0, @@ -144,7 +146,7 @@ const tower: TowerDefinition = { }, }, { - ...Sludge, + unit: Sludge, position: { x: 7, y: 0, @@ -171,7 +173,6 @@ const tower: TowerDefinition = { y: 0, }, warrior: { - ...Warrior, position: { x: 0, y: 0, @@ -180,7 +181,7 @@ const tower: TowerDefinition = { }, units: [ { - ...ThickSludge, + unit: ThickSludge, position: { x: 2, y: 0, @@ -188,7 +189,7 @@ const tower: TowerDefinition = { }, }, { - ...Archer, + unit: Archer, position: { x: 3, y: 0, @@ -196,7 +197,7 @@ const tower: TowerDefinition = { }, }, { - ...ThickSludge, + unit: ThickSludge, position: { x: 5, y: 0, @@ -222,9 +223,8 @@ const tower: TowerDefinition = { y: 0, }, warrior: { - ...Warrior, abilities: { - rescue: rescue(), + rescue: Rescue, }, position: { x: 0, @@ -234,7 +234,7 @@ const tower: TowerDefinition = { }, units: [ { - ...Captive, + unit: Captive, position: { x: 2, y: 0, @@ -242,7 +242,7 @@ const tower: TowerDefinition = { }, }, { - ...Archer, + unit: Archer, position: { x: 3, y: 0, @@ -250,7 +250,7 @@ const tower: TowerDefinition = { }, }, { - ...Archer, + unit: Archer, position: { x: 4, y: 0, @@ -258,7 +258,7 @@ const tower: TowerDefinition = { }, }, { - ...ThickSludge, + unit: ThickSludge, position: { x: 5, y: 0, @@ -266,7 +266,7 @@ const tower: TowerDefinition = { }, }, { - ...Captive, + unit: Captive, position: { x: 6, y: 0, @@ -293,7 +293,6 @@ const tower: TowerDefinition = { y: 0, }, warrior: { - ...Warrior, position: { x: 2, y: 0, @@ -302,7 +301,7 @@ const tower: TowerDefinition = { }, units: [ { - ...Captive, + unit: Captive, position: { x: 0, y: 0, @@ -310,7 +309,7 @@ const tower: TowerDefinition = { }, }, { - ...ThickSludge, + unit: ThickSludge, position: { x: 4, y: 0, @@ -318,7 +317,7 @@ const tower: TowerDefinition = { }, }, { - ...Archer, + unit: Archer, position: { x: 6, y: 0, @@ -326,7 +325,7 @@ const tower: TowerDefinition = { }, }, { - ...Archer, + unit: Archer, position: { x: 7, y: 0, @@ -352,9 +351,8 @@ const tower: TowerDefinition = { y: 0, }, warrior: { - ...Warrior, abilities: { - pivot: pivot(), + pivot: Pivot, }, position: { x: 5, @@ -364,7 +362,7 @@ const tower: TowerDefinition = { }, units: [ { - ...Archer, + unit: Archer, position: { x: 1, y: 0, @@ -372,7 +370,7 @@ const tower: TowerDefinition = { }, }, { - ...ThickSludge, + unit: ThickSludge, position: { x: 3, y: 0, @@ -399,20 +397,19 @@ const tower: TowerDefinition = { y: 0, }, warrior: { - ...Warrior, + abilities: { + look: Look.with({ range: 3 }), + shoot: Shoot.with({ power: 3, range: 3 }), + }, position: { x: 0, y: 0, facing: EAST, }, - abilities: { - look: look({ range: 3 }), - shoot: shoot({ power: 3, range: 3 }), - }, }, units: [ { - ...Captive, + unit: Captive, position: { x: 2, y: 0, @@ -420,7 +417,7 @@ const tower: TowerDefinition = { }, }, { - ...Wizard, + unit: Wizard, position: { x: 3, y: 0, @@ -428,7 +425,7 @@ const tower: TowerDefinition = { }, }, { - ...Wizard, + unit: Wizard, position: { x: 4, y: 0, @@ -455,7 +452,6 @@ const tower: TowerDefinition = { y: 0, }, warrior: { - ...Warrior, position: { x: 5, y: 0, @@ -464,7 +460,7 @@ const tower: TowerDefinition = { }, units: [ { - ...Captive, + unit: Captive, position: { x: 1, y: 0, @@ -472,7 +468,7 @@ const tower: TowerDefinition = { }, }, { - ...Archer, + unit: Archer, position: { x: 2, y: 0, @@ -480,7 +476,7 @@ const tower: TowerDefinition = { }, }, { - ...ThickSludge, + unit: ThickSludge, position: { x: 7, y: 0, @@ -488,7 +484,7 @@ const tower: TowerDefinition = { }, }, { - ...Wizard, + unit: Wizard, position: { x: 9, y: 0, @@ -496,7 +492,7 @@ const tower: TowerDefinition = { }, }, { - ...Captive, + unit: Captive, position: { x: 10, y: 0, diff --git a/towers/the-powder-keep/src/index.ts b/towers/the-powder-keep/src/index.ts index 305be12b..67b693ed 100644 --- a/towers/the-powder-keep/src/index.ts +++ b/towers/the-powder-keep/src/index.ts @@ -1,28 +1,33 @@ 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'; +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 tower: TowerDefinition = { name: 'The Powder Keep', description: 'An old fortress where something ticks beneath the floor', + warrior: { + character: '@', + color: '#8fbcbb', + maxHealth: 20, + }, levels: [ { description: @@ -40,11 +45,10 @@ const tower: TowerDefinition = { y: 3, }, warrior: { - ...Warrior, abilities: { - directionOfStairs: directionOfStairs(), - think: think(), - walk: walk(), + directionOfStairs: DirectionOfStairs, + think: Think, + walk: Walk, }, position: { x: 0, @@ -72,13 +76,12 @@ const tower: TowerDefinition = { y: 1, }, 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, @@ -88,7 +91,7 @@ const tower: TowerDefinition = { }, units: [ { - ...Sludge, + unit: Sludge, position: { x: 1, y: 0, @@ -96,7 +99,7 @@ const tower: TowerDefinition = { }, }, { - ...ThickSludge, + unit: ThickSludge, position: { x: 2, y: 1, @@ -104,7 +107,7 @@ const tower: TowerDefinition = { }, }, { - ...Sludge, + unit: Sludge, position: { x: 1, y: 1, @@ -130,20 +133,19 @@ const tower: TowerDefinition = { y: 0, }, warrior: { - ...Warrior, position: { x: 1, y: 1, facing: EAST, }, abilities: { - bind: bind(), - rescue: rescue(), + bind: Bind, + rescue: Rescue, }, }, units: [ { - ...Sludge, + unit: Sludge, position: { x: 1, y: 0, @@ -151,7 +153,7 @@ const tower: TowerDefinition = { }, }, { - ...Captive, + unit: Captive, position: { x: 1, y: 2, @@ -159,7 +161,7 @@ const tower: TowerDefinition = { }, }, { - ...Sludge, + unit: Sludge, position: { x: 0, y: 1, @@ -167,7 +169,7 @@ const tower: TowerDefinition = { }, }, { - ...Sludge, + unit: Sludge, position: { x: 2, y: 1, @@ -194,20 +196,19 @@ const tower: TowerDefinition = { y: 2, }, warrior: { - ...Warrior, position: { x: 1, y: 1, facing: EAST, }, abilities: { - directionOf: directionOf(), - listen: listen(), + directionOf: DirectionOf, + listen: Listen, }, }, units: [ { - ...Captive, + unit: Captive, position: { x: 0, y: 0, @@ -215,7 +216,7 @@ const tower: TowerDefinition = { }, }, { - ...Captive, + unit: Captive, position: { x: 0, y: 2, @@ -223,7 +224,7 @@ const tower: TowerDefinition = { }, }, { - ...Sludge, + unit: Sludge, position: { x: 2, y: 0, @@ -231,7 +232,7 @@ const tower: TowerDefinition = { }, }, { - ...ThickSludge, + unit: ThickSludge, position: { x: 3, y: 1, @@ -239,7 +240,7 @@ const tower: TowerDefinition = { }, }, { - ...Sludge, + unit: Sludge, position: { x: 2, y: 2, @@ -266,7 +267,6 @@ const tower: TowerDefinition = { y: 1, }, warrior: { - ...Warrior, position: { x: 0, y: 1, @@ -275,7 +275,7 @@ const tower: TowerDefinition = { }, units: [ { - ...ThickSludge, + unit: ThickSludge, position: { x: 4, y: 0, @@ -283,7 +283,7 @@ const tower: TowerDefinition = { }, }, { - ...ThickSludge, + unit: ThickSludge, position: { x: 3, y: 1, @@ -291,7 +291,7 @@ const tower: TowerDefinition = { }, }, { - ...Captive, + unit: Captive, position: { x: 4, y: 1, @@ -318,7 +318,6 @@ const tower: TowerDefinition = { y: 0, }, warrior: { - ...Warrior, position: { x: 0, y: 1, @@ -327,7 +326,7 @@ const tower: TowerDefinition = { }, units: [ { - ...Sludge, + unit: Sludge, position: { x: 1, y: 0, @@ -335,7 +334,7 @@ const tower: TowerDefinition = { }, }, { - ...Sludge, + unit: Sludge, position: { x: 3, y: 1, @@ -343,7 +342,7 @@ const tower: TowerDefinition = { }, }, { - ...Captive, + unit: Captive, position: { x: 0, y: 0, @@ -351,9 +350,9 @@ const tower: TowerDefinition = { }, }, { - ...Captive, + unit: Captive, effects: { - ticking: ticking({ time: 7 }), + ticking: Ticking.with({ time: 7 }), }, position: { x: 4, @@ -381,7 +380,6 @@ const tower: TowerDefinition = { y: 0, }, warrior: { - ...Warrior, position: { x: 0, y: 1, @@ -390,7 +388,7 @@ const tower: TowerDefinition = { }, units: [ { - ...Sludge, + unit: Sludge, position: { x: 1, y: 0, @@ -398,7 +396,7 @@ const tower: TowerDefinition = { }, }, { - ...Sludge, + unit: Sludge, position: { x: 1, y: 2, @@ -406,7 +404,7 @@ const tower: TowerDefinition = { }, }, { - ...Captive, + unit: Captive, position: { x: 2, y: 1, @@ -414,9 +412,9 @@ const tower: TowerDefinition = { }, }, { - ...Captive, + unit: Captive, effects: { - ticking: ticking({ time: 10 }), + ticking: Ticking.with({ time: 10 }), }, position: { x: 4, @@ -425,7 +423,7 @@ const tower: TowerDefinition = { }, }, { - ...Captive, + unit: Captive, position: { x: 2, y: 0, @@ -452,22 +450,21 @@ const tower: TowerDefinition = { y: 0, }, warrior: { - ...Warrior, position: { x: 0, y: 0, 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: [ { - ...Captive, + unit: Captive, effects: { - ticking: ticking({ time: 9 }), + ticking: Ticking.with({ time: 9 }), }, position: { x: 5, @@ -476,7 +473,7 @@ const tower: TowerDefinition = { }, }, { - ...ThickSludge, + unit: ThickSludge, position: { x: 2, y: 0, @@ -484,7 +481,7 @@ const tower: TowerDefinition = { }, }, { - ...Sludge, + unit: Sludge, position: { x: 3, y: 0, @@ -511,21 +508,20 @@ const tower: TowerDefinition = { y: 0, }, warrior: { - ...Warrior, position: { x: 0, y: 1, facing: EAST, }, abilities: { - distanceOf: distanceOf(), + distanceOf: DistanceOf, }, }, units: [ { - ...Captive, + unit: Captive, effects: { - ticking: ticking({ time: 20 }), + ticking: Ticking.with({ time: 20 }), }, position: { x: 2, @@ -534,7 +530,7 @@ const tower: TowerDefinition = { }, }, { - ...Captive, + unit: Captive, position: { x: 2, y: 2, @@ -542,7 +538,7 @@ const tower: TowerDefinition = { }, }, { - ...Sludge, + unit: Sludge, position: { x: 0, y: 0, @@ -550,7 +546,7 @@ const tower: TowerDefinition = { }, }, { - ...Sludge, + unit: Sludge, position: { x: 1, y: 0, @@ -558,7 +554,7 @@ const tower: TowerDefinition = { }, }, { - ...Sludge, + unit: Sludge, position: { x: 1, y: 1, @@ -566,7 +562,7 @@ const tower: TowerDefinition = { }, }, { - ...Sludge, + unit: Sludge, position: { x: 2, y: 1, @@ -574,7 +570,7 @@ const tower: TowerDefinition = { }, }, { - ...Sludge, + unit: Sludge, position: { x: 3, y: 1, @@ -582,7 +578,7 @@ const tower: TowerDefinition = { }, }, { - ...Sludge, + unit: Sludge, position: { x: 0, y: 2, @@ -590,7 +586,7 @@ const tower: TowerDefinition = { }, }, { - ...Sludge, + unit: Sludge, position: { x: 1, y: 2,