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