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:
parent
d3cc48c0f7
commit
e267c35e55
@ -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).
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
20
src/CharacterState.ts
Normal 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
28
src/Faction.ts
Normal 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})`;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
14
src/Level.ts
14
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;
|
||||
|
||||
@ -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;
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user