@ai-rpg-engine/starter-template 2.4.0 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +41 -9
- package/package.json +11 -5
- package/src/content.ts +12 -1
- package/src/index.ts +1 -1
- package/src/setup.ts +45 -10
- package/src/starter.test.ts +212 -5
- package/tsconfig.json +12 -13
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MCP Tool Shop
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,15 +1,47 @@
|
|
|
1
|
-
#
|
|
1
|
+
# My Game
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
An [ai-rpg-engine](https://mcp-tool-shop-org.github.io/ai-rpg-engine/) content
|
|
4
|
+
pack, scaffolded from the starter template. The project is **standalone**: it
|
|
5
|
+
carries its own `tsconfig.json` and declares every dependency it needs
|
|
6
|
+
(including `typescript` and `vitest` as devDependencies), so it builds and
|
|
7
|
+
tests anywhere — no engine monorepo required.
|
|
8
|
+
|
|
9
|
+
## Getting started
|
|
10
|
+
|
|
11
|
+
If you are reading this inside `templates/starter`, scaffold your own copy
|
|
12
|
+
first (it names everything for you):
|
|
4
13
|
|
|
5
14
|
```bash
|
|
6
|
-
|
|
15
|
+
npx --package=@ai-rpg-engine/cli ai-rpg-engine create-starter my-game --out=./my-game
|
|
7
16
|
```
|
|
8
17
|
|
|
9
|
-
Then
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
-
|
|
13
|
-
|
|
18
|
+
Then, from the project directory:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install # pulls @ai-rpg-engine/* plus typescript + vitest
|
|
22
|
+
npx tsc --noEmit # typecheck
|
|
23
|
+
npx vitest run # run the pack's tests
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Make it yours
|
|
27
|
+
|
|
28
|
+
| File | What to edit |
|
|
29
|
+
|------|--------------|
|
|
30
|
+
| `package.json` | name and description |
|
|
31
|
+
| `src/ruleset.ts` | your stats, resources, verbs |
|
|
32
|
+
| `src/content.ts` | entities and zones — keep each enemy's `ai.profileId` paired with a profile in setup |
|
|
33
|
+
| `src/setup.ts` | wire your modules alongside `buildCombatStack`; the intent profiles list lives here |
|
|
34
|
+
| `src/starter.test.ts` | grows with your content — register dialogues/abilities in the integrity lists |
|
|
35
|
+
|
|
36
|
+
Two wiring rules worth knowing from day one:
|
|
37
|
+
|
|
38
|
+
- **Enemies act only if their `ai.profileId` resolves.** Every profile id
|
|
39
|
+
declared in `src/content.ts` must appear in the `cognition.profiles` list in
|
|
40
|
+
`src/setup.ts` (built-ins: `aggressive`, `cautious`). An empty profiles list
|
|
41
|
+
means enemies never select an intent.
|
|
42
|
+
- **`buildCombatStack` owns the combat stack** (cognition, tactics, resources,
|
|
43
|
+
intent, recovery, narration). Your custom modules go in the marked
|
|
44
|
+
starter-owned section of `src/setup.ts`.
|
|
14
45
|
|
|
15
|
-
See [Chapter 58 — Create Your Own Starter](
|
|
46
|
+
See [Chapter 58 — Create Your Own Starter](https://mcp-tool-shop-org.github.io/ai-rpg-engine/handbook/58-create-your-own-starter/)
|
|
47
|
+
in the handbook for the full walkthrough.
|
package/package.json
CHANGED
|
@@ -1,21 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ai-rpg-engine/starter-template",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "Starter template for ai-rpg-engine
|
|
3
|
+
"version": "2.6.0",
|
|
4
|
+
"description": "Starter template for ai-rpg-engine \u00e2\u20ac\u201d scaffold a standalone game project from it",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
7
7
|
"src",
|
|
8
8
|
"README.md",
|
|
9
|
+
"LICENSE",
|
|
9
10
|
"tsconfig.json"
|
|
10
11
|
],
|
|
11
12
|
"scripts": {
|
|
12
13
|
"build": "tsc",
|
|
14
|
+
"typecheck": "tsc --noEmit",
|
|
13
15
|
"test": "vitest run"
|
|
14
16
|
},
|
|
15
17
|
"dependencies": {
|
|
16
|
-
"@ai-rpg-engine/core": "
|
|
17
|
-
"@ai-rpg-engine/content-schema": "
|
|
18
|
-
"@ai-rpg-engine/modules": "
|
|
18
|
+
"@ai-rpg-engine/core": "^2.6.0",
|
|
19
|
+
"@ai-rpg-engine/content-schema": "^2.6.0",
|
|
20
|
+
"@ai-rpg-engine/modules": "^2.6.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"typescript": "^5.7.0",
|
|
24
|
+
"vitest": "^3.0.0"
|
|
19
25
|
},
|
|
20
26
|
"license": "MIT",
|
|
21
27
|
"author": "mcp-tool-shop",
|
package/src/content.ts
CHANGED
|
@@ -15,7 +15,13 @@ export const manifest: GameManifest = {
|
|
|
15
15
|
};
|
|
16
16
|
|
|
17
17
|
// ═══════════════════════════════════════════════════════════════════
|
|
18
|
-
// PACK METADATA
|
|
18
|
+
// PACK METADATA
|
|
19
|
+
// This is a minimal subset to keep the template dependency-light. To list
|
|
20
|
+
// your pack in the CLI pack selector, export the full PackMetadata shape
|
|
21
|
+
// from @ai-rpg-engine/pack-registry (adds: tagline, genres, difficulty,
|
|
22
|
+
// tones, tags, engineVersion, narratorTone) plus a BuildCatalog from
|
|
23
|
+
// @ai-rpg-engine/character-creation — see any packages/starter-* content.ts
|
|
24
|
+
// for a complete example.
|
|
19
25
|
// ═══════════════════════════════════════════════════════════════════
|
|
20
26
|
|
|
21
27
|
export const packMeta = {
|
|
@@ -56,6 +62,11 @@ export const enemy: EntityState = {
|
|
|
56
62
|
resources: { hp: 12 },
|
|
57
63
|
statuses: [],
|
|
58
64
|
zoneId: 'danger-zone',
|
|
65
|
+
// ai.profileId picks this enemy's combat brain. It must match a profile
|
|
66
|
+
// provided in setup.ts (`cognition: { profiles: [...] }`) — without that
|
|
67
|
+
// pairing the enemy never selects an intent and just stands there.
|
|
68
|
+
// Built-ins: 'aggressive' (attack on sight) and 'cautious' (observe first).
|
|
69
|
+
ai: { profileId: 'aggressive', goals: ['guard-zone'], fears: [], alertLevel: 0, knowledge: {} },
|
|
59
70
|
};
|
|
60
71
|
|
|
61
72
|
// ═══════════════════════════════════════════════════════════════════
|
package/src/index.ts
CHANGED
package/src/setup.ts
CHANGED
|
@@ -14,7 +14,9 @@ import {
|
|
|
14
14
|
traversalCore,
|
|
15
15
|
statusCore,
|
|
16
16
|
buildCombatStack,
|
|
17
|
+
aggressiveProfile,
|
|
17
18
|
} from '@ai-rpg-engine/modules';
|
|
19
|
+
import type { IntentProfile } from '@ai-rpg-engine/modules';
|
|
18
20
|
import { manifest, player, enemy, zones } from './content.js';
|
|
19
21
|
import { myRuleset } from './ruleset.js';
|
|
20
22
|
|
|
@@ -24,13 +26,17 @@ import { myRuleset } from './ruleset.js';
|
|
|
24
26
|
// your starter feel different from other starters.
|
|
25
27
|
// ═══════════════════════════════════════════════════════════════════
|
|
26
28
|
|
|
27
|
-
// Example: a custom module that
|
|
29
|
+
// Example: a custom module that raises your "tension" resource whenever
|
|
30
|
+
// damage lands in combat. It listens on 'combat.damage.applied' — a real
|
|
31
|
+
// event emitted by combat-core every time an attack connects (other engine
|
|
32
|
+
// events you can hook: 'combat.contact.hit', 'combat.contact.miss',
|
|
33
|
+
// 'combat.entity.defeated', 'status.applied', 'world.zone.entered').
|
|
28
34
|
function createTensionPressure(): EngineModule {
|
|
29
35
|
return {
|
|
30
36
|
id: 'tension-pressure',
|
|
31
37
|
version: '1.0.0',
|
|
32
38
|
register(ctx) {
|
|
33
|
-
ctx.events.on('combat.
|
|
39
|
+
ctx.events.on('combat.damage.applied', (event: ResolvedEvent, world: WorldState) => {
|
|
34
40
|
const p = world.entities['player'];
|
|
35
41
|
if (p && p.resources.tension !== undefined) {
|
|
36
42
|
p.resources.tension = Math.min(100, (p.resources.tension ?? 0) + 5);
|
|
@@ -40,6 +46,19 @@ function createTensionPressure(): EngineModule {
|
|
|
40
46
|
};
|
|
41
47
|
}
|
|
42
48
|
|
|
49
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
50
|
+
// INTENT PROFILES — required for enemies to act
|
|
51
|
+
// Every entity in content.ts that declares an `ai.profileId` must find a
|
|
52
|
+
// matching profile in this list: cognition builds its profile map from
|
|
53
|
+
// `cognition.profiles`, and with an empty map no enemy ever selects an
|
|
54
|
+
// intent — they stand still forever. Built-ins from @ai-rpg-engine/modules:
|
|
55
|
+
// aggressiveProfile ('aggressive': attack visible hostiles, flee at low
|
|
56
|
+
// morale) and cautiousProfile ('cautious': observe first, strike when
|
|
57
|
+
// confident). Add your own IntentProfile objects here for custom brains.
|
|
58
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
59
|
+
|
|
60
|
+
export const myIntentProfiles: IntentProfile[] = [aggressiveProfile];
|
|
61
|
+
|
|
43
62
|
// ═══════════════════════════════════════════════════════════════════
|
|
44
63
|
// GAME FACTORY
|
|
45
64
|
// ═══════════════════════════════════════════════════════════════════
|
|
@@ -49,18 +68,34 @@ export function createGame(seed?: number): Engine {
|
|
|
49
68
|
const combat = buildCombatStack({
|
|
50
69
|
statMapping: { attack: 'power', precision: 'speed', resolve: 'grit' },
|
|
51
70
|
playerId: 'player',
|
|
52
|
-
// resourceProfile — uncomment and customize for your game's resource
|
|
71
|
+
// resourceProfile — uncomment and customize for your game's resource
|
|
72
|
+
// pressure. A CombatResourceProfile needs packId + the four arrays
|
|
73
|
+
// (gains / spends / drains / aiModifiers — empty arrays are fine):
|
|
53
74
|
// resourceProfile: {
|
|
75
|
+
// packId: 'my-game',
|
|
76
|
+
// gains: [
|
|
77
|
+
// // +2 tension every time an attack you make lands
|
|
78
|
+
// { trigger: 'attack-hit', resourceId: 'tension', amount: 2 },
|
|
79
|
+
// ],
|
|
54
80
|
// spends: [
|
|
55
|
-
//
|
|
81
|
+
// // spend 2 stamina on each attack for +1 damage
|
|
82
|
+
// { action: 'attack', resourceId: 'stamina', amount: 2, effects: { damageBonus: 1 } },
|
|
56
83
|
// ],
|
|
84
|
+
// drains: [],
|
|
85
|
+
// aiModifiers: [],
|
|
57
86
|
// },
|
|
58
|
-
// biasTags —
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
87
|
+
// biasTags — built-in pack bias tags that shape combat AI intent.
|
|
88
|
+
// Must come from PACK_BIAS_TAGS (exported by @ai-rpg-engine/modules,
|
|
89
|
+
// e.g. 'undead', 'beast', 'feral'); unknown tags warn and are dropped:
|
|
90
|
+
// biasTags: ['undead', 'beast'],
|
|
91
|
+
// engagement — backline/protector behavior for ranged parties
|
|
92
|
+
// (fields: backlineTags, protectorTags, chokepointTag, ambushTag):
|
|
93
|
+
// engagement: { backlineTags: ['ranged', 'caster'], protectorTags: ['bodyguard'] },
|
|
62
94
|
// recovery — safe zone recovery:
|
|
63
95
|
recovery: { safeZoneTags: ['safe'] },
|
|
96
|
+
// cognition — wires the intent profiles above into the enemy AI.
|
|
97
|
+
// Every ai.profileId declared in content.ts must resolve here.
|
|
98
|
+
cognition: { profiles: myIntentProfiles },
|
|
64
99
|
});
|
|
65
100
|
|
|
66
101
|
const engine = new Engine({
|
|
@@ -84,8 +119,8 @@ export function createGame(seed?: number): Engine {
|
|
|
84
119
|
}
|
|
85
120
|
|
|
86
121
|
// Register entities
|
|
87
|
-
engine.store.addEntity(
|
|
88
|
-
engine.store.addEntity(
|
|
122
|
+
engine.store.addEntity(structuredClone(player));
|
|
123
|
+
engine.store.addEntity(structuredClone(enemy));
|
|
89
124
|
|
|
90
125
|
// Set player context
|
|
91
126
|
engine.store.state.playerId = 'player';
|
package/src/starter.test.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
1
3
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { validateRulesetDefinition } from '@ai-rpg-engine/content-schema';
|
|
4
|
+
import { validateRulesetDefinition, validateGameContent, validateAbilityPack } from '@ai-rpg-engine/content-schema';
|
|
5
|
+
import type { ContentPack, AbilityDefinition, DialogueDefinition } from '@ai-rpg-engine/content-schema';
|
|
6
|
+
import { selectIntent } from '@ai-rpg-engine/modules';
|
|
7
|
+
import type { CognitionState } from '@ai-rpg-engine/modules';
|
|
3
8
|
import { myRuleset } from './ruleset.js';
|
|
4
|
-
import { createGame } from './setup.js';
|
|
9
|
+
import { createGame, myIntentProfiles } from './setup.js';
|
|
5
10
|
|
|
6
11
|
describe('starter template', () => {
|
|
7
12
|
it('ruleset validates against schema', () => {
|
|
@@ -27,9 +32,211 @@ describe('starter template', () => {
|
|
|
27
32
|
expect(engine).toBeDefined();
|
|
28
33
|
});
|
|
29
34
|
|
|
30
|
-
it('tension
|
|
35
|
+
it('tension rises when combat damage lands (tension-pressure fires)', () => {
|
|
31
36
|
const engine = createGame(1);
|
|
32
|
-
|
|
33
|
-
|
|
37
|
+
expect(engine.world.entities['player']?.resources.tension).toBe(0);
|
|
38
|
+
|
|
39
|
+
// Walk into the danger zone and fight. combat-core emits
|
|
40
|
+
// 'combat.damage.applied' whenever a hit lands — that is the event
|
|
41
|
+
// the tension-pressure module listens on.
|
|
42
|
+
engine.submitAction('move', { targetIds: ['danger-zone'] });
|
|
43
|
+
for (let i = 0; i < 8; i++) {
|
|
44
|
+
engine.submitAction('attack', { targetIds: ['grunt'] });
|
|
45
|
+
if ((engine.world.entities['player']?.resources.tension ?? 0) > 0) break;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Meta-test property: deleting the tension-pressure listener in
|
|
49
|
+
// setup.ts turns this RED — nothing else writes the tension resource.
|
|
50
|
+
expect(engine.world.entities['player']?.resources.tension).toBeGreaterThan(0);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
55
|
+
// CROSS-REFERENCE INTEGRITY
|
|
56
|
+
// The schema test above checks each piece in isolation. These tests check
|
|
57
|
+
// the connections BETWEEN pieces: dangling zone neighbors, duplicate ids,
|
|
58
|
+
// dialogue speakers that name an entity's display name instead of its id,
|
|
59
|
+
// abilities that reference undeclared stats/resources. They validate the
|
|
60
|
+
// REAL composed game (createGame()'s world state), so they automatically
|
|
61
|
+
// cover content you add — as long as you keep the two lists below in sync.
|
|
62
|
+
//
|
|
63
|
+
// Why this matters: dialogue-core finds a dialogue by matching its
|
|
64
|
+
// speakers[] against the target ENTITY ID. Writing the display name
|
|
65
|
+
// ('Grunt') instead of the id ('grunt') doesn't error — "talk to NPC"
|
|
66
|
+
// just silently reports "has nothing to say". validateGameContent catches
|
|
67
|
+
// exactly that class of bug at test time.
|
|
68
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
69
|
+
describe('starter template — cross-reference integrity', () => {
|
|
70
|
+
// Register your DialogueDefinitions here as you write them. Remember:
|
|
71
|
+
// speakers: ['grunt'] (entity id), never speakers: ['Grunt'] (name).
|
|
72
|
+
const myDialogues: DialogueDefinition[] = [];
|
|
73
|
+
// Register your AbilityDefinitions here as you write them.
|
|
74
|
+
const myAbilities: AbilityDefinition[] = [];
|
|
75
|
+
|
|
76
|
+
function builtContentPack(): ContentPack {
|
|
77
|
+
const engine = createGame(1);
|
|
78
|
+
return {
|
|
79
|
+
zones: Object.values(engine.store.state.zones) as unknown as ContentPack['zones'],
|
|
80
|
+
entities: Object.values(engine.store.state.entities) as unknown as ContentPack['entities'],
|
|
81
|
+
dialogues: myDialogues,
|
|
82
|
+
abilities: myAbilities,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
it('zones, entities, dialogues, and abilities have no dangling references or duplicate ids', () => {
|
|
87
|
+
const result = validateGameContent(builtContentPack());
|
|
88
|
+
expect(result.errors).toEqual([]);
|
|
89
|
+
expect(result.ok).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('has no one-way zone passages (neighbor symmetry advisory)', () => {
|
|
93
|
+
// A zone listing a neighbor that doesn't list it back is legal but
|
|
94
|
+
// almost always a mistake — the player can walk in and never back out.
|
|
95
|
+
const result = validateGameContent(builtContentPack());
|
|
96
|
+
expect(result.advisories).toEqual([]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('abilities reference only stats/resources declared in the ruleset', () => {
|
|
100
|
+
const result = validateAbilityPack(myAbilities, myRuleset);
|
|
101
|
+
expect(result.errors).toEqual([]);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
106
|
+
// INTENT PROFILE WIRING
|
|
107
|
+
// Enemies act only if their ai.profileId resolves to a profile provided
|
|
108
|
+
// in setup.ts's `cognition.profiles`. An empty profiles list is the #1
|
|
109
|
+
// reason a pack's enemies stand still forever. These tests keep every
|
|
110
|
+
// declared profileId resolvable as you add content.
|
|
111
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
112
|
+
describe('starter template — intent profile wiring', () => {
|
|
113
|
+
it('provides a non-empty intent profile list to cognition', () => {
|
|
114
|
+
expect(myIntentProfiles.length).toBeGreaterThan(0);
|
|
115
|
+
for (const profile of myIntentProfiles) {
|
|
116
|
+
expect(typeof profile.id).toBe('string');
|
|
117
|
+
expect(typeof profile.evaluate).toBe('function');
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('every hostile entity declares a profileId that resolves to a provided profile', () => {
|
|
122
|
+
const engine = createGame(1);
|
|
123
|
+
const hostiles = Object.values(engine.world.entities).filter(
|
|
124
|
+
(e) => e.type === 'enemy' || e.tags.includes('enemy'),
|
|
125
|
+
);
|
|
126
|
+
expect(hostiles.length).toBeGreaterThan(0);
|
|
127
|
+
|
|
128
|
+
const provided = new Set(myIntentProfiles.map((p) => p.id));
|
|
129
|
+
for (const hostile of hostiles) {
|
|
130
|
+
expect(hostile.ai?.profileId, `${hostile.id} must declare ai.profileId`).toBeTruthy();
|
|
131
|
+
expect(
|
|
132
|
+
provided.has(hostile.ai!.profileId),
|
|
133
|
+
`${hostile.id} declares "${hostile.ai!.profileId}" — not in cognition.profiles`,
|
|
134
|
+
).toBe(true);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('resolved profiles produce intents — hostiles can act on an intruder', () => {
|
|
139
|
+
const engine = createGame(1);
|
|
140
|
+
const world = engine.world;
|
|
141
|
+
const player = world.entities[world.playerId || 'player'];
|
|
142
|
+
expect(player).toBeDefined();
|
|
143
|
+
|
|
144
|
+
for (const hostile of Object.values(world.entities)) {
|
|
145
|
+
if (!(hostile.type === 'enemy' || hostile.tags.includes('enemy'))) continue;
|
|
146
|
+
const profile = myIntentProfiles.find((p) => p.id === hostile.ai?.profileId);
|
|
147
|
+
expect(profile, `${hostile.id} must resolve an intent profile`).toBeDefined();
|
|
148
|
+
|
|
149
|
+
// Stand the player in the hostile's zone as a believed-hostile intruder.
|
|
150
|
+
player!.zoneId = hostile.zoneId;
|
|
151
|
+
const cognition: CognitionState = {
|
|
152
|
+
beliefs: [{
|
|
153
|
+
subject: player!.id,
|
|
154
|
+
key: 'hostile',
|
|
155
|
+
value: true,
|
|
156
|
+
confidence: 1,
|
|
157
|
+
source: 'observed',
|
|
158
|
+
tick: world.meta.tick,
|
|
159
|
+
}],
|
|
160
|
+
memories: [],
|
|
161
|
+
currentIntent: null,
|
|
162
|
+
morale: 80,
|
|
163
|
+
suspicion: 60,
|
|
164
|
+
};
|
|
165
|
+
const intent = selectIntent(hostile, cognition, world, profile!);
|
|
166
|
+
expect(intent, `${hostile.id} (${profile!.id}) selected no intent`).not.toBeNull();
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
172
|
+
// STANDALONE SCAFFOLD
|
|
173
|
+
// The scaffold must work OUTSIDE the engine monorepo: its tsconfig may
|
|
174
|
+
// not reach for ../../tsconfig.json or ../../packages/* (that is TS5083
|
|
175
|
+
// for anyone who scaffolds a project of their own), and its package.json
|
|
176
|
+
// must carry real dependency versions plus the typescript/vitest
|
|
177
|
+
// devDependencies the printed "next steps" rely on.
|
|
178
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
179
|
+
describe('starter template — standalone scaffold', () => {
|
|
180
|
+
const read = (rel: string): string =>
|
|
181
|
+
readFileSync(fileURLToPath(new URL(rel, import.meta.url)), 'utf-8');
|
|
182
|
+
|
|
183
|
+
it('tsconfig is self-contained (no extends, no references, no ../.. paths)', () => {
|
|
184
|
+
const raw = read('../tsconfig.json');
|
|
185
|
+
expect(raw).not.toContain('../..');
|
|
186
|
+
const tsconfig = JSON.parse(raw) as Record<string, unknown>;
|
|
187
|
+
expect(tsconfig.extends).toBeUndefined();
|
|
188
|
+
expect(tsconfig.references).toBeUndefined();
|
|
189
|
+
expect(tsconfig.compilerOptions).toBeDefined();
|
|
190
|
+
// The inlined options every scaffold needs to compile on its own:
|
|
191
|
+
const opts = tsconfig.compilerOptions as Record<string, unknown>;
|
|
192
|
+
expect(opts.module).toBeDefined();
|
|
193
|
+
expect(opts.moduleResolution).toBeDefined();
|
|
194
|
+
expect(opts.strict).toBe(true);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('package.json declares real dependency versions and its own toolchain', () => {
|
|
198
|
+
const pkg = JSON.parse(read('../package.json')) as {
|
|
199
|
+
dependencies?: Record<string, string>;
|
|
200
|
+
devDependencies?: Record<string, string>;
|
|
201
|
+
};
|
|
202
|
+
const deps = pkg.dependencies ?? {};
|
|
203
|
+
expect(Object.keys(deps).length).toBeGreaterThan(0);
|
|
204
|
+
for (const [name, range] of Object.entries(deps)) {
|
|
205
|
+
expect(range, `${name} must pin a real semver range, not "*"`).not.toBe('*');
|
|
206
|
+
}
|
|
207
|
+
const devDeps = pkg.devDependencies ?? {};
|
|
208
|
+
expect(devDeps.typescript, 'typescript devDependency required for `npx tsc`').toBeTruthy();
|
|
209
|
+
expect(devDeps.vitest, 'vitest devDependency required for `npx vitest`').toBeTruthy();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('README does not send users into the monorepo', () => {
|
|
213
|
+
const readme = read('../README.md');
|
|
214
|
+
expect(readme).not.toContain('../../');
|
|
215
|
+
expect(readme.toLowerCase()).not.toContain('copy this directory to `packages/');
|
|
216
|
+
expect(readme).not.toMatch(/cp -r templates\/starter/);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
221
|
+
// CROSS-INSTANCE STATE ISOLATION
|
|
222
|
+
// setup.ts inserts entities from module-level constants. A shallow spread
|
|
223
|
+
// (`addEntity({ ...enemy })`) shares the nested resources/stats/statuses
|
|
224
|
+
// objects across every engine built in one process, so combat damage (or
|
|
225
|
+
// the NPC turn driver killing the grunt) in engine A would permanently
|
|
226
|
+
// mutate the constant and a LATER createGame() would boot with a dead
|
|
227
|
+
// grunt. structuredClone at insertion is the fix. Same class as F-71ec5dcd.
|
|
228
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
229
|
+
describe('starter template — cross-instance state isolation', () => {
|
|
230
|
+
it('killing the enemy in engine A does not carry into a fresh engine B', () => {
|
|
231
|
+
const a = createGame(1);
|
|
232
|
+
const fullHp = a.world.entities['grunt'].resources.hp;
|
|
233
|
+
expect(fullHp).toBeGreaterThan(0);
|
|
234
|
+
|
|
235
|
+
a.world.entities['grunt'].resources.hp = 0;
|
|
236
|
+
|
|
237
|
+
const b = createGame(1);
|
|
238
|
+
expect(b.world.entities['grunt'].resources.hp).toBe(fullHp);
|
|
239
|
+
expect(b.world.entities['grunt'].resources)
|
|
240
|
+
.not.toBe(a.world.entities['grunt'].resources);
|
|
34
241
|
});
|
|
35
242
|
});
|
package/tsconfig.json
CHANGED
|
@@ -1,24 +1,23 @@
|
|
|
1
1
|
{
|
|
2
|
-
"extends": "../../tsconfig.json",
|
|
3
2
|
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "Node16",
|
|
5
|
+
"moduleResolution": "Node16",
|
|
6
|
+
"declaration": true,
|
|
7
|
+
"sourceMap": true,
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"isolatedModules": true,
|
|
4
14
|
"outDir": "dist",
|
|
5
15
|
"rootDir": "src"
|
|
6
16
|
},
|
|
7
17
|
"include": [
|
|
8
18
|
"src"
|
|
9
19
|
],
|
|
10
|
-
"references": [
|
|
11
|
-
{
|
|
12
|
-
"path": "../../packages/core"
|
|
13
|
-
},
|
|
14
|
-
{
|
|
15
|
-
"path": "../../packages/content-schema"
|
|
16
|
-
},
|
|
17
|
-
{
|
|
18
|
-
"path": "../../packages/modules"
|
|
19
|
-
}
|
|
20
|
-
],
|
|
21
20
|
"exclude": [
|
|
22
21
|
"**/*.test.ts"
|
|
23
22
|
]
|
|
24
|
-
}
|
|
23
|
+
}
|