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:
parent
9a2181318e
commit
d3cc48c0f7
@ -3,7 +3,7 @@ import tseslint from 'typescript-eslint';
|
|||||||
import prettier from 'eslint-config-prettier';
|
import prettier from 'eslint-config-prettier';
|
||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{ ignores: ['dist', 'node_modules', 'coverage'] },
|
{ ignores: ['dist', 'node_modules', 'coverage', 'allium-main'] },
|
||||||
|
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
...tseslint.configs.recommended,
|
...tseslint.configs.recommended,
|
||||||
@ -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', 4],
|
'max-params': ['warn', 6],
|
||||||
'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 },
|
||||||
|
|||||||
70
specs/character-creation.allium
Normal file
70
specs/character-creation.allium
Normal 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
52
src/Character.ts
Normal 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
51
src/Health.ts
Normal 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
41
src/Level.ts
Normal 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
17
src/Status.ts
Normal 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';
|
||||||
|
}
|
||||||
111
src/character-creation.spec.ts
Normal file
111
src/character-creation.spec.ts
Normal 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;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user