Compare commits
9
Commits
23edbc6e36
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21bd58be33 | ||
|
|
31984bbd9d | ||
|
|
39839dc594 | ||
|
|
692bd7305b | ||
|
|
1c9d4ad66b | ||
|
|
fb61fb85ed | ||
|
|
ba0903714c | ||
|
|
0540e5ff5b | ||
|
|
dea77d463f |
@@ -0,0 +1,20 @@
|
|||||||
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
||||||
|
import { isToolCallEventType } from '@earendil-works/pi-coding-agent';
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
pi.on('tool_call', async (event, ctx) => {
|
||||||
|
if (!isToolCallEventType('bash', event)) return;
|
||||||
|
|
||||||
|
const cmd = event.input.command || '';
|
||||||
|
const isYxCommand = cmd.includes('yx ');
|
||||||
|
|
||||||
|
// Allow yaks in interactive mode, block in print mode (sub-agents)
|
||||||
|
if (isYxCommand && ctx.mode === 'print') {
|
||||||
|
return {
|
||||||
|
block: true,
|
||||||
|
reason:
|
||||||
|
'yx commands are disabled in print mode. Sub-agents must focus on domain work only.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
---
|
||||||
|
name: yak-tasks
|
||||||
|
description: Orchestrate sub-agents using yx task tracking. Use when delegating work to sub-agents, tracking task progress, or marking tasks complete. For breaking down problems into tasks, use /skill:problem-breakdown first.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Yak Task Orchestration
|
||||||
|
|
||||||
|
Coordinate sub-agent work using the `yx` CLI. The main agent creates tasks and delegates to sub-agents; sub-agents execute domain work without yx CLI access.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. **Break down** → `/skill:problem-breakdown` (creates yaks with context)
|
||||||
|
2. **Delegate** → sub-agents run on yaks via `pi -p` (blocked from yx CLI)
|
||||||
|
3. **Track** → `yx list` / `yx show <task-id>`
|
||||||
|
4. **Verify** → run `npm run checks` after sub-agent work
|
||||||
|
5. **Complete** → `yx done <task-id>`
|
||||||
|
|
||||||
|
## Running Sub-Agents
|
||||||
|
|
||||||
|
Sub-agents execute in print mode and are **hard-blocked from using `yx` commands** (see `.pi/extensions/yak-mode-gate.ts`). They receive:
|
||||||
|
|
||||||
|
- The yak's `.context.md` (task description)
|
||||||
|
- `AGENTS.md` (project conventions)
|
||||||
|
- Domain files to work on
|
||||||
|
- No yx CLI access
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run a sub-agent on a specific yak
|
||||||
|
# The sub-agent reads the yak context and executes the work
|
||||||
|
pi -p "Work on yak: <task-name>. Read .yaks/<task-id>/.context.md for details."
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tracking Progress
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List all yaks with hierarchy
|
||||||
|
yx list
|
||||||
|
|
||||||
|
# Show yak details
|
||||||
|
yx show <task-id>
|
||||||
|
|
||||||
|
# Check state directly
|
||||||
|
cat .yaks/<task-id>/.state
|
||||||
|
```
|
||||||
|
|
||||||
|
States:
|
||||||
|
|
||||||
|
- `pending` — not yet started
|
||||||
|
- `in-progress` — being worked on
|
||||||
|
- `done` — completed
|
||||||
|
|
||||||
|
## Marking Tasks Complete
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yx done <task-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
Always verify before marking done:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Check state
|
||||||
|
cat .yaks/<task-id>/.state
|
||||||
|
|
||||||
|
# 2. Review what was done
|
||||||
|
cat .yaks/<task-id>/.context.md
|
||||||
|
|
||||||
|
# 3. Run project checks
|
||||||
|
npm run checks
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sub-Agent Communication
|
||||||
|
|
||||||
|
Sub-agents can read yak state files directly (no yx CLI needed):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat .yaks/<task-id>/.name # task name
|
||||||
|
cat .yaks/<task-id>/.state # current state
|
||||||
|
cat .yaks/<task-id>/.context.md # task description
|
||||||
|
cat .yaks/<task-id>/.created.json # creation metadata
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
- **Delegate after breakdown** — run `/skill:problem-breakdown` first to create structured yaks
|
||||||
|
- **Verify before marking done** — always run `npm run checks` to catch sub-agent errors
|
||||||
|
- **Review context** — read `.context.md` to understand what the sub-agent was supposed to do
|
||||||
|
- **Keep yaks focused** — each yak should be a single file operation or one method implementation
|
||||||
|
- **Use hierarchy** — parent yaks block children; fix leaves first
|
||||||
@@ -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:
|
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
|
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
|
3. **"I can't believe it's not Haskell"** — TypeScript with ADTs, value objects, and immutability
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ The TypeScript implementation embraces functional patterns:
|
|||||||
All five user stories are implemented and verified:
|
All five user stories are implemented and verified:
|
||||||
|
|
||||||
| Story | Topic | Status |
|
| Story | Topic | Status |
|
||||||
|-------|-------|--------|
|
| ----- | --------------------------- | ------- |
|
||||||
| 1 | Character Creation & Damage | ✅ Done |
|
| 1 | Character Creation & Damage | ✅ Done |
|
||||||
| 2 | Levels | ✅ Done |
|
| 2 | Levels | ✅ Done |
|
||||||
| 3 | Factions | ✅ Done |
|
| 3 | Factions | ✅ Done |
|
||||||
@@ -85,7 +85,9 @@ Two custom extensions were developed for this project:
|
|||||||
|
|
||||||
### The Transcript Archive
|
### 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
|
## Build & Test
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import js from '@eslint/js';
|
import js from '@eslint/js';
|
||||||
import tseslint from 'typescript-eslint';
|
import tseslint from 'typescript-eslint';
|
||||||
import prettier from 'eslint-config-prettier';
|
import prettier from 'eslint-config-prettier';
|
||||||
|
import noPrimitiveValue from './eslint/no-primitive-value-properties.js';
|
||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{ ignores: ['dist', 'node_modules', 'coverage', 'allium-main'] },
|
{ ignores: ['dist', 'node_modules', 'coverage', 'allium-main'] },
|
||||||
@@ -17,9 +18,17 @@ export default tseslint.config(
|
|||||||
tsconfigRootDir: import.meta.dirname,
|
tsconfigRootDir: import.meta.dirname,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
plugins: {
|
||||||
|
rpg: {
|
||||||
|
rules: {
|
||||||
|
'no-primitive-value-properties': noPrimitiveValue,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
rules: {
|
rules: {
|
||||||
'@typescript-eslint/switch-exhaustiveness-check': 'warn',
|
'@typescript-eslint/switch-exhaustiveness-check': 'warn',
|
||||||
'@typescript-eslint/no-unnecessary-condition': 'warn',
|
'@typescript-eslint/no-unnecessary-condition': 'warn',
|
||||||
|
'rpg/no-primitive-value-properties': 'warn',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { readdirSync } from 'node:fs';
|
||||||
|
import { join, parse } from 'node:path';
|
||||||
|
|
||||||
|
const VALUE_OBJECTS_DIR = join(import.meta.dirname, '..', 'src', 'value-objects');
|
||||||
|
|
||||||
|
function discoverValueObjects() {
|
||||||
|
const files = readdirSync(VALUE_OBJECTS_DIR).filter((f) => f.endsWith('.ts') && f !== 'index.ts');
|
||||||
|
return files.map((f) => parse(f).name);
|
||||||
|
}
|
||||||
|
|
||||||
|
const VALUE_OBJECTS = discoverValueObjects();
|
||||||
|
|
||||||
|
function buildPropertyMap() {
|
||||||
|
const map = {};
|
||||||
|
for (const name of VALUE_OBJECTS) {
|
||||||
|
const propertyName = name.charAt(0).toLowerCase() + name.slice(1);
|
||||||
|
map[propertyName] = name;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROPERTY_MAP = buildPropertyMap();
|
||||||
|
|
||||||
|
/** Map ESLint AST type names to the primitive keyword string. */
|
||||||
|
const PRIMITIVE_TYPE_MAP = {
|
||||||
|
TSNumberKeyword: 'number',
|
||||||
|
TSStringKeyword: 'string',
|
||||||
|
TSBooleanKeyword: 'boolean',
|
||||||
|
};
|
||||||
|
|
||||||
|
function isPrimitiveType(typeNode) {
|
||||||
|
if (!typeNode) return false;
|
||||||
|
const keyword = PRIMITIVE_TYPE_MAP[typeNode.type];
|
||||||
|
return keyword !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPrimitiveKeyword(typeNode) {
|
||||||
|
if (!typeNode) return null;
|
||||||
|
return PRIMITIVE_TYPE_MAP[typeNode.type] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getParamName(param) {
|
||||||
|
if (param.type === 'TSParameterProperty') {
|
||||||
|
return getParamName(param.parameter);
|
||||||
|
}
|
||||||
|
if (param.type === 'Identifier') {
|
||||||
|
return param.name;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
meta: {
|
||||||
|
type: 'suggestion',
|
||||||
|
docs: {
|
||||||
|
description:
|
||||||
|
'Warn when primitive types are used in class properties or constructor parameters that should be value objects',
|
||||||
|
recommended: false,
|
||||||
|
},
|
||||||
|
schema: [
|
||||||
|
{
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
includeConstructorParams: { type: 'boolean', default: true },
|
||||||
|
includeProperties: { type: 'boolean', default: true },
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
messages: {
|
||||||
|
primitiveProperty:
|
||||||
|
'Use value object "{{valueObject}}" instead of primitive "{{primitive}}" for property "{{propertyName}}". Available: {{available}}',
|
||||||
|
primitiveParam:
|
||||||
|
'Use value object "{{valueObject}}" instead of primitive "{{primitive}}" for parameter "{{paramName}}". Available: {{available}}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
create(context) {
|
||||||
|
const options = context.options[0] || {};
|
||||||
|
const includeConstructorParams = options.includeConstructorParams !== false;
|
||||||
|
const includeProperties = options.includeProperties !== false;
|
||||||
|
|
||||||
|
function reportPrimitive(node, propName, primitiveType, messageId) {
|
||||||
|
const valueObject = PROPERTY_MAP[propName];
|
||||||
|
if (valueObject) {
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
messageId,
|
||||||
|
data: {
|
||||||
|
valueObject,
|
||||||
|
primitive: primitiveType,
|
||||||
|
propertyName: propName,
|
||||||
|
paramName: propName,
|
||||||
|
available: VALUE_OBJECTS.join(', '),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ClassProperty(node) {
|
||||||
|
if (!includeProperties) return;
|
||||||
|
if (!node.typeAnnotation) return;
|
||||||
|
|
||||||
|
const typeNode = node.typeAnnotation.typeAnnotation;
|
||||||
|
if (!isPrimitiveType(typeNode)) return;
|
||||||
|
|
||||||
|
reportPrimitive(node, node.key.name, getPrimitiveKeyword(typeNode), 'primitiveProperty');
|
||||||
|
},
|
||||||
|
|
||||||
|
PropertyDefinition(node) {
|
||||||
|
if (!includeProperties) return;
|
||||||
|
if (!node.typeAnnotation) return;
|
||||||
|
|
||||||
|
const typeNode = node.typeAnnotation.typeAnnotation;
|
||||||
|
if (!isPrimitiveType(typeNode)) return;
|
||||||
|
|
||||||
|
reportPrimitive(node, node.key.name, getPrimitiveKeyword(typeNode), 'primitiveProperty');
|
||||||
|
},
|
||||||
|
|
||||||
|
MethodDefinition(node) {
|
||||||
|
if (!includeConstructorParams) return;
|
||||||
|
if (node.key.name !== 'constructor') return;
|
||||||
|
if (!node.value.params) return;
|
||||||
|
|
||||||
|
for (const param of node.value.params) {
|
||||||
|
let paramName = null;
|
||||||
|
let typeNode = null;
|
||||||
|
|
||||||
|
if (param.type === 'TSParameterProperty') {
|
||||||
|
paramName = getParamName(param.parameter);
|
||||||
|
typeNode = param.parameter.typeAnnotation?.typeAnnotation;
|
||||||
|
} else if (param.type === 'Identifier') {
|
||||||
|
paramName = param.name;
|
||||||
|
typeNode = param.typeAnnotation?.typeAnnotation;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!paramName || !typeNode || !isPrimitiveType(typeNode)) continue;
|
||||||
|
|
||||||
|
reportPrimitive(param, paramName, getPrimitiveKeyword(typeNode), 'primitiveParam');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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
|
-- Value Types
|
||||||
------------------------------------------------------------
|
------------------------------------------------------------
|
||||||
|
|
||||||
type Faction {
|
value Faction {
|
||||||
name: String
|
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 {
|
entity Character {
|
||||||
name: String
|
name: String
|
||||||
health: Health
|
health: Health
|
||||||
status: alive | dead
|
status: Status
|
||||||
level: Level
|
level: Level
|
||||||
factions: Set<Faction>
|
factions: Set<Faction>
|
||||||
}
|
}
|
||||||
@@ -83,12 +96,7 @@ rule DeadCannotLeaveFaction {
|
|||||||
invariant FactionsAlwaysValid {
|
invariant FactionsAlwaysValid {
|
||||||
for c in Characters:
|
for c in Characters:
|
||||||
for f in c.factions:
|
for f in c.factions:
|
||||||
f.name.trim().length > 0
|
f.name.length > 0
|
||||||
}
|
|
||||||
|
|
||||||
invariant AllyRelationIsSymmetric {
|
|
||||||
for a in Characters, b in Characters:
|
|
||||||
a.isAllyOf(b) implies b.isAllyOf(a)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
invariant SelfNotAlly {
|
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 { Health } from '../value-objects/Health.ts';
|
||||||
import { Level } from '../value-objects/Level.ts';
|
import { Level } from '../value-objects/Level.ts';
|
||||||
|
import { Damage } from '../value-objects/Damage.ts';
|
||||||
import type { Status } from '../value-objects/Status.ts';
|
import type { Status } from '../value-objects/Status.ts';
|
||||||
import { StatusAlive, StatusDead } from '../value-objects/Status.ts';
|
import { StatusAlive, StatusDead } from '../value-objects/Status.ts';
|
||||||
import type { CharacterState } from './CharacterState.ts';
|
import type { CharacterState } from './CharacterState.ts';
|
||||||
@@ -48,6 +49,8 @@ export class Character {
|
|||||||
status: StatusAlive,
|
status: StatusAlive,
|
||||||
level,
|
level,
|
||||||
factions: new Set(),
|
factions: new Set(),
|
||||||
|
totalDamageTaken: Damage.create(0),
|
||||||
|
factionsJoined: new Set(),
|
||||||
};
|
};
|
||||||
return new Character(state);
|
return new Character(state);
|
||||||
}
|
}
|
||||||
@@ -60,6 +63,8 @@ export class Character {
|
|||||||
status: StatusAlive,
|
status: StatusAlive,
|
||||||
level,
|
level,
|
||||||
factions: new Set(),
|
factions: new Set(),
|
||||||
|
totalDamageTaken: Damage.create(0),
|
||||||
|
factionsJoined: new Set(),
|
||||||
};
|
};
|
||||||
return new Character(state);
|
return new Character(state);
|
||||||
}
|
}
|
||||||
@@ -77,6 +82,8 @@ export class Character {
|
|||||||
status,
|
status,
|
||||||
level,
|
level,
|
||||||
factions: new Set(),
|
factions: new Set(),
|
||||||
|
totalDamageTaken: Damage.create(0),
|
||||||
|
factionsJoined: new Set(),
|
||||||
};
|
};
|
||||||
return new Character(state);
|
return new Character(state);
|
||||||
}
|
}
|
||||||
@@ -101,6 +108,14 @@ export class Character {
|
|||||||
return this.#state.factions;
|
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.
|
* Check if this character is an ally of another.
|
||||||
* Two characters are allies if they share at least one faction.
|
* 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;
|
if (this.status.kind === 'dead') return this;
|
||||||
const newFactions = new Set(this.#state.factions);
|
const newFactions = new Set(this.#state.factions);
|
||||||
newFactions.add(faction);
|
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({
|
return new Character({
|
||||||
name: this.name,
|
name: this.name,
|
||||||
health: this.health,
|
health: this.health,
|
||||||
status: this.status,
|
status: this.status,
|
||||||
level: this.level,
|
level: newLevel,
|
||||||
factions: newFactions,
|
factions: newFactions,
|
||||||
|
totalDamageTaken: this.totalDamageTaken,
|
||||||
|
factionsJoined: newFactionsJoined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,6 +174,8 @@ export class Character {
|
|||||||
status: this.status,
|
status: this.status,
|
||||||
level: this.level,
|
level: this.level,
|
||||||
factions: newFactions,
|
factions: newFactions,
|
||||||
|
totalDamageTaken: this.totalDamageTaken,
|
||||||
|
factionsJoined: this.factionsJoined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,6 +198,8 @@ export class Character {
|
|||||||
status: ally.status,
|
status: ally.status,
|
||||||
level: ally.level,
|
level: ally.level,
|
||||||
factions: ally.factions,
|
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.
|
* Deal damage to another character. Returns a new Character with updated state.
|
||||||
* Does not mutate the attacker or the original target reference.
|
* 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
|
// Self-damage is forbidden — use reference equality, not name
|
||||||
if (this === target) return target;
|
if (this === target) return target;
|
||||||
// Allies cannot deal damage to each other
|
// Allies cannot deal damage to each other
|
||||||
if (this.isAllyOf(target)) return target;
|
if (this.isAllyOf(target)) return target;
|
||||||
// Dead characters cannot take damage
|
// Dead characters cannot take damage
|
||||||
if (target.status.kind === 'dead') return target;
|
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
|
// Level-based damage modifier
|
||||||
const levelDiff = this.level.diff(target.level); // = this.level - target.level
|
const levelDiff = this.level.diff(target.level); // = this.level - target.level
|
||||||
let actualDamage = damage;
|
let actualDamage = damage.value;
|
||||||
if (levelDiff <= -5) {
|
if (levelDiff <= -5) {
|
||||||
// Target is ≥5 levels above → damage reduced by 50%
|
// 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) {
|
} else if (levelDiff >= 5) {
|
||||||
// Target is ≥5 levels below → damage increased by 50%
|
// 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
|
// Reduce health by the (possibly modified) damage amount
|
||||||
const newHealth = target.health.sub(actualDamage);
|
const newHealth = target.health.sub(actualDamage);
|
||||||
const newStatus = newHealth.value === 0 ? StatusDead : StatusAlive;
|
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({
|
return new Character({
|
||||||
name: target.name,
|
name: target.name,
|
||||||
health: newHealth,
|
health: newHealth,
|
||||||
status: newStatus,
|
status: newStatus,
|
||||||
level: target.level,
|
level: newLevel,
|
||||||
factions: target.factions,
|
factions: target.factions,
|
||||||
|
totalDamageTaken: newTotalDamage,
|
||||||
|
factionsJoined: target.factionsJoined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,6 +268,8 @@ export class Character {
|
|||||||
status: this.status,
|
status: this.status,
|
||||||
level: this.level,
|
level: this.level,
|
||||||
factions: this.factions,
|
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 { Level } from '../value-objects/Level.ts';
|
||||||
import type { Status } from '../value-objects/Status.ts';
|
import type { Status } from '../value-objects/Status.ts';
|
||||||
import type { Faction } from '../factions/Faction.ts';
|
import type { Faction } from '../factions/Faction.ts';
|
||||||
|
import type { Damage } from '../value-objects/Damage.ts';
|
||||||
|
|
||||||
export type CharacterState = {
|
export type CharacterState = {
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
@@ -15,4 +16,6 @@ export type CharacterState = {
|
|||||||
readonly status: Status;
|
readonly status: Status;
|
||||||
readonly level: Level;
|
readonly level: Level;
|
||||||
readonly factions: ReadonlySet<Faction>;
|
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 { describe, it } from 'vitest';
|
||||||
import { Character } from './characters/Character.ts';
|
import { Character } from './characters/Character.ts';
|
||||||
import { Level } from './value-objects/Level.ts';
|
import { Level } from './value-objects/Level.ts';
|
||||||
|
import { Damage } from './value-objects/Damage.ts';
|
||||||
|
|
||||||
describe('DamageAndHealth', () => {
|
describe('DamageAndHealth', () => {
|
||||||
describe('DamageReducesHealth', () => {
|
describe('DamageReducesHealth', () => {
|
||||||
@@ -18,7 +19,7 @@ describe('DamageAndHealth', () => {
|
|||||||
health,
|
health,
|
||||||
});
|
});
|
||||||
const expected = Math.max(0, health - damage);
|
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;
|
return result.health.value === expected;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -39,7 +40,7 @@ describe('DamageAndHealth', () => {
|
|||||||
level: Level.create(1),
|
level: Level.create(1),
|
||||||
health,
|
health,
|
||||||
});
|
});
|
||||||
const result = attacker.dealDamage(target, damage);
|
const result = attacker.dealDamage(target, Damage.create(damage));
|
||||||
return result.health.value >= 0;
|
return result.health.value >= 0;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -60,7 +61,7 @@ describe('DamageAndHealth', () => {
|
|||||||
level: Level.create(1),
|
level: Level.create(1),
|
||||||
health,
|
health,
|
||||||
});
|
});
|
||||||
const result = attacker.dealDamage(target, damage);
|
const result = attacker.dealDamage(target, Damage.create(damage));
|
||||||
const expected = Math.max(0, health - damage);
|
const expected = Math.max(0, health - damage);
|
||||||
if (expected === 0) {
|
if (expected === 0) {
|
||||||
return result.status.kind === 'dead';
|
return result.status.kind === 'dead';
|
||||||
@@ -82,7 +83,7 @@ describe('DamageAndHealth', () => {
|
|||||||
const c = Character.createWithHealth({ name: 'hero', level: Level.create(1), health });
|
const c = Character.createWithHealth({ name: 'hero', level: Level.create(1), health });
|
||||||
const healthBefore = c.health.value;
|
const healthBefore = c.health.value;
|
||||||
const statusBefore = c.status.kind;
|
const statusBefore = c.status.kind;
|
||||||
const result = c.dealDamage(c, damage);
|
const result = c.dealDamage(c, Damage.create(damage));
|
||||||
// Should return the same reference
|
// Should return the same reference
|
||||||
return (
|
return (
|
||||||
result === c &&
|
result === c &&
|
||||||
@@ -102,11 +103,11 @@ describe('DamageAndHealth', () => {
|
|||||||
const attacker = Character.create({ name: 'attacker', level: Level.create(1) });
|
const attacker = Character.create({ name: 'attacker', level: Level.create(1) });
|
||||||
const target = Character.create({ name: 'target', level: Level.create(1) });
|
const target = Character.create({ name: 'target', level: Level.create(1) });
|
||||||
// Kill the target first — capture the returned (dead) character
|
// 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 healthBefore = deadTarget.health.value;
|
||||||
const statusBefore = deadTarget.status.kind;
|
const statusBefore = deadTarget.status.kind;
|
||||||
// Then try to deal more damage to the dead character
|
// 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 (
|
return (
|
||||||
result === deadTarget &&
|
result === deadTarget &&
|
||||||
result.health.value === healthBefore &&
|
result.health.value === healthBefore &&
|
||||||
@@ -118,14 +119,12 @@ describe('DamageAndHealth', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('NegativeDamageForbidden', () => {
|
describe('NegativeDamageForbidden', () => {
|
||||||
it('property: negative damage throws an error', () => {
|
it('property: negative damage throws an error via Damage.create', () => {
|
||||||
fc.assert(
|
fc.assert(
|
||||||
fc.property(fc.integer({ min: -10000, max: -1 }), (negativeDamage) => {
|
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;
|
let threw = false;
|
||||||
try {
|
try {
|
||||||
attacker.dealDamage(target, negativeDamage);
|
Damage.create(negativeDamage);
|
||||||
} catch {
|
} catch {
|
||||||
threw = true;
|
threw = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { describe, it } from 'vitest';
|
|||||||
import { Character } from './characters/Character.ts';
|
import { Character } from './characters/Character.ts';
|
||||||
import { Faction } from './factions/Faction.ts';
|
import { Faction } from './factions/Faction.ts';
|
||||||
import { Level } from './value-objects/Level.ts';
|
import { Level } from './value-objects/Level.ts';
|
||||||
|
import { Damage } from './value-objects/Damage.ts';
|
||||||
|
|
||||||
describe('Factions', () => {
|
describe('Factions', () => {
|
||||||
const hero = () => Character.create({ name: 'hero', level: Level.create(1) });
|
const hero = () => Character.create({ name: 'hero', level: Level.create(1) });
|
||||||
@@ -189,7 +190,7 @@ describe('Factions', () => {
|
|||||||
(factionName) => {
|
(factionName) => {
|
||||||
const attacker = Character.create({ name: 'attacker', level: Level.create(1) });
|
const attacker = Character.create({ name: 'attacker', level: Level.create(1) });
|
||||||
const hero = Character.create({ name: 'hero', 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 f = Faction.create(factionName);
|
||||||
const result = deadHero.joinFaction(f);
|
const result = deadHero.joinFaction(f);
|
||||||
return result === deadHero && result.factions.size === 0;
|
return result === deadHero && result.factions.size === 0;
|
||||||
@@ -209,7 +210,7 @@ describe('Factions', () => {
|
|||||||
const hero = Character.create({ name: 'hero', level: Level.create(1) });
|
const hero = Character.create({ name: 'hero', level: Level.create(1) });
|
||||||
const f = Faction.create(factionName);
|
const f = Faction.create(factionName);
|
||||||
const withFaction = hero.joinFaction(f);
|
const withFaction = hero.joinFaction(f);
|
||||||
const deadHero = attacker.dealDamage(withFaction, 10000);
|
const deadHero = attacker.dealDamage(withFaction, Damage.create(10000));
|
||||||
const result = deadHero.leaveFaction(f);
|
const result = deadHero.leaveFaction(f);
|
||||||
return result === deadHero && result.factions.has(f);
|
return result === deadHero && result.factions.has(f);
|
||||||
},
|
},
|
||||||
@@ -302,7 +303,7 @@ describe('Factions', () => {
|
|||||||
const target = ally().joinFaction(faction);
|
const target = ally().joinFaction(faction);
|
||||||
const healthBefore = target.health.value;
|
const healthBefore = target.health.value;
|
||||||
const statusBefore = target.status.kind;
|
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;
|
return result.health.value === healthBefore && result.status.kind === statusBefore;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -314,7 +315,7 @@ describe('Factions', () => {
|
|||||||
const faction = Faction.create('guard');
|
const faction = Faction.create('guard');
|
||||||
const attacker = hero().joinFaction(faction);
|
const attacker = hero().joinFaction(faction);
|
||||||
const target = ally().joinFaction(faction);
|
const target = ally().joinFaction(faction);
|
||||||
const result = attacker.dealDamage(target, 500);
|
const result = attacker.dealDamage(target, Damage.create(500));
|
||||||
return result === target;
|
return result === target;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -326,7 +327,7 @@ describe('Factions', () => {
|
|||||||
const attacker = hero();
|
const attacker = hero();
|
||||||
const target = enemy();
|
const target = enemy();
|
||||||
const healthBefore = target.health.value;
|
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;
|
return result.health.value === healthBefore - 100;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -414,7 +415,7 @@ describe('Factions', () => {
|
|||||||
const target = ally().joinFaction(faction);
|
const target = ally().joinFaction(faction);
|
||||||
// Kill the target with a non-ally attacker
|
// Kill the target with a non-ally attacker
|
||||||
const killer = enemy();
|
const killer = enemy();
|
||||||
const deadTarget = killer.dealDamage(target, 10000);
|
const deadTarget = killer.dealDamage(target, Damage.create(10000));
|
||||||
const healthBefore = deadTarget.health.value;
|
const healthBefore = deadTarget.health.value;
|
||||||
const statusBefore = deadTarget.status.kind;
|
const statusBefore = deadTarget.status.kind;
|
||||||
const result = healer.healAlly(deadTarget, 500);
|
const result = healer.healAlly(deadTarget, 500);
|
||||||
|
|||||||
+2
-1
@@ -2,6 +2,7 @@ import fc from 'fast-check';
|
|||||||
import { describe, it } from 'vitest';
|
import { describe, it } from 'vitest';
|
||||||
import { Character } from './characters/Character.ts';
|
import { Character } from './characters/Character.ts';
|
||||||
import { Level } from './value-objects/Level.ts';
|
import { Level } from './value-objects/Level.ts';
|
||||||
|
import { Damage } from './value-objects/Damage.ts';
|
||||||
|
|
||||||
describe('Healing', () => {
|
describe('Healing', () => {
|
||||||
describe('SelfHealIncreasesHealth', () => {
|
describe('SelfHealIncreasesHealth', () => {
|
||||||
@@ -88,7 +89,7 @@ describe('Healing', () => {
|
|||||||
const attacker = Character.create({ name: 'attacker', level: Level.create(1) });
|
const attacker = Character.create({ name: 'attacker', level: Level.create(1) });
|
||||||
const hero = Character.create({ name: 'hero', level: Level.create(1) });
|
const hero = Character.create({ name: 'hero', level: Level.create(1) });
|
||||||
// Kill the hero first using a different attacker
|
// 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 healthBefore = deadHero.health.value;
|
||||||
const statusBefore = deadHero.status.kind;
|
const statusBefore = deadHero.status.kind;
|
||||||
// Try to heal the dead character
|
// Try to heal the dead character
|
||||||
|
|||||||
+9
-8
@@ -2,6 +2,7 @@ import fc from 'fast-check';
|
|||||||
import { describe, it } from 'vitest';
|
import { describe, it } from 'vitest';
|
||||||
import { Character } from './characters/Character.ts';
|
import { Character } from './characters/Character.ts';
|
||||||
import { Level } from './value-objects/Level.ts';
|
import { Level } from './value-objects/Level.ts';
|
||||||
|
import { Damage } from './value-objects/Damage.ts';
|
||||||
|
|
||||||
describe('Levels', () => {
|
describe('Levels', () => {
|
||||||
describe('CloseLevelNoModifier', () => {
|
describe('CloseLevelNoModifier', () => {
|
||||||
@@ -21,7 +22,7 @@ describe('Levels', () => {
|
|||||||
level: Level.create(targetLevel),
|
level: Level.create(targetLevel),
|
||||||
health: 1000,
|
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);
|
return result.health.value === Math.max(0, 1000 - baseDamage);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -47,7 +48,7 @@ describe('Levels', () => {
|
|||||||
health: 1000,
|
health: 1000,
|
||||||
});
|
});
|
||||||
const expectedDamage = Math.floor(baseDamage * 0.5);
|
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);
|
return result.health.value === Math.max(0, 1000 - expectedDamage);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -66,7 +67,7 @@ describe('Levels', () => {
|
|||||||
health: 1000,
|
health: 1000,
|
||||||
});
|
});
|
||||||
const expectedDamage = Math.floor(oddDamage * 0.5);
|
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);
|
return result.health.value === Math.max(0, 1000 - expectedDamage);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -91,7 +92,7 @@ describe('Levels', () => {
|
|||||||
health: 1000,
|
health: 1000,
|
||||||
});
|
});
|
||||||
const expectedDamage = Math.floor(baseDamage * 1.5);
|
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);
|
return result.health.value === Math.max(0, 1000 - expectedDamage);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -111,7 +112,7 @@ describe('Levels', () => {
|
|||||||
health: 1000,
|
health: 1000,
|
||||||
});
|
});
|
||||||
const expectedDamage = Math.floor(damage * 0.5);
|
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);
|
return result.health.value === Math.max(0, 1000 - expectedDamage);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -128,7 +129,7 @@ describe('Levels', () => {
|
|||||||
health: 1000,
|
health: 1000,
|
||||||
});
|
});
|
||||||
const expectedDamage = Math.floor(damage * 1.5);
|
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);
|
return result.health.value === Math.max(0, 1000 - expectedDamage);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -144,7 +145,7 @@ describe('Levels', () => {
|
|||||||
level: Level.create(5),
|
level: Level.create(5),
|
||||||
health: 1000,
|
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);
|
return result.health.value === Math.max(0, 1000 - damage);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -160,7 +161,7 @@ describe('Levels', () => {
|
|||||||
level: Level.create(1),
|
level: Level.create(1),
|
||||||
health: 1000,
|
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);
|
return result.health.value === Math.max(0, 1000 - damage);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
+20
-16
@@ -2,6 +2,7 @@ import fc from 'fast-check';
|
|||||||
import { describe, it } from 'vitest';
|
import { describe, it } from 'vitest';
|
||||||
import { Character } from './characters/Character.ts';
|
import { Character } from './characters/Character.ts';
|
||||||
import { Level } from './value-objects/Level.ts';
|
import { Level } from './value-objects/Level.ts';
|
||||||
|
import { Damage } from './value-objects/Damage.ts';
|
||||||
import { MagicalWeapon } from './magical-objects/MagicalWeapon.ts';
|
import { MagicalWeapon } from './magical-objects/MagicalWeapon.ts';
|
||||||
import { HealingObject } from './magical-objects/HealingObject.ts';
|
import { HealingObject } from './magical-objects/HealingObject.ts';
|
||||||
|
|
||||||
@@ -38,7 +39,7 @@ describe('Magical Objects', () => {
|
|||||||
const target = Character.create({ name: 'goblin', level: Level.create(1) });
|
const target = Character.create({ name: 'goblin', level: Level.create(1) });
|
||||||
const weapon = MagicalWeapon.create({ damage, maxHealth: weaponHP, owner: attacker });
|
const weapon = MagicalWeapon.create({ damage, maxHealth: weaponHP, owner: attacker });
|
||||||
const result = attacker.useWeapon(weapon, target);
|
const result = attacker.useWeapon(weapon, target);
|
||||||
return result.weapon.health === weaponHP - 1;
|
return result.weapon.health.value === weaponHP - 1;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -85,12 +86,12 @@ describe('Magical Objects', () => {
|
|||||||
const weapon = MagicalWeapon.create({ damage, maxHealth: weaponHP, owner: attacker });
|
const weapon = MagicalWeapon.create({ damage, maxHealth: weaponHP, owner: attacker });
|
||||||
// Kill the attacker using a separate killer
|
// Kill the attacker using a separate killer
|
||||||
const killer = Character.create({ name: 'boss', level: Level.create(1) });
|
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;
|
const weaponHPBefore = weapon.health.value;
|
||||||
const targetHealthBefore = target.health.value;
|
const targetHealthBefore = target.health.value;
|
||||||
const result = deadAttacker.useWeapon(weapon, target);
|
const result = deadAttacker.useWeapon(weapon, target);
|
||||||
return (
|
return (
|
||||||
result.weapon.health === weaponHPBefore &&
|
result.weapon.health.value === weaponHPBefore &&
|
||||||
result.target.health.value === targetHealthBefore
|
result.target.health.value === targetHealthBefore
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -110,11 +111,11 @@ describe('Magical Objects', () => {
|
|||||||
const thief = Character.create({ name: 'thief', level: Level.create(1) });
|
const thief = Character.create({ name: 'thief', level: Level.create(1) });
|
||||||
const target = Character.create({ name: 'goblin', level: Level.create(1) });
|
const target = Character.create({ name: 'goblin', level: Level.create(1) });
|
||||||
const weapon = MagicalWeapon.create({ damage, maxHealth: weaponHP, owner });
|
const weapon = MagicalWeapon.create({ damage, maxHealth: weaponHP, owner });
|
||||||
const weaponHPBefore = weapon.health;
|
const weaponHPBefore = weapon.health.value;
|
||||||
const targetHealthBefore = target.health.value;
|
const targetHealthBefore = target.health.value;
|
||||||
const result = thief.useWeapon(weapon, target);
|
const result = thief.useWeapon(weapon, target);
|
||||||
return (
|
return (
|
||||||
result.weapon.health === weaponHPBefore &&
|
result.weapon.health.value === weaponHPBefore &&
|
||||||
result.target.health.value === targetHealthBefore
|
result.target.health.value === targetHealthBefore
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -136,7 +137,9 @@ describe('Magical Objects', () => {
|
|||||||
const targetHealthBefore = firstUse.target.health.value;
|
const targetHealthBefore = firstUse.target.health.value;
|
||||||
// Try to use again on the destroyed weapon
|
// Try to use again on the destroyed weapon
|
||||||
const result = owner.useWeapon(destroyedWeapon, firstUse.target);
|
const result = owner.useWeapon(destroyedWeapon, firstUse.target);
|
||||||
return result.weapon.health === 0 && result.target.health.value === targetHealthBefore;
|
return (
|
||||||
|
result.weapon.health.value === 0 && result.target.health.value === targetHealthBefore
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -203,7 +206,7 @@ describe('Magical Objects', () => {
|
|||||||
});
|
});
|
||||||
const object = HealingObject.create({ maxHealth: objectHP, currentHealth: objectHP });
|
const object = HealingObject.create({ maxHealth: objectHP, currentHealth: objectHP });
|
||||||
const result = character.useHealingObject(object, healAmount);
|
const result = character.useHealingObject(object, healAmount);
|
||||||
return result.object.health === objectHP - healAmount;
|
return result.object.health.value === objectHP - healAmount;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -254,12 +257,12 @@ describe('Magical Objects', () => {
|
|||||||
const object = HealingObject.create({ maxHealth: objectHP, currentHealth: objectHP });
|
const object = HealingObject.create({ maxHealth: objectHP, currentHealth: objectHP });
|
||||||
// Kill the character using a separate killer
|
// Kill the character using a separate killer
|
||||||
const killer = Character.create({ name: 'boss', level: Level.create(1) });
|
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;
|
const objectHPBefore = object.health.value;
|
||||||
const characterHealthBefore = deadCharacter.health.value;
|
const characterHealthBefore = deadCharacter.health.value;
|
||||||
const result = deadCharacter.useHealingObject(object, 100);
|
const result = deadCharacter.useHealingObject(object, 100);
|
||||||
return (
|
return (
|
||||||
result.object.health === objectHPBefore &&
|
result.object.health.value === objectHPBefore &&
|
||||||
result.character.health.value === characterHealthBefore
|
result.character.health.value === characterHealthBefore
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
@@ -287,7 +290,8 @@ describe('Magical Objects', () => {
|
|||||||
// Try to use again on the destroyed object
|
// Try to use again on the destroyed object
|
||||||
const result = healedCharacter.useHealingObject(destroyedObject, 100);
|
const result = healedCharacter.useHealingObject(destroyedObject, 100);
|
||||||
return (
|
return (
|
||||||
result.object.health === 0 && result.character.health.value === characterHealthBefore
|
result.object.health.value === 0 &&
|
||||||
|
result.character.health.value === characterHealthBefore
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -305,7 +309,7 @@ describe('Magical Objects', () => {
|
|||||||
const target = Character.create({ name: 'goblin', level: Level.create(1) });
|
const target = Character.create({ name: 'goblin', level: Level.create(1) });
|
||||||
const weapon = MagicalWeapon.create({ damage, maxHealth: weaponHP, owner: attacker });
|
const weapon = MagicalWeapon.create({ damage, maxHealth: weaponHP, owner: attacker });
|
||||||
const result = attacker.useWeapon(weapon, target);
|
const result = attacker.useWeapon(weapon, target);
|
||||||
return result.weapon.health >= 0;
|
return result.weapon.health.value >= 0;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -320,7 +324,7 @@ describe('Magical Objects', () => {
|
|||||||
const character = Character.create({ name: 'hero', level: Level.create(1) });
|
const character = Character.create({ name: 'hero', level: Level.create(1) });
|
||||||
const object = HealingObject.create({ maxHealth: objectHP, currentHealth: objectHP });
|
const object = HealingObject.create({ maxHealth: objectHP, currentHealth: objectHP });
|
||||||
const result = character.useHealingObject(object, healAmount);
|
const result = character.useHealingObject(object, healAmount);
|
||||||
return result.object.health >= 0;
|
return result.object.health.value >= 0;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -336,7 +340,7 @@ describe('Magical Objects', () => {
|
|||||||
const target = Character.create({ name: 'goblin', level: Level.create(1) });
|
const target = Character.create({ name: 'goblin', level: Level.create(1) });
|
||||||
const weapon = MagicalWeapon.create({ damage, maxHealth: weaponHP, owner: attacker });
|
const weapon = MagicalWeapon.create({ damage, maxHealth: weaponHP, owner: attacker });
|
||||||
const result = attacker.useWeapon(weapon, target);
|
const result = attacker.useWeapon(weapon, target);
|
||||||
return result.weapon.health <= weaponHP;
|
return result.weapon.health.value <= weaponHP;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -351,7 +355,7 @@ describe('Magical Objects', () => {
|
|||||||
const character = Character.create({ name: 'hero', level: Level.create(1) });
|
const character = Character.create({ name: 'hero', level: Level.create(1) });
|
||||||
const object = HealingObject.create({ maxHealth: objectHP, currentHealth: objectHP });
|
const object = HealingObject.create({ maxHealth: objectHP, currentHealth: objectHP });
|
||||||
const result = character.useHealingObject(object, healAmount);
|
const result = character.useHealingObject(object, healAmount);
|
||||||
return result.object.health <= objectHP;
|
return result.object.health.value <= objectHP;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,14 +7,15 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Character } from '../characters/Character.ts';
|
import { Character } from '../characters/Character.ts';
|
||||||
|
import { Health } from '../value-objects/Health.ts';
|
||||||
import { Level } from '../value-objects/Level.ts';
|
import { Level } from '../value-objects/Level.ts';
|
||||||
import { MagicalObject } from './MagicalObject.ts';
|
import { MagicalObject } from './MagicalObject.ts';
|
||||||
import type { Healer } from './magical-object-types.ts';
|
import type { Healer } from './magical-object-types.ts';
|
||||||
|
|
||||||
export class HealingObject extends MagicalObject implements Healer {
|
export class HealingObject extends MagicalObject implements Healer {
|
||||||
private constructor(
|
private constructor(
|
||||||
health: number,
|
health: Health,
|
||||||
maxHealth: number,
|
maxHealth: Health,
|
||||||
status: { readonly kind: 'alive' } | { readonly kind: 'destroyed' },
|
status: { readonly kind: 'alive' } | { readonly kind: 'destroyed' },
|
||||||
) {
|
) {
|
||||||
super(health, maxHealth, status);
|
super(health, maxHealth, status);
|
||||||
@@ -32,7 +33,7 @@ export class HealingObject extends MagicalObject implements Healer {
|
|||||||
if (currentHealth > maxHealth) throw new Error('CurrentHealth cannot exceed maxHealth');
|
if (currentHealth > maxHealth) throw new Error('CurrentHealth cannot exceed maxHealth');
|
||||||
const status =
|
const status =
|
||||||
currentHealth === 0 ? { kind: 'destroyed' as const } : { kind: 'alive' as const };
|
currentHealth === 0 ? { kind: 'destroyed' as const } : { kind: 'alive' as const };
|
||||||
return new HealingObject(currentHealth, maxHealth, status);
|
return new HealingObject(Health.create(currentHealth), Health.create(maxHealth), status);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Use this object to heal a character. Returns updated object and character. */
|
/** Use this object to heal a character. Returns updated object and character. */
|
||||||
@@ -44,7 +45,7 @@ export class HealingObject extends MagicalObject implements Healer {
|
|||||||
// Negative amount is invalid
|
// Negative amount is invalid
|
||||||
if (amount < 0) throw new Error('Heal amount must be non-negative');
|
if (amount < 0) throw new Error('Heal amount must be non-negative');
|
||||||
// Calculate actual heal amount: min of requested, object remaining, character headroom
|
// Calculate actual heal amount: min of requested, object remaining, character headroom
|
||||||
const objectRemaining = this.health;
|
const objectRemaining = this.health.value;
|
||||||
const characterMax = Level.maxHealthForLevel(character.level.value);
|
const characterMax = Level.maxHealthForLevel(character.level.value);
|
||||||
const characterHeadroom = characterMax - character.health.value;
|
const characterHeadroom = characterMax - character.health.value;
|
||||||
const actualHeal = Math.min(amount, objectRemaining, characterHeadroom);
|
const actualHeal = Math.min(amount, objectRemaining, characterHeadroom);
|
||||||
@@ -53,7 +54,7 @@ export class HealingObject extends MagicalObject implements Healer {
|
|||||||
return { object: this, character };
|
return { object: this, character };
|
||||||
}
|
}
|
||||||
// Create updated object
|
// Create updated object
|
||||||
const newObjectHealth = this.health - actualHeal;
|
const newObjectHealth = this.health.value - actualHeal;
|
||||||
const newObjectStatus =
|
const newObjectStatus =
|
||||||
newObjectHealth === 0 ? { kind: 'destroyed' as const } : { kind: 'alive' as const };
|
newObjectHealth === 0 ? { kind: 'destroyed' as const } : { kind: 'alive' as const };
|
||||||
// Create updated character
|
// Create updated character
|
||||||
@@ -64,7 +65,7 @@ export class HealingObject extends MagicalObject implements Healer {
|
|||||||
health: newCharacterHealth,
|
health: newCharacterHealth,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
object: new HealingObject(newObjectHealth, this.maxHealth, newObjectStatus),
|
object: new HealingObject(Health.create(newObjectHealth), this.maxHealth, newObjectStatus),
|
||||||
character: newCharacter,
|
character: newCharacter,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,24 +7,26 @@
|
|||||||
* - Status derived from health (0 = destroyed, > 0 = alive)
|
* - Status derived from health (0 = destroyed, > 0 = alive)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { Health } from '../value-objects/Health.ts';
|
||||||
|
|
||||||
export type MagicalObjectStatus = { readonly kind: 'alive' } | { readonly kind: 'destroyed' };
|
export type MagicalObjectStatus = { readonly kind: 'alive' } | { readonly kind: 'destroyed' };
|
||||||
|
|
||||||
export class MagicalObject {
|
export class MagicalObject {
|
||||||
readonly #health: number;
|
readonly #health: Health;
|
||||||
readonly #maxHealth: number;
|
readonly #maxHealth: Health;
|
||||||
readonly #status: MagicalObjectStatus;
|
readonly #status: MagicalObjectStatus;
|
||||||
|
|
||||||
protected constructor(health: number, maxHealth: number, status: MagicalObjectStatus) {
|
protected constructor(health: Health, maxHealth: Health, status: MagicalObjectStatus) {
|
||||||
this.#health = health;
|
this.#health = health;
|
||||||
this.#maxHealth = maxHealth;
|
this.#maxHealth = maxHealth;
|
||||||
this.#status = status;
|
this.#status = status;
|
||||||
}
|
}
|
||||||
|
|
||||||
get health(): number {
|
get health(): Health {
|
||||||
return this.#health;
|
return this.#health;
|
||||||
}
|
}
|
||||||
|
|
||||||
get maxHealth(): number {
|
get maxHealth(): Health {
|
||||||
return this.#maxHealth;
|
return this.#maxHealth;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +37,7 @@ export class MagicalObject {
|
|||||||
/** Create a destroyed object (health = 0). */
|
/** Create a destroyed object (health = 0). */
|
||||||
static createDestroyed(maxHealth: number): MagicalObject {
|
static createDestroyed(maxHealth: number): MagicalObject {
|
||||||
if (maxHealth < 0) throw new Error('MaxHealth cannot be negative');
|
if (maxHealth < 0) throw new Error('MaxHealth cannot be negative');
|
||||||
return new MagicalObject(0, maxHealth, { kind: 'destroyed' });
|
return new MagicalObject(Health.create(0), Health.create(maxHealth), { kind: 'destroyed' });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check if this object is alive. */
|
/** Check if this object is alive. */
|
||||||
|
|||||||
@@ -6,18 +6,20 @@
|
|||||||
* - Damage is non-negative
|
* - Damage is non-negative
|
||||||
*/
|
*/
|
||||||
import { Character } from '../characters/Character.ts';
|
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 { MagicalObject } from './MagicalObject.ts';
|
||||||
import type { DamageDealer } from './magical-object-types.ts';
|
import type { DamageDealer } from './magical-object-types.ts';
|
||||||
|
|
||||||
export class MagicalWeapon extends MagicalObject implements DamageDealer {
|
export class MagicalWeapon extends MagicalObject implements DamageDealer {
|
||||||
readonly #damage: number;
|
readonly #damage: Damage;
|
||||||
readonly #owner: Character;
|
readonly #owner: Character;
|
||||||
|
|
||||||
private constructor(
|
private constructor(
|
||||||
health: number,
|
health: Health,
|
||||||
maxHealth: number,
|
maxHealth: Health,
|
||||||
status: { readonly kind: 'alive' } | { readonly kind: 'destroyed' },
|
status: { readonly kind: 'alive' } | { readonly kind: 'destroyed' },
|
||||||
damage: number,
|
damage: Damage,
|
||||||
owner: Character,
|
owner: Character,
|
||||||
) {
|
) {
|
||||||
super(health, maxHealth, status);
|
super(health, maxHealth, status);
|
||||||
@@ -35,11 +37,16 @@ export class MagicalWeapon extends MagicalObject implements DamageDealer {
|
|||||||
owner: Character;
|
owner: Character;
|
||||||
}): MagicalWeapon {
|
}): MagicalWeapon {
|
||||||
if (maxHealth < 0) throw new Error('MaxHealth cannot be negative');
|
if (maxHealth < 0) throw new Error('MaxHealth cannot be negative');
|
||||||
if (damage < 0) throw new Error('Damage cannot be negative');
|
return new MagicalWeapon(
|
||||||
return new MagicalWeapon(maxHealth, maxHealth, { kind: 'alive' }, damage, owner);
|
Health.create(maxHealth),
|
||||||
|
Health.create(maxHealth),
|
||||||
|
{ kind: 'alive' },
|
||||||
|
Damage.create(damage),
|
||||||
|
owner,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
get damage(): number {
|
get damage(): Damage {
|
||||||
return this.#damage;
|
return this.#damage;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +61,7 @@ export class MagicalWeapon extends MagicalObject implements DamageDealer {
|
|||||||
return { weapon: this, target };
|
return { weapon: this, target };
|
||||||
}
|
}
|
||||||
// Deal fixed damage
|
// 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 newTargetStatus = newTargetHealth === 0 ? { kind: 'dead' as const } : target.status;
|
||||||
const newTarget = Character.createWithHealthAndStatus({
|
const newTarget = Character.createWithHealthAndStatus({
|
||||||
name: target.name,
|
name: target.name,
|
||||||
@@ -63,12 +70,12 @@ export class MagicalWeapon extends MagicalObject implements DamageDealer {
|
|||||||
status: newTargetStatus,
|
status: newTargetStatus,
|
||||||
});
|
});
|
||||||
// Reduce weapon health by 1
|
// Reduce weapon health by 1
|
||||||
const newWeaponHealth = this.health - 1;
|
const newWeaponHealth = this.health.value - 1;
|
||||||
const newWeaponStatus =
|
const newWeaponStatus =
|
||||||
newWeaponHealth === 0 ? { kind: 'destroyed' as const } : { kind: 'alive' as const };
|
newWeaponHealth === 0 ? { kind: 'destroyed' as const } : { kind: 'alive' as const };
|
||||||
return {
|
return {
|
||||||
weapon: new MagicalWeapon(
|
weapon: new MagicalWeapon(
|
||||||
newWeaponHealth,
|
Health.create(newWeaponHealth),
|
||||||
this.maxHealth,
|
this.maxHealth,
|
||||||
newWeaponStatus,
|
newWeaponStatus,
|
||||||
this.#damage,
|
this.#damage,
|
||||||
|
|||||||
@@ -6,19 +6,20 @@
|
|||||||
* so Character can depend on abstractions rather than concrete classes.
|
* so Character can depend on abstractions rather than concrete classes.
|
||||||
*/
|
*/
|
||||||
import type { Character } from '../characters/Character.ts';
|
import type { Character } from '../characters/Character.ts';
|
||||||
|
import type { Health } from '../value-objects/Health.ts';
|
||||||
import type { MagicalObjectStatus } from './MagicalObject.ts';
|
import type { MagicalObjectStatus } from './MagicalObject.ts';
|
||||||
|
|
||||||
/** A magical object that deals damage — from Character's point of view */
|
/** A magical object that deals damage — from Character's point of view */
|
||||||
export interface DamageDealer {
|
export interface DamageDealer {
|
||||||
readonly owner: Character;
|
readonly owner: Character;
|
||||||
readonly health: number;
|
readonly health: Health;
|
||||||
readonly status: MagicalObjectStatus;
|
readonly status: MagicalObjectStatus;
|
||||||
use(target: Character): { weapon: DamageDealer; target: Character };
|
use(target: Character): { weapon: DamageDealer; target: Character };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A magical object that heals — from Character's point of view */
|
/** A magical object that heals — from Character's point of view */
|
||||||
export interface Healer {
|
export interface Healer {
|
||||||
readonly health: number;
|
readonly health: Health;
|
||||||
readonly status: MagicalObjectStatus;
|
readonly status: MagicalObjectStatus;
|
||||||
heal(character: Character, amount: number): { object: Healer; character: Character };
|
heal(character: Character, amount: number): { object: Healer; character: Character };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
diff(target: Level): number {
|
||||||
return this.value - target.value;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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
@@ -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
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
|
- Healing Magical Objects cannot deal Damage
|
||||||
|
|
||||||
3. Characters can deal Damage by using a Magical Weapon.
|
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
|
- 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
|
- 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
|
- Every time the weapon is used, the Health is reduced by 1
|
||||||
|
|||||||
Reference in New Issue
Block a user