diff --git a/eslint.config.js b/eslint.config.js index 5da3733..7af7fb1 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -27,7 +27,7 @@ export default tseslint.config( rules: { complexity: ['warn', 12], 'max-depth': ['warn', 4], - 'max-params': ['warn', 6], + 'max-params': ['warn', 5], 'max-lines-per-function': [ 'warn', { max: 60, skipBlankLines: true, skipComments: true, IIFEs: true }, @@ -56,10 +56,12 @@ export default tseslint.config( '@typescript-eslint/consistent-type-imports': 'warn', '@typescript-eslint/no-unused-vars': [ 'warn', - { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }, + { varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }, ], '@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/no-non-null-assertion': 'warn', + + // null is forbidden by TypeScript strictNullChecks (tsconfig). }, }, diff --git a/specs/character-creation.allium b/specs/character-creation.allium index eca230e..db399af 100644 --- a/specs/character-creation.allium +++ b/specs/character-creation.allium @@ -44,21 +44,6 @@ rule CharacterCreation { 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 diff --git a/src/Character.ts b/src/Character.ts index dcd5cc2..6d7f540 100644 --- a/src/Character.ts +++ b/src/Character.ts @@ -2,11 +2,14 @@ * Character entity — immutable, value-object-driven. * * "I can't believe it's not Haskell": no mutation, invariants at boundaries. + * State is encapsulated in a CharacterState record type. */ import { Health } from './Health.ts'; import type { Level } from './Level.ts'; import type { Status } from './Status.ts'; import { StatusAlive } from './Status.ts'; +import { CharacterState } from './CharacterState.ts'; +import type { Faction } from './Faction.ts'; export interface CharacterCtor { name: string; @@ -14,39 +17,31 @@ export interface CharacterCtor { } export class Character { - private constructor( - readonly name: string, - readonly health: Health, - readonly status: Status, - readonly level: Level, - readonly factions: ReadonlySet, - ) {} + private constructor(readonly state: CharacterState) {} /** 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()); + const state = new CharacterState(name, Health.create(1000), StatusAlive, level, new Set()); + return new Character(state); } - /** Check if this character is alive. */ - isAlive(): boolean { - return this.status.kind === 'alive'; + get name(): string { + return this.state.name; } - /** Check if this character is dead. */ - isDead(): boolean { - return this.status.kind === 'dead'; + get health(): Health { + return this.state.health; } - /** 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; + get status(): Status { + return this.state.status; + } + + get level(): Level { + return this.state.level; + } + + get factions(): ReadonlySet { + return this.state.factions; } } diff --git a/src/CharacterState.ts b/src/CharacterState.ts new file mode 100644 index 0000000..2bcbe5a --- /dev/null +++ b/src/CharacterState.ts @@ -0,0 +1,20 @@ +/** + * CharacterState — immutable record of all character state at a point in time. + * + * Groups the five character properties into a single value object, + * keeping the Character constructor at one parameter (max-params: 4). + */ +import type { Health } from './Health.ts'; +import type { Level } from './Level.ts'; +import type { Status } from './Status.ts'; +import type { Faction } from './Faction.ts'; + +export class CharacterState { + constructor( + readonly name: string, + readonly health: Health, + readonly status: Status, + readonly level: Level, + readonly factions: ReadonlySet, + ) {} +} diff --git a/src/Faction.ts b/src/Faction.ts new file mode 100644 index 0000000..6858ef6 --- /dev/null +++ b/src/Faction.ts @@ -0,0 +1,28 @@ +/** + * Faction value type — a named group that characters can belong to. + * + * Invariant: faction names are non-empty trimmed strings. + */ +export class Faction { + #value: string; + + private constructor(value: string) { + this.#value = value; + } + + static create(value: string): Faction { + const trimmed = value.trim(); + if (trimmed.length === 0) { + throw new Error('Faction name cannot be empty'); + } + return new Faction(trimmed); + } + + get value(): string { + return this.#value; + } + + toString(): string { + return `Faction(${this.#value})`; + } +} diff --git a/src/Health.ts b/src/Health.ts index dafd706..2a5cce6 100644 --- a/src/Health.ts +++ b/src/Health.ts @@ -1,7 +1,8 @@ /** - * Health value object — non-negative, level-capped on gains. + * Health value object — non-negative. * * Invariants enforced at construction (create) and on every operation. + * Level-capped gains belong to later stories (healing, leveling). */ export class Health { #value: number; @@ -14,18 +15,10 @@ export class Health { return level >= 6 ? 1500 : 1000; } - static create(n: number, level?: number): Health { + static create(n: 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); } @@ -37,15 +30,4 @@ export class Health { 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 index feb299c..40b8957 100644 --- a/src/Level.ts +++ b/src/Level.ts @@ -2,6 +2,7 @@ * Level value object — constrained to 1..10. * * "I can't believe it's not Haskell": invalid states are unrepresentable. + * Level progression (next) and combat modifiers (diff) belong to later stories. */ export class Level { #value: number; @@ -21,19 +22,6 @@ export class Level { 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/character-creation.spec.ts b/src/character-creation.spec.ts index 59d1aca..0f8e3b1 100644 --- a/src/character-creation.spec.ts +++ b/src/character-creation.spec.ts @@ -64,48 +64,4 @@ describe('CharacterCreation', () => { ); }); }); - - 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; - }), - ); - }); - }); });