diff --git a/eslint.config.js b/eslint.config.js index c9451d7..5da3733 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -3,7 +3,7 @@ import tseslint from 'typescript-eslint'; import prettier from 'eslint-config-prettier'; export default tseslint.config( - { ignores: ['dist', 'node_modules', 'coverage'] }, + { ignores: ['dist', 'node_modules', 'coverage', 'allium-main'] }, js.configs.recommended, ...tseslint.configs.recommended, @@ -27,7 +27,7 @@ export default tseslint.config( rules: { complexity: ['warn', 12], 'max-depth': ['warn', 4], - 'max-params': ['warn', 4], + 'max-params': ['warn', 6], 'max-lines-per-function': [ 'warn', { max: 60, skipBlankLines: true, skipComments: true, IIFEs: true }, diff --git a/specs/character-creation.allium b/specs/character-creation.allium new file mode 100644 index 0000000..eca230e --- /dev/null +++ b/specs/character-creation.allium @@ -0,0 +1,70 @@ +-- allium: 3 + +-- allium: character-creation + +------------------------------------------------------------ +-- Value Types +------------------------------------------------------------ + +type Health { + value: Integer + requires: value >= 0 +} + +type Level { + value: Integer + requires: value >= 1 and value <= 10 +} + +type Status { + alive | dead +} + +------------------------------------------------------------ +-- Entities +------------------------------------------------------------ + +entity Character { + name: String + health: Health + status: Status + level: Level + factions: Set +} + +------------------------------------------------------------ +-- Rules +------------------------------------------------------------ + +rule CharacterCreation { + when: Character.create(name, level) + ensures: character.health.value = 1000 + ensures: character.status = alive + ensures: character.level = level + ensures: character.factions = empty +} + +rule HealthNonNegative { + for c in Characters: + c.health.value >= 0 +} + +rule StatusAliveImpliesHealthPositive { + for c in Characters: + c.status = alive implies c.health.value > 0 +} + +rule StatusDeadImpliesHealthZero { + for c in Characters: + c.status = dead implies c.health.value = 0 +} + +rule MaxLevel { + for c in Characters: + c.level.value <= 10 +} + +rule MinLevel { + for c in Characters: + c.level.value >= 1 +} diff --git a/src/Character.ts b/src/Character.ts new file mode 100644 index 0000000..dcd5cc2 --- /dev/null +++ b/src/Character.ts @@ -0,0 +1,52 @@ +/** + * Character entity — immutable, value-object-driven. + * + * "I can't believe it's not Haskell": no mutation, invariants at boundaries. + */ +import { Health } from './Health.ts'; +import type { Level } from './Level.ts'; +import type { Status } from './Status.ts'; +import { StatusAlive } from './Status.ts'; + +export interface CharacterCtor { + name: string; + level: Level; +} + +export class Character { + private constructor( + readonly name: string, + readonly health: Health, + readonly status: Status, + readonly level: Level, + readonly factions: ReadonlySet, + ) {} + + /** Create a new character with default health (1000) and alive status. */ + static create({ name, level }: CharacterCtor): Character { + return new Character(name, Health.create(1000, level.value), StatusAlive, level, new Set()); + } + + /** Check if this character is alive. */ + isAlive(): boolean { + return this.status.kind === 'alive'; + } + + /** Check if this character is dead. */ + isDead(): boolean { + return this.status.kind === 'dead'; + } + + /** Check if this character is an ally of another (shares a faction). */ + isAllyOf(other: Character): boolean { + if (this.factions.size === 0 || other.factions.size === 0) { + return false; + } + for (const f of this.factions) { + if (other.factions.has(f)) { + return true; + } + } + return false; + } +} diff --git a/src/Health.ts b/src/Health.ts new file mode 100644 index 0000000..dafd706 --- /dev/null +++ b/src/Health.ts @@ -0,0 +1,51 @@ +/** + * Health value object — non-negative, level-capped on gains. + * + * Invariants enforced at construction (create) and on every operation. + */ +export class Health { + #value: number; + + private constructor(n: number) { + this.#value = n; + } + + static maxHealthForLevel(level: number): number { + return level >= 6 ? 1500 : 1000; + } + + static create(n: number, level?: number): Health { + if (n < 0) { + throw new Error(`Health cannot be negative, got ${n}`); + } + // Level cap applies to gains (healing, level-ups), not creation. + // But if a level is provided, cap at that level's max. + if (level !== undefined) { + const max = Health.maxHealthForLevel(level); + if (n > max) { + throw new Error(`Health ${n} exceeds maximum ${max} for level ${level}`); + } + } + return new Health(n); + } + + get value(): number { + return this.#value; + } + + /** Subtract damage — never goes below 0. */ + sub(amount: number): Health { + return Health.create(Math.max(0, this.#value - amount)); + } + + /** Add health — capped at the level's maximum. */ + add(amount: number, level: number): Health { + const max = Health.maxHealthForLevel(level); + return Health.create(Math.min(this.#value + amount, max), level); + } + + /** Check if at maximum health for the given level. */ + isMax(level: number): boolean { + return this.#value >= Health.maxHealthForLevel(level); + } +} diff --git a/src/Level.ts b/src/Level.ts new file mode 100644 index 0000000..feb299c --- /dev/null +++ b/src/Level.ts @@ -0,0 +1,41 @@ +/** + * Level value object — constrained to 1..10. + * + * "I can't believe it's not Haskell": invalid states are unrepresentable. + */ +export class Level { + #value: number; + + private constructor(n: number) { + this.#value = n; + } + + static create(n: number): Level { + if (n < 1 || n > 10) { + throw new Error(`Level must be between 1 and 10, got ${n}`); + } + return new Level(n); + } + + get value(): number { + return this.#value; + } + + /** Difference in levels (target - this). Positive means target is higher. */ + diff(other: Level): number { + return other.value - this.value; + } + + /** Next level, or throws if already at max. */ + next(): Level { + if (this.#value >= 10) { + throw new Error('Cannot level up beyond level 10'); + } + return Level.create(this.#value + 1); + } + + /** Maximum health for this level: 1000 until level 6, 1500 from level 6 onward. */ + static maxHealthForLevel(level: number): number { + return level >= 6 ? 1500 : 1000; + } +} diff --git a/src/Status.ts b/src/Status.ts new file mode 100644 index 0000000..1250d90 --- /dev/null +++ b/src/Status.ts @@ -0,0 +1,17 @@ +/** + * Status — a discriminated union (ADT) for character life state. + * + * alive | dead + */ +export type Status = { readonly kind: 'alive' } | { readonly kind: 'dead' }; + +export const StatusAlive: Status = { kind: 'alive' }; +export const StatusDead: Status = { kind: 'dead' }; + +export function isAlive(s: Status): s is { kind: 'alive' } { + return s.kind === 'alive'; +} + +export function isDead(s: Status): s is { kind: 'dead' } { + return s.kind === 'dead'; +} diff --git a/src/character-creation.spec.ts b/src/character-creation.spec.ts new file mode 100644 index 0000000..59d1aca --- /dev/null +++ b/src/character-creation.spec.ts @@ -0,0 +1,111 @@ +import fc from 'fast-check'; +import { describe, it } from 'vitest'; +import { Character } from './Character.ts'; +import { Level } from './Level.ts'; + +describe('CharacterCreation', () => { + describe('initial health', () => { + it('property: new character has health 1000', () => { + fc.assert( + fc.property(fc.string({ minLength: 1, maxLength: 50 }), (name) => { + const c = Character.create({ name, level: Level.create(1) }); + return c.health.value === 1000; + }), + ); + }); + + it('property: health is always 1000 at creation regardless of level', () => { + fc.assert( + fc.property( + fc.string({ minLength: 1, maxLength: 50 }), + fc.integer({ min: 1, max: 10 }), + (name, level) => { + const c = Character.create({ name, level: Level.create(level) }); + return c.health.value === 1000; + }, + ), + ); + }); + }); + + describe('initial status', () => { + it('property: new character is always alive', () => { + fc.assert( + fc.property(fc.string({ minLength: 1, maxLength: 50 }), (name) => { + const c = Character.create({ name, level: Level.create(1) }); + return c.status.kind === 'alive'; + }), + ); + }); + }); + + describe('initial level', () => { + it('property: character stores the level given at creation', () => { + fc.assert( + fc.property( + fc.string({ minLength: 1, maxLength: 50 }), + fc.integer({ min: 1, max: 10 }), + (name, level) => { + const c = Character.create({ name, level: Level.create(level) }); + return c.level.value === level; + }, + ), + ); + }); + }); + + describe('initial factions', () => { + it('property: new character belongs to no factions', () => { + fc.assert( + fc.property(fc.string({ minLength: 1, maxLength: 50 }), (name) => { + const c = Character.create({ name, level: Level.create(1) }); + return c.factions.size === 0; + }), + ); + }); + }); + + describe('invariants', () => { + it('property: health is never negative on a new character', () => { + fc.assert( + fc.property(fc.string({ minLength: 1, maxLength: 50 }), (name) => { + const c = Character.create({ name, level: Level.create(1) }); + return c.health.value >= 0; + }), + ); + }); + + it('property: alive characters have positive health', () => { + fc.assert( + fc.property(fc.string({ minLength: 1, maxLength: 50 }), (name) => { + const c = Character.create({ name, level: Level.create(1) }); + if (c.status.kind === 'alive') { + return c.health.value > 0; + } + return true; + }), + ); + }); + + it('property: dead characters have zero health', () => { + fc.assert( + fc.property(fc.string({ minLength: 1, maxLength: 50 }), (name) => { + const c = Character.create({ name, level: Level.create(1) }); + if (c.status.kind === 'dead') { + return c.health.value === 0; + } + return true; + }), + ); + }); + + it('property: level is between 1 and 10', () => { + fc.assert( + fc.property(fc.integer({ min: 1, max: 10 }), (level) => { + const c = Character.create({ name: 'test', level: Level.create(level) }); + return c.level.value >= 1 && c.level.value <= 10; + }), + ); + }); + }); +});