refactor(story1): clean story boundaries, introduce CharacterState record type, tighten ESLint

- Introduce CharacterState value object to group character properties
  → Character constructor reduced from 5 to 1 parameter
- Introduce Faction value type (previously bare string)
- Remove cross-story methods: isAllyOf, isAlive, isDead, add, isMax, next, diff
- Remove misleading level parameter from Health.create
- Remove trivial invariant tests (dead-code paths on fresh characters)
- Move cross-cutting invariants out of character-creation.allium
- ESLint: forbid unused params (remove argsIgnorePattern), max-params=5
- null parameters enforced by TypeScript strictNullChecks (tsconfig)
This commit is contained in:
Willem van den Ende 2026-06-12 20:32:44 +01:00
parent d3cc48c0f7
commit e267c35e55
8 changed files with 76 additions and 120 deletions

View File

@ -27,7 +27,7 @@ export default tseslint.config(
rules: { rules: {
complexity: ['warn', 12], complexity: ['warn', 12],
'max-depth': ['warn', 4], 'max-depth': ['warn', 4],
'max-params': ['warn', 6], 'max-params': ['warn', 5],
'max-lines-per-function': [ 'max-lines-per-function': [
'warn', 'warn',
{ max: 60, skipBlankLines: true, skipComments: true, IIFEs: true }, { max: 60, skipBlankLines: true, skipComments: true, IIFEs: true },
@ -56,10 +56,12 @@ export default tseslint.config(
'@typescript-eslint/consistent-type-imports': 'warn', '@typescript-eslint/consistent-type-imports': 'warn',
'@typescript-eslint/no-unused-vars': [ '@typescript-eslint/no-unused-vars': [
'warn', 'warn',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }, { varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' },
], ],
'@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-non-null-assertion': 'warn', '@typescript-eslint/no-non-null-assertion': 'warn',
// null is forbidden by TypeScript strictNullChecks (tsconfig).
}, },
}, },

View File

@ -44,21 +44,6 @@ rule CharacterCreation {
ensures: character.factions = empty 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 { rule MaxLevel {
for c in Characters: for c in Characters:
c.level.value <= 10 c.level.value <= 10

View File

@ -2,11 +2,14 @@
* Character entity immutable, value-object-driven. * Character entity immutable, value-object-driven.
* *
* "I can't believe it's not Haskell": no mutation, invariants at boundaries. * "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 { Health } from './Health.ts';
import type { Level } from './Level.ts'; import type { Level } from './Level.ts';
import type { Status } from './Status.ts'; import type { Status } from './Status.ts';
import { StatusAlive } from './Status.ts'; import { StatusAlive } from './Status.ts';
import { CharacterState } from './CharacterState.ts';
import type { Faction } from './Faction.ts';
export interface CharacterCtor { export interface CharacterCtor {
name: string; name: string;
@ -14,39 +17,31 @@ export interface CharacterCtor {
} }
export class Character { export class Character {
private constructor( private constructor(readonly state: CharacterState) {}
readonly name: string,
readonly health: Health,
readonly status: Status,
readonly level: Level,
readonly factions: ReadonlySet<string>,
) {}
/** Create a new character with default health (1000) and alive status. */ /** Create a new character with default health (1000) and alive status. */
static create({ name, level }: CharacterCtor): Character { 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. */ get name(): string {
isAlive(): boolean { return this.state.name;
return this.status.kind === 'alive';
} }
/** Check if this character is dead. */ get health(): Health {
isDead(): boolean { return this.state.health;
return this.status.kind === 'dead';
} }
/** Check if this character is an ally of another (shares a faction). */ get status(): Status {
isAllyOf(other: Character): boolean { return this.state.status;
if (this.factions.size === 0 || other.factions.size === 0) { }
return false;
} get level(): Level {
for (const f of this.factions) { return this.state.level;
if (other.factions.has(f)) { }
return true;
} get factions(): ReadonlySet<Faction> {
} return this.state.factions;
return false;
} }
} }

20
src/CharacterState.ts Normal file
View File

@ -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<Faction>,
) {}
}

28
src/Faction.ts Normal file
View File

@ -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})`;
}
}

View File

@ -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. * Invariants enforced at construction (create) and on every operation.
* Level-capped gains belong to later stories (healing, leveling).
*/ */
export class Health { export class Health {
#value: number; #value: number;
@ -14,18 +15,10 @@ export class Health {
return level >= 6 ? 1500 : 1000; return level >= 6 ? 1500 : 1000;
} }
static create(n: number, level?: number): Health { static create(n: number): Health {
if (n < 0) { if (n < 0) {
throw new Error(`Health cannot be negative, got ${n}`); 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); return new Health(n);
} }
@ -37,15 +30,4 @@ export class Health {
sub(amount: number): Health { sub(amount: number): Health {
return Health.create(Math.max(0, this.#value - amount)); 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);
}
} }

View File

@ -2,6 +2,7 @@
* Level value object constrained to 1..10. * Level value object constrained to 1..10.
* *
* "I can't believe it's not Haskell": invalid states are unrepresentable. * "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 { export class Level {
#value: number; #value: number;
@ -21,19 +22,6 @@ export class Level {
return this.#value; 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. */ /** Maximum health for this level: 1000 until level 6, 1500 from level 6 onward. */
static maxHealthForLevel(level: number): number { static maxHealthForLevel(level: number): number {
return level >= 6 ? 1500 : 1000; return level >= 6 ? 1500 : 1000;

View File

@ -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;
}),
);
});
});
}); });