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: {
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).
},
},

View File

@ -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

View File

@ -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<string>,
) {}
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;
get status(): Status {
return this.state.status;
}
for (const f of this.factions) {
if (other.factions.has(f)) {
return true;
get level(): Level {
return this.state.level;
}
}
return false;
get factions(): ReadonlySet<Faction> {
return this.state.factions;
}
}

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.
* 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);
}
}

View File

@ -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;

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