Compare commits

..

No commits in common. "39839dc5943c1b0cc71782b1fb25baae12afb204" and "fb61fb85edb585d2ffea52f1c1fc74f967b0b2d9" have entirely different histories.

17 changed files with 3967 additions and 26463 deletions

View File

@ -6,22 +6,9 @@
-- Value Types
------------------------------------------------------------
value Faction {
type Faction {
name: String
}
value Health {
value: Integer
requires: value >= 0
}
value Level {
value: Integer
requires: value >= 1 and value <= 10
}
enum Status {
alive | dead
requires: trimmed(name).length > 0
}
------------------------------------------------------------
@ -31,7 +18,7 @@ enum Status {
entity Character {
name: String
health: Health
status: Status
status: alive | dead
level: Level
factions: Set<Faction>
}
@ -96,7 +83,12 @@ rule DeadCannotLeaveFaction {
invariant FactionsAlwaysValid {
for c in Characters:
for f in c.factions:
f.name.length > 0
f.name.trim().length > 0
}
invariant AllyRelationIsSymmetric {
for a in Characters, b in Characters:
a.isAllyOf(b) implies b.isAllyOf(a)
}
invariant SelfNotAlly {

View File

@ -1,183 +0,0 @@
-- 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
}
rule NonOwnerCannotUseWeapon {
when: CharacterUsesWeapon(thief, weapon, target)
requires: thief != weapon.owner
ensures:
target.health.value = target.health.value
weapon.health.value = weapon.health.value
}
rule WeaponDestroyedCannotDealDamage {
when: CharacterUsesWeapon(owner, weapon, target)
requires: weapon.status = destroyed
ensures:
target.health.value = target.health.value
weapon.health.value = weapon.health.value
}
------------------------------------------------------------
-- 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
}

View File

@ -1,120 +0,0 @@
-- 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
}

View File

@ -1,131 +0,0 @@
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;
},
),
);
});
});
});

View File

@ -6,7 +6,6 @@
*/
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';
@ -49,8 +48,6 @@ export class Character {
status: StatusAlive,
level,
factions: new Set(),
totalDamageTaken: Damage.create(0),
factionsJoined: new Set(),
};
return new Character(state);
}
@ -63,8 +60,6 @@ export class Character {
status: StatusAlive,
level,
factions: new Set(),
totalDamageTaken: Damage.create(0),
factionsJoined: new Set(),
};
return new Character(state);
}
@ -82,8 +77,6 @@ export class Character {
status,
level,
factions: new Set(),
totalDamageTaken: Damage.create(0),
factionsJoined: new Set(),
};
return new Character(state);
}
@ -108,14 +101,6 @@ 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.
@ -137,24 +122,12 @@ 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: newLevel,
level: this.level,
factions: newFactions,
totalDamageTaken: this.totalDamageTaken,
factionsJoined: newFactionsJoined,
});
}
@ -174,8 +147,6 @@ export class Character {
status: this.status,
level: this.level,
factions: newFactions,
totalDamageTaken: this.totalDamageTaken,
factionsJoined: this.factionsJoined,
});
}
@ -198,8 +169,6 @@ export class Character {
status: ally.status,
level: ally.level,
factions: ally.factions,
totalDamageTaken: ally.totalDamageTaken,
factionsJoined: ally.factionsJoined,
});
}
@ -207,46 +176,34 @@ 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: Damage): Character {
dealDamage(target: Character, damage: number): 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.value;
let actualDamage = damage;
if (levelDiff <= -5) {
// Target is ≥5 levels above → damage reduced by 50%
actualDamage = Math.floor(damage.value * 0.5);
actualDamage = Math.floor(damage * 0.5);
} else if (levelDiff >= 5) {
// Target is ≥5 levels below → damage increased by 50%
actualDamage = Math.floor(damage.value * 1.5);
actualDamage = Math.floor(damage * 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: newLevel,
level: target.level,
factions: target.factions,
totalDamageTaken: newTotalDamage,
factionsJoined: target.factionsJoined,
});
}
@ -268,8 +225,6 @@ export class Character {
status: this.status,
level: this.level,
factions: this.factions,
totalDamageTaken: this.totalDamageTaken,
factionsJoined: this.factionsJoined,
});
}

View File

@ -8,7 +8,6 @@ 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;
@ -16,6 +15,4 @@ export type CharacterState = {
readonly status: Status;
readonly level: Level;
readonly factions: ReadonlySet<Faction>;
readonly totalDamageTaken: Damage;
readonly factionsJoined: ReadonlySet<Faction>;
};

View File

@ -2,7 +2,6 @@ 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', () => {
@ -19,7 +18,7 @@ describe('DamageAndHealth', () => {
health,
});
const expected = Math.max(0, health - damage);
const result = attacker.dealDamage(target, Damage.create(damage));
const result = attacker.dealDamage(target, damage);
return result.health.value === expected;
},
),
@ -40,7 +39,7 @@ describe('DamageAndHealth', () => {
level: Level.create(1),
health,
});
const result = attacker.dealDamage(target, Damage.create(damage));
const result = attacker.dealDamage(target, damage);
return result.health.value >= 0;
},
),
@ -61,7 +60,7 @@ describe('DamageAndHealth', () => {
level: Level.create(1),
health,
});
const result = attacker.dealDamage(target, Damage.create(damage));
const result = attacker.dealDamage(target, damage);
const expected = Math.max(0, health - damage);
if (expected === 0) {
return result.status.kind === 'dead';
@ -83,7 +82,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.create(damage));
const result = c.dealDamage(c, damage);
// Should return the same reference
return (
result === c &&
@ -103,11 +102,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, Damage.create(10000));
const deadTarget = attacker.dealDamage(target, 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.create(damage));
const result = attacker.dealDamage(deadTarget, damage);
return (
result === deadTarget &&
result.health.value === healthBefore &&
@ -119,12 +118,14 @@ describe('DamageAndHealth', () => {
});
describe('NegativeDamageForbidden', () => {
it('property: negative damage throws an error via Damage.create', () => {
it('property: negative damage throws an error', () => {
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 {
Damage.create(negativeDamage);
attacker.dealDamage(target, negativeDamage);
} catch {
threw = true;
}

View File

@ -3,7 +3,6 @@ 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) });
@ -190,7 +189,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, Damage.create(10000));
const deadHero = attacker.dealDamage(hero, 10000);
const f = Faction.create(factionName);
const result = deadHero.joinFaction(f);
return result === deadHero && result.factions.size === 0;
@ -210,7 +209,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, Damage.create(10000));
const deadHero = attacker.dealDamage(withFaction, 10000);
const result = deadHero.leaveFaction(f);
return result === deadHero && result.factions.has(f);
},
@ -303,7 +302,7 @@ describe('Factions', () => {
const target = ally().joinFaction(faction);
const healthBefore = target.health.value;
const statusBefore = target.status.kind;
const result = attacker.dealDamage(target, Damage.create(500));
const result = attacker.dealDamage(target, 500);
return result.health.value === healthBefore && result.status.kind === statusBefore;
}),
);
@ -315,7 +314,7 @@ describe('Factions', () => {
const faction = Faction.create('guard');
const attacker = hero().joinFaction(faction);
const target = ally().joinFaction(faction);
const result = attacker.dealDamage(target, Damage.create(500));
const result = attacker.dealDamage(target, 500);
return result === target;
}),
);
@ -327,7 +326,7 @@ describe('Factions', () => {
const attacker = hero();
const target = enemy();
const healthBefore = target.health.value;
const result = attacker.dealDamage(target, Damage.create(100));
const result = attacker.dealDamage(target, 100);
return result.health.value === healthBefore - 100;
}),
);
@ -415,7 +414,7 @@ describe('Factions', () => {
const target = ally().joinFaction(faction);
// Kill the target with a non-ally attacker
const killer = enemy();
const deadTarget = killer.dealDamage(target, Damage.create(10000));
const deadTarget = killer.dealDamage(target, 10000);
const healthBefore = deadTarget.health.value;
const statusBefore = deadTarget.status.kind;
const result = healer.healAlly(deadTarget, 500);

View File

@ -2,7 +2,6 @@ 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', () => {
@ -89,7 +88,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, Damage.create(10000));
const deadHero = attacker.dealDamage(hero, 10000);
const healthBefore = deadHero.health.value;
const statusBefore = deadHero.status.kind;
// Try to heal the dead character

View File

@ -2,7 +2,6 @@ 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', () => {
@ -22,7 +21,7 @@ describe('Levels', () => {
level: Level.create(targetLevel),
health: 1000,
});
const result = attacker.dealDamage(target, Damage.create(baseDamage));
const result = attacker.dealDamage(target, baseDamage);
return result.health.value === Math.max(0, 1000 - baseDamage);
},
),
@ -48,7 +47,7 @@ describe('Levels', () => {
health: 1000,
});
const expectedDamage = Math.floor(baseDamage * 0.5);
const result = attacker.dealDamage(target, Damage.create(baseDamage));
const result = attacker.dealDamage(target, baseDamage);
return result.health.value === Math.max(0, 1000 - expectedDamage);
},
),
@ -67,7 +66,7 @@ describe('Levels', () => {
health: 1000,
});
const expectedDamage = Math.floor(oddDamage * 0.5);
const result = attacker.dealDamage(target, Damage.create(oddDamage));
const result = attacker.dealDamage(target, oddDamage);
return result.health.value === Math.max(0, 1000 - expectedDamage);
}),
);
@ -92,7 +91,7 @@ describe('Levels', () => {
health: 1000,
});
const expectedDamage = Math.floor(baseDamage * 1.5);
const result = attacker.dealDamage(target, Damage.create(baseDamage));
const result = attacker.dealDamage(target, baseDamage);
return result.health.value === Math.max(0, 1000 - expectedDamage);
},
),
@ -112,7 +111,7 @@ describe('Levels', () => {
health: 1000,
});
const expectedDamage = Math.floor(damage * 0.5);
const result = attacker.dealDamage(target, Damage.create(damage));
const result = attacker.dealDamage(target, damage);
return result.health.value === Math.max(0, 1000 - expectedDamage);
}),
);
@ -129,7 +128,7 @@ describe('Levels', () => {
health: 1000,
});
const expectedDamage = Math.floor(damage * 1.5);
const result = attacker.dealDamage(target, Damage.create(damage));
const result = attacker.dealDamage(target, damage);
return result.health.value === Math.max(0, 1000 - expectedDamage);
}),
);
@ -145,7 +144,7 @@ describe('Levels', () => {
level: Level.create(5),
health: 1000,
});
const result = attacker.dealDamage(target, Damage.create(damage));
const result = attacker.dealDamage(target, damage);
return result.health.value === Math.max(0, 1000 - damage);
}),
);
@ -161,7 +160,7 @@ describe('Levels', () => {
level: Level.create(1),
health: 1000,
});
const result = attacker.dealDamage(target, Damage.create(damage));
const result = attacker.dealDamage(target, damage);
return result.health.value === Math.max(0, 1000 - damage);
}),
);

View File

@ -2,7 +2,6 @@ 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';
@ -86,7 +85,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, Damage.create(10000));
const deadAttacker = killer.dealDamage(attacker, 10000);
const weaponHPBefore = weapon.health.value;
const targetHealthBefore = target.health.value;
const result = deadAttacker.useWeapon(weapon, target);
@ -257,7 +256,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, Damage.create(10000));
const deadCharacter = killer.dealDamage(character, 10000);
const objectHPBefore = object.health.value;
const characterHealthBefore = deadCharacter.health.value;
const result = deadCharacter.useHealingObject(object, 100);

View File

@ -7,19 +7,18 @@
*/
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: Damage;
readonly #damage: number;
readonly #owner: Character;
private constructor(
health: Health,
maxHealth: Health,
status: { readonly kind: 'alive' } | { readonly kind: 'destroyed' },
damage: Damage,
damage: number,
owner: Character,
) {
super(health, maxHealth, status);
@ -37,16 +36,17 @@ 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.create(damage),
damage,
owner,
);
}
get damage(): Damage {
get damage(): number {
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.value);
const newTargetHealth = Math.max(0, target.health.value - this.#damage);
const newTargetStatus = newTargetHealth === 0 ? { kind: 'dead' as const } : target.status;
const newTarget = Character.createWithHealthAndStatus({
name: target.name,

View File

@ -1,26 +0,0 @@
/**
* 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);
}
}

View File

@ -31,12 +31,4 @@ export class Level {
diff(target: Level): number {
return this.value - target.value;
}
/** Damage threshold to reach the given level.
* Level 12: 1000, Level 23: 3000, Level 34: 6000, etc.
* Formula: 1000 * N * (N+1) / 2
*/
static damageThresholdForLevel(level: number): number {
return (1000 * level * (level + 1)) / 2;
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -50,7 +50,6 @@ 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