Compare commits
6
Commits
ba0903714c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21bd58be33 | ||
|
|
31984bbd9d | ||
|
|
39839dc594 | ||
|
|
692bd7305b | ||
|
|
1c9d4ad66b | ||
|
|
fb61fb85ed |
@@ -1,174 +0,0 @@
|
||||
-- allium: 3
|
||||
|
||||
-- allium: magical-objects
|
||||
|
||||
------------------------------------------------------------
|
||||
-- External Entities
|
||||
------------------------------------------------------------
|
||||
|
||||
external entity Character {
|
||||
name: String
|
||||
health: Health
|
||||
status: alive | dead
|
||||
level: Level
|
||||
factions: Set<Faction>
|
||||
}
|
||||
|
||||
external entity Health {
|
||||
value: Integer
|
||||
}
|
||||
|
||||
external entity Level {
|
||||
value: Integer
|
||||
}
|
||||
|
||||
external entity Faction {
|
||||
name: String
|
||||
}
|
||||
|
||||
------------------------------------------------------------
|
||||
-- Entities and Variants
|
||||
------------------------------------------------------------
|
||||
|
||||
entity MagicalWeapon {
|
||||
health: Health
|
||||
maxHealth: Integer
|
||||
status: alive | destroyed
|
||||
damage: Integer
|
||||
owner: Character
|
||||
}
|
||||
|
||||
entity HealingObject {
|
||||
health: Health
|
||||
maxHealth: Integer
|
||||
status: alive | destroyed
|
||||
}
|
||||
|
||||
------------------------------------------------------------
|
||||
-- Rules
|
||||
------------------------------------------------------------
|
||||
|
||||
rule WeaponDealsDamage {
|
||||
when: MagicalWeapon.dealsDamage(weapon, target, attacker)
|
||||
requires: weapon.status = alive
|
||||
requires: attacker = weapon.owner
|
||||
requires: attacker.status = alive
|
||||
ensures: target.health.value = max(0, target.health.value - weapon.damage)
|
||||
ensures: weapon.health.value = weapon.health.value - 1
|
||||
ensures:
|
||||
if weapon.health.value - 1 = 0:
|
||||
weapon.status = destroyed
|
||||
else:
|
||||
weapon.status = alive
|
||||
ensures:
|
||||
if max(0, target.health.value - weapon.damage) = 0:
|
||||
target.status = dead
|
||||
else:
|
||||
target.status = alive
|
||||
}
|
||||
|
||||
rule DeadCannotUseWeapon {
|
||||
when: MagicalWeapon.dealsDamage(weapon, target, attacker)
|
||||
requires: attacker.status = dead
|
||||
ensures:
|
||||
target.health.value = target.health.value
|
||||
weapon.health.value = weapon.health.value
|
||||
weapon.status = weapon.status
|
||||
target.status = target.status
|
||||
}
|
||||
|
||||
rule NonOwnerCannotUseWeapon {
|
||||
when: MagicalWeapon.dealsDamage(weapon, target, attacker)
|
||||
requires: attacker != weapon.owner
|
||||
ensures:
|
||||
target.health.value = target.health.value
|
||||
weapon.health.value = weapon.health.value
|
||||
weapon.status = weapon.status
|
||||
target.status = target.status
|
||||
}
|
||||
|
||||
rule DestroyedWeaponCannotDealDamage {
|
||||
when: MagicalWeapon.dealsDamage(weapon, target, attacker)
|
||||
requires: weapon.status = destroyed
|
||||
ensures:
|
||||
target.health.value = target.health.value
|
||||
weapon.health.value = weapon.health.value
|
||||
weapon.status = weapon.status
|
||||
target.status = target.status
|
||||
}
|
||||
|
||||
rule HealingObjectHealsCharacter {
|
||||
when: HealingObject.healsCharacter(object, character, amount)
|
||||
requires: object.status = alive
|
||||
requires: character.status = alive
|
||||
ensures: healAmount = min(amount, object.maxHealth - object.health.value)
|
||||
ensures: character.health.value = character.health.value + healAmount
|
||||
ensures: object.health.value = object.health.value - healAmount
|
||||
ensures:
|
||||
if object.health.value - healAmount = 0:
|
||||
object.status = destroyed
|
||||
else:
|
||||
object.status = alive
|
||||
}
|
||||
|
||||
rule DeadCannotUseHealingObject {
|
||||
when: HealingObject.healsCharacter(object, character, amount)
|
||||
requires: character.status = dead
|
||||
ensures:
|
||||
character.health.value = character.health.value
|
||||
object.health.value = object.health.value
|
||||
object.status = object.status
|
||||
}
|
||||
|
||||
rule DestroyedHealingObjectCannotHeal {
|
||||
when: HealingObject.healsCharacter(object, character, amount)
|
||||
requires: object.status = destroyed
|
||||
ensures:
|
||||
character.health.value = character.health.value
|
||||
object.health.value = object.health.value
|
||||
object.status = object.status
|
||||
}
|
||||
|
||||
------------------------------------------------------------
|
||||
-- Invariants
|
||||
------------------------------------------------------------
|
||||
|
||||
invariant WeaponHealthNeverNegative {
|
||||
for w in MagicalWeapons:
|
||||
w.health.value >= 0
|
||||
}
|
||||
|
||||
invariant WeaponDestroyedAtZeroHealth {
|
||||
for w in MagicalWeapons:
|
||||
w.health.value = 0 implies w.status = destroyed
|
||||
}
|
||||
|
||||
invariant WeaponMaxHealthNeverExceeded {
|
||||
for w in MagicalWeapons:
|
||||
w.health.value <= w.maxHealth
|
||||
}
|
||||
|
||||
invariant HealingObjectHealthNeverNegative {
|
||||
for h in HealingObjects:
|
||||
h.health.value >= 0
|
||||
}
|
||||
|
||||
invariant HealingObjectDestroyedAtZeroHealth {
|
||||
for h in HealingObjects:
|
||||
h.health.value = 0 implies h.status = destroyed
|
||||
}
|
||||
|
||||
invariant HealingObjectMaxHealthNeverExceeded {
|
||||
for h in HealingObjects:
|
||||
h.health.value <= h.maxHealth
|
||||
}
|
||||
|
||||
invariant HealingObjectCannotDealDamage {
|
||||
for h in HealingObjects:
|
||||
not h.dealsDamage(_, _)
|
||||
}
|
||||
|
||||
invariant WeaponCannotHeal {
|
||||
for w in MagicalWeapons:
|
||||
not w.healsCharacter(_, _)
|
||||
}
|
||||
@@ -16,7 +16,7 @@ An implementation of the RPG Combat rules engine. There are six user stories des
|
||||
|
||||
This project combines three practices:
|
||||
|
||||
1. **Allium** (`.allium` specs) — formal behavioural specifications that capture _what_ the system does
|
||||
1. **Allium** (`.allium` specs) — formal behavioural specifications that capture _what_ the system does. All specs live in [specs/](specs/) — one file per story/domain area.
|
||||
2. **fast-check** — property-based testing that verifies those properties hold across thousands of random inputs
|
||||
3. **"I can't believe it's not Haskell"** — TypeScript with ADTs, value objects, and immutability
|
||||
|
||||
|
||||
@@ -85,7 +85,9 @@ Two custom extensions were developed for this project:
|
||||
|
||||
### The Transcript Archive
|
||||
|
||||
Every session is exported as an HTML transcript in the `transcripts/` directory — over 20 sessions documenting the full journey from first requirements review through horizontal refactoring. These are the project's most valuable artifacts.
|
||||
Every session is exported as an HTML transcript in the `transcripts/` directory — 28 sessions documenting the full journey from first requirements review through horizontal refactoring. See the [full transcript index](transcripts/index.md) for a chronological list.
|
||||
|
||||
These are the project's most valuable artifacts.
|
||||
|
||||
## Build & Test
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
-- allium: 3
|
||||
|
||||
-- allium: changing-level
|
||||
|
||||
------------------------------------------------------------
|
||||
-- Value Types
|
||||
------------------------------------------------------------
|
||||
|
||||
value Faction {
|
||||
name: String
|
||||
}
|
||||
|
||||
value Health {
|
||||
value: Integer
|
||||
requires: value >= 0
|
||||
}
|
||||
|
||||
value Level {
|
||||
value: Integer
|
||||
requires: value >= 1 and value <= 10
|
||||
}
|
||||
|
||||
value Damage {
|
||||
value: Integer
|
||||
requires: value >= 0
|
||||
}
|
||||
|
||||
------------------------------------------------------------
|
||||
-- Enumerations
|
||||
------------------------------------------------------------
|
||||
|
||||
enum Status {
|
||||
alive | dead
|
||||
}
|
||||
|
||||
------------------------------------------------------------
|
||||
-- Entities
|
||||
------------------------------------------------------------
|
||||
|
||||
entity Character {
|
||||
name: String
|
||||
health: Health
|
||||
status: Status
|
||||
level: Level
|
||||
factions: Set<Faction>
|
||||
totalDamageTaken: Damage
|
||||
factionsJoined: Set<Faction>
|
||||
}
|
||||
|
||||
------------------------------------------------------------
|
||||
-- Rules
|
||||
------------------------------------------------------------
|
||||
|
||||
rule DamageIsAccumulated {
|
||||
when: Character.dealDamage(attacker, target, damage)
|
||||
requires: target.status = alive
|
||||
ensures: target.totalDamageTaken.value = old(target.totalDamageTaken.value) + damage.value
|
||||
}
|
||||
|
||||
rule LevelUpFromDamage {
|
||||
when: Character.dealDamage(attacker, target, damage)
|
||||
requires: target.status = alive
|
||||
requires: old(target.totalDamageTaken.value) + damage.value >= 1000 * (target.level.value + 1) * (target.level.value + 2) / 2
|
||||
ensures: target.level.value = target.level.value + 1
|
||||
ensures: target.totalDamageTaken.value = old(target.totalDamageTaken.value) + damage.value
|
||||
}
|
||||
|
||||
rule LevelUpFromFaction {
|
||||
when: Character.joinFaction(character, faction)
|
||||
requires: character.status = alive
|
||||
requires: old(character.factionsJoined.size) + 1 >= 3 * (character.level.value + 1)
|
||||
ensures: character.level.value = character.level.value + 1
|
||||
ensures: character.factionsJoined = old(character.factionsJoined) + {faction}
|
||||
}
|
||||
|
||||
rule MaxLevelCappedOnDamage {
|
||||
when: Character.dealDamage(attacker, target, damage)
|
||||
requires: target.status = alive
|
||||
requires: target.level.value = 10
|
||||
ensures: target.level.value = 10
|
||||
}
|
||||
|
||||
rule MaxLevelCappedOnFaction {
|
||||
when: Character.joinFaction(character, faction)
|
||||
requires: character.status = alive
|
||||
requires: character.level.value = 10
|
||||
ensures: character.level.value = 10
|
||||
}
|
||||
|
||||
rule DeadCannotLevelUpFromDamage {
|
||||
when: Character.dealDamage(attacker, target, damage)
|
||||
requires: target.status = dead
|
||||
ensures: target.level.value = old(target.level.value)
|
||||
ensures: target.totalDamageTaken.value = old(target.totalDamageTaken.value)
|
||||
}
|
||||
|
||||
rule DeadCannotLevelUpFromFaction {
|
||||
when: Character.joinFaction(character, faction)
|
||||
requires: character.status = dead
|
||||
ensures: character.level.value = old(character.level.value)
|
||||
}
|
||||
|
||||
------------------------------------------------------------
|
||||
-- Invariants
|
||||
------------------------------------------------------------
|
||||
|
||||
invariant LevelBounded {
|
||||
for c in Characters:
|
||||
c.level.value >= 1 and c.level.value <= 10
|
||||
}
|
||||
|
||||
invariant DamageTotalNonNegative {
|
||||
for c in Characters:
|
||||
c.totalDamageTaken.value >= 0
|
||||
}
|
||||
|
||||
invariant FactionsJoinedNonNegative {
|
||||
for c in Characters:
|
||||
c.factionsJoined.size >= 0
|
||||
}
|
||||
@@ -6,9 +6,22 @@
|
||||
-- Value Types
|
||||
------------------------------------------------------------
|
||||
|
||||
type Faction {
|
||||
value Faction {
|
||||
name: String
|
||||
requires: trimmed(name).length > 0
|
||||
}
|
||||
|
||||
value Health {
|
||||
value: Integer
|
||||
requires: value >= 0
|
||||
}
|
||||
|
||||
value Level {
|
||||
value: Integer
|
||||
requires: value >= 1 and value <= 10
|
||||
}
|
||||
|
||||
enum Status {
|
||||
alive | dead
|
||||
}
|
||||
|
||||
------------------------------------------------------------
|
||||
@@ -18,7 +31,7 @@ type Faction {
|
||||
entity Character {
|
||||
name: String
|
||||
health: Health
|
||||
status: alive | dead
|
||||
status: Status
|
||||
level: Level
|
||||
factions: Set<Faction>
|
||||
}
|
||||
@@ -83,12 +96,7 @@ rule DeadCannotLeaveFaction {
|
||||
invariant FactionsAlwaysValid {
|
||||
for c in Characters:
|
||||
for f in c.factions:
|
||||
f.name.trim().length > 0
|
||||
}
|
||||
|
||||
invariant AllyRelationIsSymmetric {
|
||||
for a in Characters, b in Characters:
|
||||
a.isAllyOf(b) implies b.isAllyOf(a)
|
||||
f.name.length > 0
|
||||
}
|
||||
|
||||
invariant SelfNotAlly {
|
||||
@@ -0,0 +1,189 @@
|
||||
-- allium: 3
|
||||
-- allium: magical-objects
|
||||
|
||||
------------------------------------------------------------
|
||||
-- Value Types
|
||||
------------------------------------------------------------
|
||||
|
||||
value Health {
|
||||
value: Integer
|
||||
requires: value >= 0
|
||||
}
|
||||
|
||||
value Faction {
|
||||
name: String
|
||||
}
|
||||
|
||||
value Level {
|
||||
value: Integer
|
||||
requires: value >= 1 and value <= 10
|
||||
}
|
||||
|
||||
------------------------------------------------------------
|
||||
-- Enumerations
|
||||
------------------------------------------------------------
|
||||
|
||||
enum Status {
|
||||
alive | destroyed
|
||||
}
|
||||
|
||||
------------------------------------------------------------
|
||||
-- Entities
|
||||
------------------------------------------------------------
|
||||
|
||||
entity Character {
|
||||
name: String
|
||||
health: Health
|
||||
status: Status
|
||||
level: Level
|
||||
factions: Set<Faction>
|
||||
}
|
||||
|
||||
entity MagicalObject {
|
||||
health: Health
|
||||
maxHealth: Integer
|
||||
status: Status
|
||||
}
|
||||
|
||||
entity HealingObject {
|
||||
health: Health
|
||||
maxHealth: Integer
|
||||
status: Status
|
||||
}
|
||||
|
||||
entity MagicalWeapon {
|
||||
health: Health
|
||||
maxHealth: Integer
|
||||
status: Status
|
||||
damage: Integer
|
||||
owner: Character
|
||||
}
|
||||
|
||||
------------------------------------------------------------
|
||||
-- Rules
|
||||
------------------------------------------------------------
|
||||
|
||||
rule HealingObjectHealsCharacter {
|
||||
when: CharacterUsesHealingObject(character, object, amount)
|
||||
|
||||
requires: object.status = alive
|
||||
requires: character.status = alive
|
||||
requires: amount >= 0
|
||||
|
||||
ensures:
|
||||
character.health.value = character.health.value + min(amount, object.health.value, Level.maxHealthForLevel(character.level) - character.health.value)
|
||||
object.health.value = object.health.value - min(amount, object.health.value, Level.maxHealthForLevel(character.level) - character.health.value)
|
||||
if object.health.value = 0:
|
||||
object.status = destroyed
|
||||
else:
|
||||
object.status = alive
|
||||
}
|
||||
|
||||
rule HealingObjectDestroyedCannotHeal {
|
||||
when: CharacterUsesHealingObject(character, object, amount)
|
||||
|
||||
requires: object.status = destroyed
|
||||
|
||||
ensures:
|
||||
character.health.value = character.health.value
|
||||
object.health.value = object.health.value
|
||||
}
|
||||
|
||||
rule DeadCannotUseHealingObject {
|
||||
when: CharacterUsesHealingObject(character, object, amount)
|
||||
|
||||
requires: character.status = dead
|
||||
|
||||
ensures:
|
||||
character.health.value = character.health.value
|
||||
object.health.value = object.health.value
|
||||
}
|
||||
|
||||
rule HealingObjectZeroHealIsNoOp {
|
||||
when: CharacterUsesHealingObject(character, object, amount)
|
||||
|
||||
requires: object.status = alive
|
||||
requires: character.status = alive
|
||||
requires: amount >= 0
|
||||
requires: min(amount, object.health.value, Level.maxHealthForLevel(character.level) - character.health.value) = 0
|
||||
|
||||
ensures:
|
||||
character.health.value = character.health.value
|
||||
object.health.value = object.health.value
|
||||
}
|
||||
|
||||
rule MagicalWeaponDealsDamage {
|
||||
when: CharacterUsesWeapon(owner, weapon, target)
|
||||
|
||||
requires: weapon.status = alive
|
||||
requires: owner.status = alive
|
||||
requires: owner = weapon.owner
|
||||
|
||||
ensures:
|
||||
target.health.value = max(0, target.health.value - weapon.damage)
|
||||
weapon.health.value = weapon.health.value - 1
|
||||
if weapon.health.value = 0:
|
||||
weapon.status = destroyed
|
||||
else:
|
||||
weapon.status = alive
|
||||
}
|
||||
|
||||
rule DeadCannotUseWeapon {
|
||||
when: CharacterUsesWeapon(owner, weapon, target)
|
||||
|
||||
requires: owner.status = dead
|
||||
|
||||
ensures:
|
||||
target.health.value = target.health.value
|
||||
weapon.health.value = weapon.health.value
|
||||
weapon.status = weapon.status
|
||||
target.status = target.status
|
||||
}
|
||||
|
||||
rule NonOwnerCannotUseWeapon {
|
||||
when: CharacterUsesWeapon(thief, weapon, target)
|
||||
|
||||
requires: thief != weapon.owner
|
||||
|
||||
ensures:
|
||||
target.health.value = target.health.value
|
||||
weapon.health.value = weapon.health.value
|
||||
weapon.status = weapon.status
|
||||
target.status = target.status
|
||||
}
|
||||
|
||||
rule WeaponDestroyedCannotDealDamage {
|
||||
when: CharacterUsesWeapon(owner, weapon, target)
|
||||
|
||||
requires: weapon.status = destroyed
|
||||
|
||||
ensures:
|
||||
target.health.value = target.health.value
|
||||
weapon.health.value = weapon.health.value
|
||||
weapon.status = weapon.status
|
||||
target.status = target.status
|
||||
}
|
||||
|
||||
------------------------------------------------------------
|
||||
-- Invariants
|
||||
------------------------------------------------------------
|
||||
|
||||
invariant MagicalObjectHealthNonNegative {
|
||||
for m in MagicalObjects:
|
||||
m.health.value >= 0
|
||||
}
|
||||
|
||||
invariant MagicalObjectHealthNeverExceedsMax {
|
||||
for m in MagicalObjects:
|
||||
m.health.value <= m.maxHealth
|
||||
}
|
||||
|
||||
invariant MagicalObjectDestroyedAtZeroHealth {
|
||||
for m in MagicalObjects:
|
||||
m.health.value = 0 implies m.status = destroyed
|
||||
}
|
||||
|
||||
invariant MagicalObjectAliveAtPositiveHealth {
|
||||
for m in MagicalObjects:
|
||||
m.health.value > 0 implies m.status = alive
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import fc from 'fast-check';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Character } from './characters/Character.ts';
|
||||
import { Level } from './value-objects/Level.ts';
|
||||
import { Damage } from './value-objects/Damage.ts';
|
||||
import { Faction } from './factions/Faction.ts';
|
||||
|
||||
describe('ChangingLevel', () => {
|
||||
describe('MaxLevelCappedOnDamage (example)', () => {
|
||||
it('L10 takes 99999 damage → stays L10', () => {
|
||||
const attacker = Character.create({ name: 'dragons', level: Level.create(1) });
|
||||
const target = Character.create({ name: 'hero', level: Level.create(10) });
|
||||
const result = attacker.dealDamage(target, Damage.create(99999));
|
||||
expect(result.level.value).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MaxLevelCappedOnFaction (example)', () => {
|
||||
it('L10 joins 100 factions → stays L10', () => {
|
||||
const hero = Character.create({ name: 'hero', level: Level.create(10) });
|
||||
let char = hero;
|
||||
for (let i = 0; i < 100; i++) {
|
||||
char = char.joinFaction(Faction.create(`faction-${i}`));
|
||||
}
|
||||
expect(char.level.value).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DeadCannotLevelUpFromDamage (example)', () => {
|
||||
it('dead char takes 1000 damage → stays same level', () => {
|
||||
const attacker = Character.create({ name: 'attacker', level: Level.create(1) });
|
||||
const target = Character.create({ name: 'hero', level: Level.create(1) });
|
||||
const dead = attacker.dealDamage(target, Damage.create(10000));
|
||||
const result = attacker.dealDamage(dead, Damage.create(1000));
|
||||
expect(result.level.value).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DeadCannotLevelUpFromFaction (example)', () => {
|
||||
it('dead char joins 5 factions → stays same level', () => {
|
||||
const attacker = Character.create({ name: 'attacker', level: Level.create(1) });
|
||||
const target = Character.create({ name: 'hero', level: Level.create(1) });
|
||||
const dead = attacker.dealDamage(target, Damage.create(10000));
|
||||
let char = dead;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
char = char.joinFaction(Faction.create(`faction-${i}`));
|
||||
}
|
||||
expect(char.level.value).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('LevelUpFromDamage (property)', () => {
|
||||
it('property: cumulative damage triggers level-up at threshold', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 1, max: 9 }),
|
||||
fc.integer({ min: 1, max: 500 }),
|
||||
fc.integer({ min: 1, max: 500 }),
|
||||
(level, dmg1, dmg2) => {
|
||||
const currentLevel = Level.create(level);
|
||||
const threshold = Level.damageThresholdForLevel(level + 1);
|
||||
const total = dmg1 + dmg2;
|
||||
|
||||
// Only test when total meets threshold AND target survives
|
||||
// Target starts with 1000 health, needs health > total after damage
|
||||
if (total >= threshold && total < 1000) {
|
||||
const attacker = Character.create({ name: 'a', level: Level.create(1) });
|
||||
const target = Character.create({ name: 't', level: currentLevel });
|
||||
const afterFirst = attacker.dealDamage(target, Damage.create(dmg1));
|
||||
const afterSecond = attacker.dealDamage(afterFirst, Damage.create(dmg2));
|
||||
const expectedLevel = Math.min(10, level + 1);
|
||||
return afterSecond.level.value === expectedLevel;
|
||||
}
|
||||
return true; // skip non-applicable cases
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('LevelUpFromFaction (property)', () => {
|
||||
it('property: faction count triggers level-up at thresholds', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 1, max: 9 }),
|
||||
fc.integer({ min: 1, max: 30 }),
|
||||
(level, totalFactions) => {
|
||||
// Compute expected level: how many thresholds are crossed?
|
||||
let currentLevel = level;
|
||||
for (let f = 1; f <= totalFactions; f++) {
|
||||
if (f >= 3 * (currentLevel + 1) && currentLevel < 10) {
|
||||
currentLevel++;
|
||||
}
|
||||
}
|
||||
|
||||
const hero = Character.create({ name: 'hero', level: Level.create(level) });
|
||||
let char = hero;
|
||||
for (let i = 0; i < totalFactions; i++) {
|
||||
char = char.joinFaction(Faction.create(`faction-${i}`));
|
||||
}
|
||||
return char.level.value === currentLevel;
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DamageAccumulation (property)', () => {
|
||||
it('property: totalDamageTaken accumulates across multiple damage events', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(fc.integer({ min: 1, max: 200 }), { minLength: 2, maxLength: 10 }),
|
||||
(damageEvents) => {
|
||||
const expectedTotal = damageEvents.reduce((sum, d) => sum + d, 0);
|
||||
// Ensure target survives: total damage must be < 1000 (starting health)
|
||||
if (expectedTotal >= 1000) return true;
|
||||
|
||||
const attacker = Character.create({ name: 'a', level: Level.create(1) });
|
||||
const target = Character.create({ name: 't', level: Level.create(1) });
|
||||
|
||||
let current = target;
|
||||
for (const dmg of damageEvents) {
|
||||
current = attacker.dealDamage(current, Damage.create(dmg));
|
||||
}
|
||||
return current.totalDamageTaken.value === expectedTotal;
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
import { Health } from '../value-objects/Health.ts';
|
||||
import { Level } from '../value-objects/Level.ts';
|
||||
import { Damage } from '../value-objects/Damage.ts';
|
||||
import type { Status } from '../value-objects/Status.ts';
|
||||
import { StatusAlive, StatusDead } from '../value-objects/Status.ts';
|
||||
import type { CharacterState } from './CharacterState.ts';
|
||||
@@ -48,6 +49,8 @@ export class Character {
|
||||
status: StatusAlive,
|
||||
level,
|
||||
factions: new Set(),
|
||||
totalDamageTaken: Damage.create(0),
|
||||
factionsJoined: new Set(),
|
||||
};
|
||||
return new Character(state);
|
||||
}
|
||||
@@ -60,6 +63,8 @@ export class Character {
|
||||
status: StatusAlive,
|
||||
level,
|
||||
factions: new Set(),
|
||||
totalDamageTaken: Damage.create(0),
|
||||
factionsJoined: new Set(),
|
||||
};
|
||||
return new Character(state);
|
||||
}
|
||||
@@ -77,6 +82,8 @@ export class Character {
|
||||
status,
|
||||
level,
|
||||
factions: new Set(),
|
||||
totalDamageTaken: Damage.create(0),
|
||||
factionsJoined: new Set(),
|
||||
};
|
||||
return new Character(state);
|
||||
}
|
||||
@@ -101,6 +108,14 @@ export class Character {
|
||||
return this.#state.factions;
|
||||
}
|
||||
|
||||
get totalDamageTaken(): Damage {
|
||||
return this.#state.totalDamageTaken;
|
||||
}
|
||||
|
||||
get factionsJoined(): ReadonlySet<Faction> {
|
||||
return this.#state.factionsJoined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this character is an ally of another.
|
||||
* Two characters are allies if they share at least one faction.
|
||||
@@ -122,12 +137,24 @@ export class Character {
|
||||
if (this.status.kind === 'dead') return this;
|
||||
const newFactions = new Set(this.#state.factions);
|
||||
newFactions.add(faction);
|
||||
|
||||
const newFactionsJoined = new Set(this.#state.factionsJoined);
|
||||
newFactionsJoined.add(faction);
|
||||
|
||||
// Level-up from faction count
|
||||
let newLevel = this.level;
|
||||
if (newFactionsJoined.size >= 3 * (newLevel.value + 1)) {
|
||||
newLevel = Level.create(Math.min(10, newLevel.value + 1));
|
||||
}
|
||||
|
||||
return new Character({
|
||||
name: this.name,
|
||||
health: this.health,
|
||||
status: this.status,
|
||||
level: this.level,
|
||||
level: newLevel,
|
||||
factions: newFactions,
|
||||
totalDamageTaken: this.totalDamageTaken,
|
||||
factionsJoined: newFactionsJoined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -147,6 +174,8 @@ export class Character {
|
||||
status: this.status,
|
||||
level: this.level,
|
||||
factions: newFactions,
|
||||
totalDamageTaken: this.totalDamageTaken,
|
||||
factionsJoined: this.factionsJoined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -169,6 +198,8 @@ export class Character {
|
||||
status: ally.status,
|
||||
level: ally.level,
|
||||
factions: ally.factions,
|
||||
totalDamageTaken: ally.totalDamageTaken,
|
||||
factionsJoined: ally.factionsJoined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -176,34 +207,46 @@ export class Character {
|
||||
* Deal damage to another character. Returns a new Character with updated state.
|
||||
* Does not mutate the attacker or the original target reference.
|
||||
*/
|
||||
dealDamage(target: Character, damage: number): Character {
|
||||
dealDamage(target: Character, damage: Damage): Character {
|
||||
// Self-damage is forbidden — use reference equality, not name
|
||||
if (this === target) return target;
|
||||
// Allies cannot deal damage to each other
|
||||
if (this.isAllyOf(target)) return target;
|
||||
// Dead characters cannot take damage
|
||||
if (target.status.kind === 'dead') return target;
|
||||
// Negative damage is invalid
|
||||
if (damage < 0) throw new Error(`Damage must be non-negative, got ${damage}`);
|
||||
// Level-based damage modifier
|
||||
const levelDiff = this.level.diff(target.level); // = this.level - target.level
|
||||
let actualDamage = damage;
|
||||
let actualDamage = damage.value;
|
||||
if (levelDiff <= -5) {
|
||||
// Target is ≥5 levels above → damage reduced by 50%
|
||||
actualDamage = Math.floor(damage * 0.5);
|
||||
actualDamage = Math.floor(damage.value * 0.5);
|
||||
} else if (levelDiff >= 5) {
|
||||
// Target is ≥5 levels below → damage increased by 50%
|
||||
actualDamage = Math.floor(damage * 1.5);
|
||||
actualDamage = Math.floor(damage.value * 1.5);
|
||||
}
|
||||
// Reduce health by the (possibly modified) damage amount
|
||||
const newHealth = target.health.sub(actualDamage);
|
||||
const newStatus = newHealth.value === 0 ? StatusDead : StatusAlive;
|
||||
|
||||
// Level-up from cumulative damage
|
||||
let newLevel = target.level;
|
||||
const newTotalDamage = target.totalDamageTaken.add(Damage.create(actualDamage));
|
||||
|
||||
if (newStatus.kind === 'alive') {
|
||||
const threshold = Level.damageThresholdForLevel(newLevel.value + 1);
|
||||
if (newTotalDamage.value >= threshold) {
|
||||
newLevel = Level.create(Math.min(10, newLevel.value + 1));
|
||||
}
|
||||
}
|
||||
|
||||
return new Character({
|
||||
name: target.name,
|
||||
health: newHealth,
|
||||
status: newStatus,
|
||||
level: target.level,
|
||||
level: newLevel,
|
||||
factions: target.factions,
|
||||
totalDamageTaken: newTotalDamage,
|
||||
factionsJoined: target.factionsJoined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -225,6 +268,8 @@ export class Character {
|
||||
status: this.status,
|
||||
level: this.level,
|
||||
factions: this.factions,
|
||||
totalDamageTaken: this.totalDamageTaken,
|
||||
factionsJoined: this.factionsJoined,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { Health } from '../value-objects/Health.ts';
|
||||
import type { Level } from '../value-objects/Level.ts';
|
||||
import type { Status } from '../value-objects/Status.ts';
|
||||
import type { Faction } from '../factions/Faction.ts';
|
||||
import type { Damage } from '../value-objects/Damage.ts';
|
||||
|
||||
export type CharacterState = {
|
||||
readonly name: string;
|
||||
@@ -15,4 +16,6 @@ export type CharacterState = {
|
||||
readonly status: Status;
|
||||
readonly level: Level;
|
||||
readonly factions: ReadonlySet<Faction>;
|
||||
readonly totalDamageTaken: Damage;
|
||||
readonly factionsJoined: ReadonlySet<Faction>;
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import fc from 'fast-check';
|
||||
import { describe, it } from 'vitest';
|
||||
import { Character } from './characters/Character.ts';
|
||||
import { Level } from './value-objects/Level.ts';
|
||||
import { Damage } from './value-objects/Damage.ts';
|
||||
|
||||
describe('DamageAndHealth', () => {
|
||||
describe('DamageReducesHealth', () => {
|
||||
@@ -18,7 +19,7 @@ describe('DamageAndHealth', () => {
|
||||
health,
|
||||
});
|
||||
const expected = Math.max(0, health - damage);
|
||||
const result = attacker.dealDamage(target, damage);
|
||||
const result = attacker.dealDamage(target, Damage.create(damage));
|
||||
return result.health.value === expected;
|
||||
},
|
||||
),
|
||||
@@ -39,7 +40,7 @@ describe('DamageAndHealth', () => {
|
||||
level: Level.create(1),
|
||||
health,
|
||||
});
|
||||
const result = attacker.dealDamage(target, damage);
|
||||
const result = attacker.dealDamage(target, Damage.create(damage));
|
||||
return result.health.value >= 0;
|
||||
},
|
||||
),
|
||||
@@ -60,7 +61,7 @@ describe('DamageAndHealth', () => {
|
||||
level: Level.create(1),
|
||||
health,
|
||||
});
|
||||
const result = attacker.dealDamage(target, damage);
|
||||
const result = attacker.dealDamage(target, Damage.create(damage));
|
||||
const expected = Math.max(0, health - damage);
|
||||
if (expected === 0) {
|
||||
return result.status.kind === 'dead';
|
||||
@@ -82,7 +83,7 @@ describe('DamageAndHealth', () => {
|
||||
const c = Character.createWithHealth({ name: 'hero', level: Level.create(1), health });
|
||||
const healthBefore = c.health.value;
|
||||
const statusBefore = c.status.kind;
|
||||
const result = c.dealDamage(c, damage);
|
||||
const result = c.dealDamage(c, Damage.create(damage));
|
||||
// Should return the same reference
|
||||
return (
|
||||
result === c &&
|
||||
@@ -102,11 +103,11 @@ describe('DamageAndHealth', () => {
|
||||
const attacker = Character.create({ name: 'attacker', level: Level.create(1) });
|
||||
const target = Character.create({ name: 'target', level: Level.create(1) });
|
||||
// Kill the target first — capture the returned (dead) character
|
||||
const deadTarget = attacker.dealDamage(target, 10000);
|
||||
const deadTarget = attacker.dealDamage(target, Damage.create(10000));
|
||||
const healthBefore = deadTarget.health.value;
|
||||
const statusBefore = deadTarget.status.kind;
|
||||
// Then try to deal more damage to the dead character
|
||||
const result = attacker.dealDamage(deadTarget, damage);
|
||||
const result = attacker.dealDamage(deadTarget, Damage.create(damage));
|
||||
return (
|
||||
result === deadTarget &&
|
||||
result.health.value === healthBefore &&
|
||||
@@ -118,14 +119,12 @@ describe('DamageAndHealth', () => {
|
||||
});
|
||||
|
||||
describe('NegativeDamageForbidden', () => {
|
||||
it('property: negative damage throws an error', () => {
|
||||
it('property: negative damage throws an error via Damage.create', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.integer({ min: -10000, max: -1 }), (negativeDamage) => {
|
||||
const attacker = Character.create({ name: 'attacker', level: Level.create(1) });
|
||||
const target = Character.create({ name: 'target', level: Level.create(1) });
|
||||
let threw = false;
|
||||
try {
|
||||
attacker.dealDamage(target, negativeDamage);
|
||||
Damage.create(negativeDamage);
|
||||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, it } from 'vitest';
|
||||
import { Character } from './characters/Character.ts';
|
||||
import { Faction } from './factions/Faction.ts';
|
||||
import { Level } from './value-objects/Level.ts';
|
||||
import { Damage } from './value-objects/Damage.ts';
|
||||
|
||||
describe('Factions', () => {
|
||||
const hero = () => Character.create({ name: 'hero', level: Level.create(1) });
|
||||
@@ -189,7 +190,7 @@ describe('Factions', () => {
|
||||
(factionName) => {
|
||||
const attacker = Character.create({ name: 'attacker', level: Level.create(1) });
|
||||
const hero = Character.create({ name: 'hero', level: Level.create(1) });
|
||||
const deadHero = attacker.dealDamage(hero, 10000);
|
||||
const deadHero = attacker.dealDamage(hero, Damage.create(10000));
|
||||
const f = Faction.create(factionName);
|
||||
const result = deadHero.joinFaction(f);
|
||||
return result === deadHero && result.factions.size === 0;
|
||||
@@ -209,7 +210,7 @@ describe('Factions', () => {
|
||||
const hero = Character.create({ name: 'hero', level: Level.create(1) });
|
||||
const f = Faction.create(factionName);
|
||||
const withFaction = hero.joinFaction(f);
|
||||
const deadHero = attacker.dealDamage(withFaction, 10000);
|
||||
const deadHero = attacker.dealDamage(withFaction, Damage.create(10000));
|
||||
const result = deadHero.leaveFaction(f);
|
||||
return result === deadHero && result.factions.has(f);
|
||||
},
|
||||
@@ -302,7 +303,7 @@ describe('Factions', () => {
|
||||
const target = ally().joinFaction(faction);
|
||||
const healthBefore = target.health.value;
|
||||
const statusBefore = target.status.kind;
|
||||
const result = attacker.dealDamage(target, 500);
|
||||
const result = attacker.dealDamage(target, Damage.create(500));
|
||||
return result.health.value === healthBefore && result.status.kind === statusBefore;
|
||||
}),
|
||||
);
|
||||
@@ -314,7 +315,7 @@ describe('Factions', () => {
|
||||
const faction = Faction.create('guard');
|
||||
const attacker = hero().joinFaction(faction);
|
||||
const target = ally().joinFaction(faction);
|
||||
const result = attacker.dealDamage(target, 500);
|
||||
const result = attacker.dealDamage(target, Damage.create(500));
|
||||
return result === target;
|
||||
}),
|
||||
);
|
||||
@@ -326,7 +327,7 @@ describe('Factions', () => {
|
||||
const attacker = hero();
|
||||
const target = enemy();
|
||||
const healthBefore = target.health.value;
|
||||
const result = attacker.dealDamage(target, 100);
|
||||
const result = attacker.dealDamage(target, Damage.create(100));
|
||||
return result.health.value === healthBefore - 100;
|
||||
}),
|
||||
);
|
||||
@@ -414,7 +415,7 @@ describe('Factions', () => {
|
||||
const target = ally().joinFaction(faction);
|
||||
// Kill the target with a non-ally attacker
|
||||
const killer = enemy();
|
||||
const deadTarget = killer.dealDamage(target, 10000);
|
||||
const deadTarget = killer.dealDamage(target, Damage.create(10000));
|
||||
const healthBefore = deadTarget.health.value;
|
||||
const statusBefore = deadTarget.status.kind;
|
||||
const result = healer.healAlly(deadTarget, 500);
|
||||
|
||||
+2
-1
@@ -2,6 +2,7 @@ import fc from 'fast-check';
|
||||
import { describe, it } from 'vitest';
|
||||
import { Character } from './characters/Character.ts';
|
||||
import { Level } from './value-objects/Level.ts';
|
||||
import { Damage } from './value-objects/Damage.ts';
|
||||
|
||||
describe('Healing', () => {
|
||||
describe('SelfHealIncreasesHealth', () => {
|
||||
@@ -88,7 +89,7 @@ describe('Healing', () => {
|
||||
const attacker = Character.create({ name: 'attacker', level: Level.create(1) });
|
||||
const hero = Character.create({ name: 'hero', level: Level.create(1) });
|
||||
// Kill the hero first using a different attacker
|
||||
const deadHero = attacker.dealDamage(hero, 10000);
|
||||
const deadHero = attacker.dealDamage(hero, Damage.create(10000));
|
||||
const healthBefore = deadHero.health.value;
|
||||
const statusBefore = deadHero.status.kind;
|
||||
// Try to heal the dead character
|
||||
|
||||
+9
-8
@@ -2,6 +2,7 @@ import fc from 'fast-check';
|
||||
import { describe, it } from 'vitest';
|
||||
import { Character } from './characters/Character.ts';
|
||||
import { Level } from './value-objects/Level.ts';
|
||||
import { Damage } from './value-objects/Damage.ts';
|
||||
|
||||
describe('Levels', () => {
|
||||
describe('CloseLevelNoModifier', () => {
|
||||
@@ -21,7 +22,7 @@ describe('Levels', () => {
|
||||
level: Level.create(targetLevel),
|
||||
health: 1000,
|
||||
});
|
||||
const result = attacker.dealDamage(target, baseDamage);
|
||||
const result = attacker.dealDamage(target, Damage.create(baseDamage));
|
||||
return result.health.value === Math.max(0, 1000 - baseDamage);
|
||||
},
|
||||
),
|
||||
@@ -47,7 +48,7 @@ describe('Levels', () => {
|
||||
health: 1000,
|
||||
});
|
||||
const expectedDamage = Math.floor(baseDamage * 0.5);
|
||||
const result = attacker.dealDamage(target, baseDamage);
|
||||
const result = attacker.dealDamage(target, Damage.create(baseDamage));
|
||||
return result.health.value === Math.max(0, 1000 - expectedDamage);
|
||||
},
|
||||
),
|
||||
@@ -66,7 +67,7 @@ describe('Levels', () => {
|
||||
health: 1000,
|
||||
});
|
||||
const expectedDamage = Math.floor(oddDamage * 0.5);
|
||||
const result = attacker.dealDamage(target, oddDamage);
|
||||
const result = attacker.dealDamage(target, Damage.create(oddDamage));
|
||||
return result.health.value === Math.max(0, 1000 - expectedDamage);
|
||||
}),
|
||||
);
|
||||
@@ -91,7 +92,7 @@ describe('Levels', () => {
|
||||
health: 1000,
|
||||
});
|
||||
const expectedDamage = Math.floor(baseDamage * 1.5);
|
||||
const result = attacker.dealDamage(target, baseDamage);
|
||||
const result = attacker.dealDamage(target, Damage.create(baseDamage));
|
||||
return result.health.value === Math.max(0, 1000 - expectedDamage);
|
||||
},
|
||||
),
|
||||
@@ -111,7 +112,7 @@ describe('Levels', () => {
|
||||
health: 1000,
|
||||
});
|
||||
const expectedDamage = Math.floor(damage * 0.5);
|
||||
const result = attacker.dealDamage(target, damage);
|
||||
const result = attacker.dealDamage(target, Damage.create(damage));
|
||||
return result.health.value === Math.max(0, 1000 - expectedDamage);
|
||||
}),
|
||||
);
|
||||
@@ -128,7 +129,7 @@ describe('Levels', () => {
|
||||
health: 1000,
|
||||
});
|
||||
const expectedDamage = Math.floor(damage * 1.5);
|
||||
const result = attacker.dealDamage(target, damage);
|
||||
const result = attacker.dealDamage(target, Damage.create(damage));
|
||||
return result.health.value === Math.max(0, 1000 - expectedDamage);
|
||||
}),
|
||||
);
|
||||
@@ -144,7 +145,7 @@ describe('Levels', () => {
|
||||
level: Level.create(5),
|
||||
health: 1000,
|
||||
});
|
||||
const result = attacker.dealDamage(target, damage);
|
||||
const result = attacker.dealDamage(target, Damage.create(damage));
|
||||
return result.health.value === Math.max(0, 1000 - damage);
|
||||
}),
|
||||
);
|
||||
@@ -160,7 +161,7 @@ describe('Levels', () => {
|
||||
level: Level.create(1),
|
||||
health: 1000,
|
||||
});
|
||||
const result = attacker.dealDamage(target, damage);
|
||||
const result = attacker.dealDamage(target, Damage.create(damage));
|
||||
return result.health.value === Math.max(0, 1000 - damage);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import fc from 'fast-check';
|
||||
import { describe, it } from 'vitest';
|
||||
import { Character } from './characters/Character.ts';
|
||||
import { Level } from './value-objects/Level.ts';
|
||||
import { Damage } from './value-objects/Damage.ts';
|
||||
import { MagicalWeapon } from './magical-objects/MagicalWeapon.ts';
|
||||
import { HealingObject } from './magical-objects/HealingObject.ts';
|
||||
|
||||
@@ -85,7 +86,7 @@ describe('Magical Objects', () => {
|
||||
const weapon = MagicalWeapon.create({ damage, maxHealth: weaponHP, owner: attacker });
|
||||
// Kill the attacker using a separate killer
|
||||
const killer = Character.create({ name: 'boss', level: Level.create(1) });
|
||||
const deadAttacker = killer.dealDamage(attacker, 10000);
|
||||
const deadAttacker = killer.dealDamage(attacker, Damage.create(10000));
|
||||
const weaponHPBefore = weapon.health.value;
|
||||
const targetHealthBefore = target.health.value;
|
||||
const result = deadAttacker.useWeapon(weapon, target);
|
||||
@@ -256,7 +257,7 @@ describe('Magical Objects', () => {
|
||||
const object = HealingObject.create({ maxHealth: objectHP, currentHealth: objectHP });
|
||||
// Kill the character using a separate killer
|
||||
const killer = Character.create({ name: 'boss', level: Level.create(1) });
|
||||
const deadCharacter = killer.dealDamage(character, 10000);
|
||||
const deadCharacter = killer.dealDamage(character, Damage.create(10000));
|
||||
const objectHPBefore = object.health.value;
|
||||
const characterHealthBefore = deadCharacter.health.value;
|
||||
const result = deadCharacter.useHealingObject(object, 100);
|
||||
|
||||
@@ -7,18 +7,19 @@
|
||||
*/
|
||||
import { Character } from '../characters/Character.ts';
|
||||
import { Health } from '../value-objects/Health.ts';
|
||||
import { Damage } from '../value-objects/Damage.ts';
|
||||
import { MagicalObject } from './MagicalObject.ts';
|
||||
import type { DamageDealer } from './magical-object-types.ts';
|
||||
|
||||
export class MagicalWeapon extends MagicalObject implements DamageDealer {
|
||||
readonly #damage: number;
|
||||
readonly #damage: Damage;
|
||||
readonly #owner: Character;
|
||||
|
||||
private constructor(
|
||||
health: Health,
|
||||
maxHealth: Health,
|
||||
status: { readonly kind: 'alive' } | { readonly kind: 'destroyed' },
|
||||
damage: number,
|
||||
damage: Damage,
|
||||
owner: Character,
|
||||
) {
|
||||
super(health, maxHealth, status);
|
||||
@@ -36,17 +37,16 @@ export class MagicalWeapon extends MagicalObject implements DamageDealer {
|
||||
owner: Character;
|
||||
}): MagicalWeapon {
|
||||
if (maxHealth < 0) throw new Error('MaxHealth cannot be negative');
|
||||
if (damage < 0) throw new Error('Damage cannot be negative');
|
||||
return new MagicalWeapon(
|
||||
Health.create(maxHealth),
|
||||
Health.create(maxHealth),
|
||||
{ kind: 'alive' },
|
||||
damage,
|
||||
Damage.create(damage),
|
||||
owner,
|
||||
);
|
||||
}
|
||||
|
||||
get damage(): number {
|
||||
get damage(): Damage {
|
||||
return this.#damage;
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ export class MagicalWeapon extends MagicalObject implements DamageDealer {
|
||||
return { weapon: this, target };
|
||||
}
|
||||
// Deal fixed damage
|
||||
const newTargetHealth = Math.max(0, target.health.value - this.#damage);
|
||||
const newTargetHealth = Math.max(0, target.health.value - this.#damage.value);
|
||||
const newTargetStatus = newTargetHealth === 0 ? { kind: 'dead' as const } : target.status;
|
||||
const newTarget = Character.createWithHealthAndStatus({
|
||||
name: target.name,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Damage value object — non-negative, immutable.
|
||||
*
|
||||
* Invariant enforced at construction: n >= 0
|
||||
*/
|
||||
export class Damage {
|
||||
readonly #value: number;
|
||||
|
||||
private constructor(n: number) {
|
||||
this.#value = n;
|
||||
}
|
||||
|
||||
static create(n: number): Damage {
|
||||
if (n < 0) throw new Error(`Damage must be non-negative, got ${n}`);
|
||||
return new Damage(n);
|
||||
}
|
||||
|
||||
get value(): number {
|
||||
return this.#value;
|
||||
}
|
||||
|
||||
/** Add another damage amount — cumulative damage is still a Damage. */
|
||||
add(other: Damage): Damage {
|
||||
return Damage.create(this.#value + other.value);
|
||||
}
|
||||
}
|
||||
@@ -31,4 +31,12 @@ export class Level {
|
||||
diff(target: Level): number {
|
||||
return this.value - target.value;
|
||||
}
|
||||
|
||||
/** Damage threshold to reach the given level.
|
||||
* Level 1→2: 1000, Level 2→3: 3000, Level 3→4: 6000, etc.
|
||||
* Formula: 1000 * N * (N+1) / 2
|
||||
*/
|
||||
static damageThresholdForLevel(level: number): number {
|
||||
return (1000 * level * (level + 1)) / 2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Transcript Archive
|
||||
|
||||
Chronological list of all session transcripts from the RPG Combat project.
|
||||
|
||||
| Date | Transcript |
|
||||
| ---- | ---------- |
|
||||
| 2026-06-12 20:20 | [Card, Conversation & Confirmation](card-conversation-confirmation.html) |
|
||||
| 2026-06-12 20:20 | [Install Allium](install-allium.html) |
|
||||
| 2026-06-12 20:20 | [Review User Stories](review-user-stories.md) |
|
||||
| 2026-06-12 23:02 | [Clear and Export](clear-and-export.html) |
|
||||
| 2026-06-12 23:02 | [Refactor Story 1](refactor-story-1.html) |
|
||||
| 2026-06-12 23:02 | [Story 1 — Process Improvement](story1-process-improvement.md) |
|
||||
| 2026-06-13 15:42 | [YAGNI in AGENTS.md](yagni-in-agents-md.html) |
|
||||
| 2026-06-13 15:51 | [Story 2 — Refactored](story-2-refactored.html) |
|
||||
| 2026-06-13 16:06 | [Forgot to Mention the Story](forgot-to-mention-the-story.html) |
|
||||
| 2026-06-13 22:03 | [Forgot to Commit](forgot-to-commit.html) |
|
||||
| 2026-06-13 22:03 | [Story 2 (re?) — Done](story-2-(re?)-done.html) |
|
||||
| 2026-06-13 22:20 | [Story 4 — Built](story-4-built.html) |
|
||||
| 2026-06-13 22:32 | [Found Out Story 3 Is Not Done](found-out-story-3-is-not-done.html) |
|
||||
| 2026-06-14 10:48 | [Break Down Horizontal Refactoring into Yaks](break-down-horizontal-refactoring-into-yaks.html) |
|
||||
| 2026-06-14 10:48 | [Create Task Breakdown with Yaks Skill](create-task-breakdown-with-yaks-skill.html) |
|
||||
| 2026-06-14 10:48 | [Fixed Character Implementation (Maybe)](fixed-character-implementation-maybe.html) |
|
||||
| 2026-06-14 10:51 | [Resolved Circular Dependency](resolved-circular-dependency.html) |
|
||||
| 2026-06-14 12:01 | [Break Down the Refactoring Yaks](break-down-the-refactoring-yaks.html) |
|
||||
| 2026-06-14 12:01 | [Break Yaks Down into Phases](break-yaks-down-into-phases.html) |
|
||||
| 2026-06-14 12:01 | [Yaks Were Not Marked S-Done](yaks-were-not-marked-s-done.html) |
|
||||
| 2026-06-14 12:47 | [Create Yak Run Skill](create-yak-run-skill.html) |
|
||||
| 2026-06-14 12:47 | [ESLint Rule Against Value Objects and Yaks to Refactor](eslint-rule-against-value-objects-and-yaks-to-refactor.html) |
|
||||
| 2026-06-14 12:47 | [Generate README](generate-readme.html) |
|
||||
| 2026-06-14 12:47 | [Yaks Work for Horizontal Refactoring](yaks-work-for-horizontal-refactoring.html) |
|
||||
| 2026-06-14 14:26 | [Refactor: Replace Number Health with Health Value Object in MagicalObject](refactor:-replace-number-health-with-Health-value-object-in-MagicalObject.html) |
|
||||
| 2026-06-14 14:26 | [Story 4 Also Done — Spec and Story Needed to Be Put Straight](story-4-also-done,-spec-and-story-needed-to-be-put-straight.html) |
|
||||
| 2026-06-15 08:09 | [Last Story Done](last-story-done.html) |
|
||||
| 2026-06-15 09:36 | [Wrap Up](wrap-up.html) |
|
||||
File diff suppressed because one or more lines are too long
+13112
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -50,6 +50,7 @@ This is a description of the business rules we should support in the game engine
|
||||
- Healing Magical Objects cannot deal Damage
|
||||
|
||||
3. Characters can deal Damage by using a Magical Weapon.
|
||||
- A Character can only use a Magical Weapon they own
|
||||
- These Magical Objects deal a fixed amount of damage when they are used
|
||||
- The amount of damage is fixed at the time the weapon is created
|
||||
- Every time the weapon is used, the Health is reduced by 1
|
||||
|
||||
Reference in New Issue
Block a user