feat: Character Creation user story — ADTs, properties, and Allium spec

- Level value object (1..10, level-capped health)
- Health value object (non-negative, pure sub/add)
- Status discriminated union (alive | dead)
- Character entity with private constructor and static factory
- 9 fast-check properties covering creation invariants
- Allium spec with entities, rules, and invariants
This commit is contained in:
Willem van den Ende 2026-06-12 20:23:48 +01:00
parent 9a2181318e
commit d3cc48c0f7
7 changed files with 344 additions and 2 deletions

View File

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

View File

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

52
src/Character.ts Normal file
View File

@ -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<string>,
) {}
/** 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;
}
}

51
src/Health.ts Normal file
View File

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

41
src/Level.ts Normal file
View File

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

17
src/Status.ts Normal file
View File

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

View File

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