@alexkroman1/aai-cli 5.1.1 → 5.3.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/dist/_agent.d.ts +39 -0
- package/dist/_api-client.d.ts +37 -0
- package/dist/_bundler.d.ts +46 -0
- package/dist/_config.d.ts +57 -0
- package/dist/_default-html.d.ts +12 -0
- package/dist/_deploy.d.ts +28 -0
- package/dist/_dev-server.d.ts +74 -0
- package/dist/{_init-B-LEVTHx.mjs → _init-DU-sXH6S.mjs} +44 -65
- package/dist/_init.d.ts +7 -0
- package/dist/_mock-api.d.ts +22 -0
- package/dist/_mock-registry.d.ts +17 -0
- package/dist/_output.d.ts +50 -0
- package/dist/_server-common.d.ts +17 -0
- package/dist/_slug-api.d.ts +15 -0
- package/dist/_templates.d.ts +21 -0
- package/dist/_typecheck-gate.d.ts +7 -0
- package/dist/_ui.d.ts +22 -0
- package/dist/_utils.d.ts +48 -0
- package/dist/_vite-env.d.ts +8 -0
- package/dist/build.d.ts +16 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.mjs +1 -1
- package/dist/client-bundler.d.ts +21 -0
- package/dist/delete.d.ts +18 -0
- package/dist/deploy.d.ts +15 -0
- package/dist/dev.d.ts +13 -0
- package/dist/{init-tc3Rbgrf.mjs → init-BjeK8crW.mjs} +1 -1
- package/dist/init.d.ts +24 -0
- package/dist/scaffold/.env.example +11 -0
- package/dist/scaffold/CLAUDE.md +938 -0
- package/dist/scaffold/global.d.ts +1 -0
- package/dist/scaffold/package.json +32 -0
- package/dist/scaffold/pnpm-workspace.yaml +13 -0
- package/dist/scaffold/tsconfig.json +19 -0
- package/dist/scaffold/vite.config.ts +14 -0
- package/dist/scaffold/vitest.config.ts +24 -0
- package/dist/secret.d.ts +25 -0
- package/dist/storage.d.ts +22 -0
- package/dist/templates/code-interpreter/agent.ts +11 -0
- package/dist/templates/code-interpreter/system-prompt.md +18 -0
- package/dist/templates/dispatch-center/agent.test.ts +153 -0
- package/dist/templates/dispatch-center/agent.ts +50 -0
- package/dist/templates/dispatch-center/client.tsx +378 -0
- package/dist/templates/dispatch-center/shared.ts +631 -0
- package/dist/templates/dispatch-center/system-prompt.md +39 -0
- package/dist/templates/dispatch-center/tools/incident_add_note.ts +27 -0
- package/dist/templates/dispatch-center/tools/incident_create.ts +79 -0
- package/dist/templates/dispatch-center/tools/incident_escalate.ts +88 -0
- package/dist/templates/dispatch-center/tools/incident_get.ts +36 -0
- package/dist/templates/dispatch-center/tools/incident_triage.ts +78 -0
- package/dist/templates/dispatch-center/tools/incident_update_status.ts +68 -0
- package/dist/templates/dispatch-center/tools/ops_dashboard.ts +52 -0
- package/dist/templates/dispatch-center/tools/ops_protocols.ts +27 -0
- package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +149 -0
- package/dist/templates/dispatch-center/tools/resources_dispatch.ts +97 -0
- package/dist/templates/dispatch-center/tools/resources_get_available.ts +36 -0
- package/dist/templates/dispatch-center/tools/resources_update_status.ts +62 -0
- package/dist/templates/embedded-assets/agent.ts +55 -0
- package/dist/templates/embedded-assets/knowledge.json +20 -0
- package/dist/templates/health-assistant/agent.ts +174 -0
- package/dist/templates/health-assistant/system-prompt.md +17 -0
- package/dist/templates/infocom-adventure/agent.ts +119 -0
- package/dist/templates/infocom-adventure/client.tsx +279 -0
- package/dist/templates/infocom-adventure/shared.ts +38 -0
- package/dist/templates/infocom-adventure/system-prompt.md +43 -0
- package/dist/templates/math-buddy/agent.ts +16 -0
- package/dist/templates/math-buddy/system-prompt.md +12 -0
- package/dist/templates/night-owl/agent.ts +76 -0
- package/dist/templates/night-owl/client.tsx +130 -0
- package/dist/templates/night-owl/shared.ts +5 -0
- package/dist/templates/personal-finance/agent.ts +11 -0
- package/dist/templates/personal-finance/system-prompt.md +17 -0
- package/dist/templates/pipeline-simple/agent.test.ts +32 -0
- package/dist/templates/pipeline-simple/agent.ts +13 -0
- package/dist/templates/pizza-ordering/agent.test.ts +235 -0
- package/dist/templates/pizza-ordering/agent.ts +174 -0
- package/dist/templates/pizza-ordering/client.tsx +120 -0
- package/dist/templates/pizza-ordering/shared.ts +142 -0
- package/dist/templates/pizza-ordering/system-prompt.md +17 -0
- package/dist/templates/simple/agent.ts +5 -0
- package/dist/templates/solo-rpg/agent.test.ts +477 -0
- package/dist/templates/solo-rpg/agent.ts +37 -0
- package/dist/templates/solo-rpg/client.tsx +809 -0
- package/dist/templates/solo-rpg/shared.ts +747 -0
- package/dist/templates/solo-rpg/system-prompt.md +115 -0
- package/dist/templates/solo-rpg/tools/action_roll.ts +98 -0
- package/dist/templates/solo-rpg/tools/burn_momentum.ts +69 -0
- package/dist/templates/solo-rpg/tools/check_state.ts +11 -0
- package/dist/templates/solo-rpg/tools/load_game.ts +25 -0
- package/dist/templates/solo-rpg/tools/oracle.ts +248 -0
- package/dist/templates/solo-rpg/tools/save_game.ts +20 -0
- package/dist/templates/solo-rpg/tools/setup_character.ts +208 -0
- package/dist/templates/solo-rpg/tools/update_state.ts +170 -0
- package/dist/templates/web-researcher/agent.ts +11 -0
- package/dist/test.d.ts +39 -0
- package/dist/tsdown.config.d.ts +2 -0
- package/dist/typecheck.d.ts +26 -0
- package/dist/worker-bundler.d.ts +29 -0
- package/package.json +9 -6
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
You are a friendly pizza order-taker at Pizza Palace. Keep responses short and conversational, optimized for voice.
|
|
2
|
+
|
|
3
|
+
Your job is to help customers build their pizza order step by step. Guide them through size, crust, and toppings.
|
|
4
|
+
|
|
5
|
+
The menu (sizes, crusts, toppings, and prices) is appended below this prompt — it is generated from the same price list the ordering tools charge from.
|
|
6
|
+
|
|
7
|
+
Behavior:
|
|
8
|
+
- When a customer wants a pizza, collect size, crust, and toppings, then use add_pizza to add it.
|
|
9
|
+
- If they just say something like "pepperoni pizza", assume medium, regular crust, and confirm before adding.
|
|
10
|
+
- Always confirm what you added after using add_pizza.
|
|
11
|
+
- Use view_order when the customer asks to review their order.
|
|
12
|
+
- Use update_pizza if they want to change an existing pizza.
|
|
13
|
+
- Use remove_pizza if they want to remove one.
|
|
14
|
+
- When they say they are done ordering, use place_order.
|
|
15
|
+
- Suggest popular combos if they seem unsure. For example, "Our most popular is a large pepperoni with extra cheese."
|
|
16
|
+
- Always mention the running total after changes.
|
|
17
|
+
- Be warm but efficient. No long monologues.
|
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
import type { Db, ToolContext } from "@alexkroman1/aai";
|
|
2
|
+
import { describe, expect, test, vi } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
applyConsequences,
|
|
5
|
+
DEFAULT_STATE,
|
|
6
|
+
type GameState,
|
|
7
|
+
getGameState,
|
|
8
|
+
MAX_NPCS,
|
|
9
|
+
MIN_MOMENTUM,
|
|
10
|
+
makeNpc,
|
|
11
|
+
rollAction,
|
|
12
|
+
saveGameState,
|
|
13
|
+
} from "./shared.ts";
|
|
14
|
+
import { actionRoll } from "./tools/action_roll.ts";
|
|
15
|
+
import { burnMomentum } from "./tools/burn_momentum.ts";
|
|
16
|
+
import { checkState } from "./tools/check_state.ts";
|
|
17
|
+
import { loadGame } from "./tools/load_game.ts";
|
|
18
|
+
import { saveGame } from "./tools/save_game.ts";
|
|
19
|
+
import { setupCharacter } from "./tools/setup_character.ts";
|
|
20
|
+
import { updateState } from "./tools/update_state.ts";
|
|
21
|
+
|
|
22
|
+
// ── Test doubles ─────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Map-backed fake of the app database, implementing exactly the three SQL
|
|
26
|
+
* statements the shared save-slot helpers emit (create table / select /
|
|
27
|
+
* upsert). Values are stored parsed, the way a postgres driver returns jsonb.
|
|
28
|
+
*/
|
|
29
|
+
function makeDb(): { db: Db; rows: Map<string, unknown> } {
|
|
30
|
+
const rows = new Map<string, unknown>();
|
|
31
|
+
const db: Db = {
|
|
32
|
+
async query<T>(sql: string, params: unknown[] = []): Promise<T[]> {
|
|
33
|
+
if (sql.startsWith("create table if not exists app_state")) return [];
|
|
34
|
+
if (sql.startsWith("select value from app_state")) {
|
|
35
|
+
const key = params[0] as string;
|
|
36
|
+
return rows.has(key) ? ([{ value: structuredClone(rows.get(key)) }] as T[]) : [];
|
|
37
|
+
}
|
|
38
|
+
if (sql.startsWith("insert into app_state")) {
|
|
39
|
+
const [key, json] = params as [string, string];
|
|
40
|
+
rows.set(key, JSON.parse(json)); // $2::jsonb — parsed like postgres would
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
throw new Error(`unexpected SQL in test: ${sql}`);
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
return { db, rows };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function makeCtx(sessionId = "session-a", db: Db = makeDb().db): ToolContext {
|
|
50
|
+
return {
|
|
51
|
+
env: {},
|
|
52
|
+
state: {},
|
|
53
|
+
db,
|
|
54
|
+
generate: () => Promise.reject(new Error("generate not available in tests")),
|
|
55
|
+
messages: [],
|
|
56
|
+
sessionId,
|
|
57
|
+
send: vi.fn(),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const SETUP_ARGS = {
|
|
62
|
+
genre: "dark_fantasy",
|
|
63
|
+
tone: "dark_gritty",
|
|
64
|
+
archetype: "investigator",
|
|
65
|
+
playerName: "Kael",
|
|
66
|
+
characterConcept: "A haunted detective",
|
|
67
|
+
settingDescription: "A city of fog and iron.",
|
|
68
|
+
startingLocation: "The Docks",
|
|
69
|
+
locationDesc: "Rotting piers under gaslight.",
|
|
70
|
+
timeOfDay: "night" as const,
|
|
71
|
+
openingSituation: "A body washes ashore bearing your family crest.",
|
|
72
|
+
npc1Name: "Mira",
|
|
73
|
+
npc1Desc: "A wary informant",
|
|
74
|
+
npc1Disposition: "distrustful" as const,
|
|
75
|
+
npc1Agenda: "Pay off her debts",
|
|
76
|
+
threatClockName: "The Syndicate Closes In",
|
|
77
|
+
threatClockDesc: "Assassins find the player",
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
function playingState(): GameState {
|
|
81
|
+
const state = structuredClone(DEFAULT_STATE);
|
|
82
|
+
state.initialized = true;
|
|
83
|
+
state.phase = "playing";
|
|
84
|
+
state.sceneCount = 3;
|
|
85
|
+
state.npcs.push(makeNpc({ id: "npc_1", name: "Mira", disposition: "neutral" }));
|
|
86
|
+
state.clocks.push({
|
|
87
|
+
id: "clock_1",
|
|
88
|
+
name: "Doom",
|
|
89
|
+
clockType: "threat",
|
|
90
|
+
segments: 4,
|
|
91
|
+
filled: 0,
|
|
92
|
+
triggerDescription: "The doom arrives",
|
|
93
|
+
});
|
|
94
|
+
return state;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── setup_character ──────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
describe("setup_character", () => {
|
|
100
|
+
test("running setup twice starts fresh: no duplicate ids, no stale resources, truthful return", async () => {
|
|
101
|
+
const ctx = makeCtx();
|
|
102
|
+
|
|
103
|
+
await setupCharacter.execute(SETUP_ARGS, ctx);
|
|
104
|
+
|
|
105
|
+
// Simulate a played, damaged game between setups.
|
|
106
|
+
const played = getGameState(ctx);
|
|
107
|
+
played.health = 1;
|
|
108
|
+
played.momentum = -4;
|
|
109
|
+
played.chaosFactor = 8;
|
|
110
|
+
played.sceneCount = 42;
|
|
111
|
+
saveGameState(ctx, played);
|
|
112
|
+
|
|
113
|
+
const result = (await setupCharacter.execute(
|
|
114
|
+
{ ...SETUP_ARGS, playerName: "Luna" },
|
|
115
|
+
ctx,
|
|
116
|
+
)) as Record<string, unknown>;
|
|
117
|
+
|
|
118
|
+
const state = getGameState(ctx);
|
|
119
|
+
expect(state.npcs).toHaveLength(1);
|
|
120
|
+
expect(state.npcs[0]?.id).toBe("npc_1");
|
|
121
|
+
expect(state.clocks).toHaveLength(1);
|
|
122
|
+
expect(state.clocks[0]?.id).toBe("clock_1");
|
|
123
|
+
expect(state.playerName).toBe("Luna");
|
|
124
|
+
expect(state.health).toBe(5);
|
|
125
|
+
expect(state.momentum).toBe(2);
|
|
126
|
+
expect(state.chaosFactor).toBe(5);
|
|
127
|
+
expect(state.sceneCount).toBe(1);
|
|
128
|
+
expect(state.sessionLog).toHaveLength(0);
|
|
129
|
+
|
|
130
|
+
// The return value reports the REAL saved state, not hardcoded numbers.
|
|
131
|
+
expect(result.health).toBe(state.health);
|
|
132
|
+
expect(result.momentum).toBe(state.momentum);
|
|
133
|
+
expect(result.chaosFactor).toBe(state.chaosFactor);
|
|
134
|
+
expect(result.playerName).toBe("Luna");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("stats are a permutation of [3,2,2,1,1] with the archetype's stat at 3", async () => {
|
|
138
|
+
const ctx = makeCtx();
|
|
139
|
+
await setupCharacter.execute(SETUP_ARGS, ctx);
|
|
140
|
+
const state = getGameState(ctx);
|
|
141
|
+
const stats = [state.edge, state.heart, state.iron, state.shadow, state.wits];
|
|
142
|
+
expect([...stats].sort()).toEqual([1, 1, 2, 2, 3]);
|
|
143
|
+
// investigator biases wits (index 4) to the high stat
|
|
144
|
+
expect(state.wits).toBe(3);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("game state is scoped per session — a second session sees a fresh game", async () => {
|
|
148
|
+
await setupCharacter.execute(SETUP_ARGS, makeCtx("session-a"));
|
|
149
|
+
// ctx.state is per-session by construction — a new session, a new game.
|
|
150
|
+
const other = (await checkState.execute({} as never, makeCtx("session-b"))) as {
|
|
151
|
+
initialized: boolean;
|
|
152
|
+
};
|
|
153
|
+
expect(other.initialized).toBe(false);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// ── applyConsequences ────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
describe("applyConsequences MISS matrix", () => {
|
|
160
|
+
const miss = { result: "MISS" as const, move: "clash" };
|
|
161
|
+
|
|
162
|
+
test("combat miss scales damage by position and ticks the threat clock", () => {
|
|
163
|
+
for (const [position, dmg, ticks] of [
|
|
164
|
+
["controlled", 1, 1],
|
|
165
|
+
["risky", 2, 1],
|
|
166
|
+
["desperate", 3, 2],
|
|
167
|
+
] as const) {
|
|
168
|
+
const state = playingState();
|
|
169
|
+
const { deltas, clockEvents } = applyConsequences(state, miss, position, "standard", null);
|
|
170
|
+
expect(state.health).toBe(5 - dmg);
|
|
171
|
+
expect(deltas.health).toBe(-dmg);
|
|
172
|
+
expect(state.clocks[0]?.filled).toBe(ticks);
|
|
173
|
+
expect(deltas.clockTicks).toBe(ticks);
|
|
174
|
+
expect(clockEvents).toHaveLength(0); // 4-segment clock not full yet
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("momentum loss is floored at MIN_MOMENTUM and deltas record the actual change", () => {
|
|
179
|
+
const state = playingState();
|
|
180
|
+
state.momentum = -5;
|
|
181
|
+
const { deltas } = applyConsequences(state, miss, "desperate", "standard", null);
|
|
182
|
+
expect(state.momentum).toBe(MIN_MOMENTUM); // -5 - 3 clamps to -6
|
|
183
|
+
expect(deltas.momentum).toBe(-1);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("social miss with a target drops bond (floored at 0) and spirit", () => {
|
|
187
|
+
const state = playingState();
|
|
188
|
+
state.npcs[0]!.bond = 0;
|
|
189
|
+
const { deltas } = applyConsequences(
|
|
190
|
+
state,
|
|
191
|
+
{ result: "MISS", move: "compel" },
|
|
192
|
+
"risky",
|
|
193
|
+
"standard",
|
|
194
|
+
"npc_1",
|
|
195
|
+
);
|
|
196
|
+
expect(state.npcs[0]?.bond).toBe(0);
|
|
197
|
+
expect(deltas.bond).toBe(0); // already at floor — nothing actually applied
|
|
198
|
+
expect(state.spirit).toBe(4);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("filling the threat clock emits its trigger event and crisis flags fire at 0 health", () => {
|
|
202
|
+
const state = playingState();
|
|
203
|
+
state.clocks[0]!.filled = 3; // one tick from full
|
|
204
|
+
state.health = 2;
|
|
205
|
+
const { clockEvents } = applyConsequences(state, miss, "risky", "standard", null);
|
|
206
|
+
expect(clockEvents).toEqual([{ clock: "Doom", trigger: "The doom arrives" }]);
|
|
207
|
+
expect(state.health).toBe(0);
|
|
208
|
+
expect(state.crisisMode).toBe(true);
|
|
209
|
+
expect(state.gameOver).toBe(false);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("strong hit with great effect gains +3 momentum and shifts disposition on compel", () => {
|
|
213
|
+
const state = playingState();
|
|
214
|
+
const { deltas } = applyConsequences(
|
|
215
|
+
state,
|
|
216
|
+
{ result: "STRONG_HIT", move: "compel" },
|
|
217
|
+
"risky",
|
|
218
|
+
"great",
|
|
219
|
+
"npc_1",
|
|
220
|
+
);
|
|
221
|
+
expect(state.momentum).toBe(5);
|
|
222
|
+
expect(deltas.momentum).toBe(3);
|
|
223
|
+
expect(state.npcs[0]?.disposition).toBe("friendly");
|
|
224
|
+
expect(deltas.dispositionFrom).toBe("neutral");
|
|
225
|
+
expect(state.npcs[0]?.bond).toBe(1);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// ── burn_momentum ────────────────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
describe("burn_momentum", () => {
|
|
232
|
+
function seedRolledState(momentum: number, ctx: ToolContext) {
|
|
233
|
+
const state = playingState();
|
|
234
|
+
// A MISS was applied: health -2, momentum -2, clock +1.
|
|
235
|
+
state.health = 3;
|
|
236
|
+
state.clocks[0]!.filled = 1;
|
|
237
|
+
state.momentum = momentum;
|
|
238
|
+
state.lastRoll = {
|
|
239
|
+
d1: 2,
|
|
240
|
+
d2: 2,
|
|
241
|
+
c1: 3,
|
|
242
|
+
c2: 5,
|
|
243
|
+
statName: "iron",
|
|
244
|
+
statValue: 2,
|
|
245
|
+
actionScore: 6,
|
|
246
|
+
result: "MISS",
|
|
247
|
+
move: "clash",
|
|
248
|
+
match: false,
|
|
249
|
+
position: "risky",
|
|
250
|
+
effect: "standard",
|
|
251
|
+
targetNpcId: null,
|
|
252
|
+
deltas: {
|
|
253
|
+
health: -2,
|
|
254
|
+
spirit: 0,
|
|
255
|
+
supply: 0,
|
|
256
|
+
momentum: -2,
|
|
257
|
+
npcId: null,
|
|
258
|
+
bond: 0,
|
|
259
|
+
dispositionFrom: null,
|
|
260
|
+
dispositionTo: null,
|
|
261
|
+
clockId: "clock_1",
|
|
262
|
+
clockTicks: 1,
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
saveGameState(ctx, state);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
test("a legal burn reverts the miss's consequences, upgrades, and resets momentum", async () => {
|
|
269
|
+
const ctx = makeCtx();
|
|
270
|
+
seedRolledState(8, ctx); // 8 beats both dice (3, 5)
|
|
271
|
+
|
|
272
|
+
const result = (await burnMomentum.execute({} as never, ctx)) as Record<string, unknown>;
|
|
273
|
+
expect(result.burned).toBe(true);
|
|
274
|
+
expect(result.newResultCode).toBe("STRONG_HIT");
|
|
275
|
+
|
|
276
|
+
const state = getGameState(ctx);
|
|
277
|
+
expect(state.health).toBe(5); // -2 reverted
|
|
278
|
+
expect(state.clocks[0]?.filled).toBe(0); // tick reverted
|
|
279
|
+
expect(state.momentum).toBe(2); // reset, overriding the strong hit's gain
|
|
280
|
+
expect(state.lastRoll).toBeNull();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("momentum beating only one die upgrades a MISS to WEAK_HIT", async () => {
|
|
284
|
+
const ctx = makeCtx();
|
|
285
|
+
seedRolledState(4, ctx); // beats 3, not 5
|
|
286
|
+
const result = (await burnMomentum.execute({} as never, ctx)) as Record<string, unknown>;
|
|
287
|
+
expect(result.newResultCode).toBe("WEAK_HIT");
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("burn is refused with no stored roll, insufficient momentum, or a strong hit", async () => {
|
|
291
|
+
const ctx = makeCtx();
|
|
292
|
+
|
|
293
|
+
// No roll yet
|
|
294
|
+
let result = (await burnMomentum.execute({} as never, ctx)) as Record<string, unknown>;
|
|
295
|
+
expect(result.error).toMatch(/No recent action roll/);
|
|
296
|
+
|
|
297
|
+
// Momentum too low to beat either die
|
|
298
|
+
seedRolledState(2, ctx);
|
|
299
|
+
result = (await burnMomentum.execute({} as never, ctx)) as Record<string, unknown>;
|
|
300
|
+
expect(result.error).toMatch(/not high enough/);
|
|
301
|
+
|
|
302
|
+
// Strong hits cannot be upgraded
|
|
303
|
+
seedRolledState(8, ctx);
|
|
304
|
+
const state = getGameState(ctx);
|
|
305
|
+
state.lastRoll!.result = "STRONG_HIT";
|
|
306
|
+
saveGameState(ctx, state);
|
|
307
|
+
result = (await burnMomentum.execute({} as never, ctx)) as Record<string, unknown>;
|
|
308
|
+
expect(result.error).toMatch(/already a Strong Hit/);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("action_roll persists the roll so burn needs no dice arguments", async () => {
|
|
312
|
+
const ctx = makeCtx();
|
|
313
|
+
saveGameState(ctx, playingState());
|
|
314
|
+
await actionRoll.execute(
|
|
315
|
+
{ move: "clash", stat: "iron", position: "risky", effect: "standard", purpose: "attack" },
|
|
316
|
+
ctx,
|
|
317
|
+
);
|
|
318
|
+
const state = getGameState(ctx);
|
|
319
|
+
expect(state.lastRoll).not.toBeNull();
|
|
320
|
+
expect(state.lastRoll?.move).toBe("clash");
|
|
321
|
+
expect(state.lastRoll?.deltas).toBeDefined();
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// ── rollAction dice boundaries ───────────────────────────────────────────────
|
|
326
|
+
|
|
327
|
+
describe("rollAction", () => {
|
|
328
|
+
// Math.random call order inside rollAction: d1, d2, c1, c2.
|
|
329
|
+
function mockDice(d1: number, d2: number, c1: number, c2: number) {
|
|
330
|
+
const spy = vi.spyOn(Math, "random");
|
|
331
|
+
for (const [value, sides] of [
|
|
332
|
+
[d1, 6],
|
|
333
|
+
[d2, 6],
|
|
334
|
+
[c1, 10],
|
|
335
|
+
[c2, 10],
|
|
336
|
+
] as const) {
|
|
337
|
+
spy.mockReturnValueOnce((value - 0.5) / sides);
|
|
338
|
+
}
|
|
339
|
+
return spy;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
test("tying a challenge die is NOT a beat — equal on both dice is a MISS with match", () => {
|
|
343
|
+
mockDice(3, 3, 8, 8); // action score 3+3+2 = 8 vs 8, 8
|
|
344
|
+
const roll = rollAction("wits", 2, "face_danger");
|
|
345
|
+
expect(roll.actionScore).toBe(8);
|
|
346
|
+
expect(roll.result).toBe("MISS");
|
|
347
|
+
expect(roll.match).toBe(true);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("action score caps at 10 even when dice + stat exceed it", () => {
|
|
351
|
+
mockDice(6, 6, 1, 1); // 6+6+4 = 16 → capped to 10
|
|
352
|
+
const roll = rollAction("iron", 4, "clash");
|
|
353
|
+
expect(roll.actionScore).toBe(10);
|
|
354
|
+
expect(roll.result).toBe("STRONG_HIT");
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("beating exactly one die is a WEAK_HIT", () => {
|
|
358
|
+
mockDice(4, 2, 5, 9); // 4+2+2 = 8: beats 5, not 9
|
|
359
|
+
const roll = rollAction("edge", 2, "face_danger");
|
|
360
|
+
expect(roll.result).toBe("WEAK_HIT");
|
|
361
|
+
expect(roll.match).toBe(false);
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
// ── update_state: clocks, caps, validation ───────────────────────────────────
|
|
366
|
+
|
|
367
|
+
describe("update_state", () => {
|
|
368
|
+
test("clock ids never collide after a removal (max-scan, not length+1)", async () => {
|
|
369
|
+
const ctx = makeCtx();
|
|
370
|
+
saveGameState(ctx, playingState()); // has clock_1
|
|
371
|
+
|
|
372
|
+
await updateState.execute({ addClockName: "Second" }, ctx); // clock_2
|
|
373
|
+
await updateState.execute({ removeClockName: "Doom" }, ctx); // removes clock_1
|
|
374
|
+
await updateState.execute({ addClockName: "Third" }, ctx);
|
|
375
|
+
|
|
376
|
+
const state = getGameState(ctx);
|
|
377
|
+
const ids = state.clocks.map((c) => c.id);
|
|
378
|
+
expect(new Set(ids).size).toBe(ids.length);
|
|
379
|
+
expect(ids).toEqual(["clock_2", "clock_3"]);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("advancing a clock to full reports its trigger event", async () => {
|
|
383
|
+
const ctx = makeCtx();
|
|
384
|
+
const state = playingState();
|
|
385
|
+
state.clocks[0]!.filled = 3; // 3 of 4
|
|
386
|
+
saveGameState(ctx, state);
|
|
387
|
+
|
|
388
|
+
const result = (await updateState.execute({ advanceClockName: "Doom" }, ctx)) as {
|
|
389
|
+
clockEvents: { clock: string; trigger: string }[];
|
|
390
|
+
};
|
|
391
|
+
expect(result.clockEvents).toEqual([{ clock: "Doom", trigger: "The doom arrives" }]);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test("NPC count is capped at MAX_NPCS with a warning", async () => {
|
|
395
|
+
const ctx = makeCtx();
|
|
396
|
+
const state = playingState();
|
|
397
|
+
while (state.npcs.length < MAX_NPCS) {
|
|
398
|
+
state.npcs.push(makeNpc({ id: `npc_${state.npcs.length + 1}`, name: "Extra" }));
|
|
399
|
+
}
|
|
400
|
+
saveGameState(ctx, state);
|
|
401
|
+
|
|
402
|
+
const result = (await updateState.execute({ addNpcName: "One Too Many" }, ctx)) as {
|
|
403
|
+
warnings?: string[];
|
|
404
|
+
};
|
|
405
|
+
expect(result.warnings?.[0]).toMatch(/NPC limit/);
|
|
406
|
+
const after = getGameState(ctx);
|
|
407
|
+
expect(after.npcs).toHaveLength(MAX_NPCS);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test("zod schemas reject out-of-range and malformed inputs", () => {
|
|
411
|
+
const params = updateState.parameters!;
|
|
412
|
+
expect(() => params.parse({ addClockSegments: 1 })).toThrow(); // below min
|
|
413
|
+
expect(() => params.parse({ addClockSegments: 13 })).toThrow(); // above max
|
|
414
|
+
expect(() => params.parse({ addClockSegments: 2.5 })).toThrow(); // non-integer
|
|
415
|
+
expect(() => params.parse({ updateNpcBond: 5 })).toThrow(); // above MAX_BOND
|
|
416
|
+
expect(() => params.parse({ updateNpcBond: -1 })).toThrow();
|
|
417
|
+
expect(() => params.parse({ timeOfDay: "noonish" })).toThrow(); // not a phase
|
|
418
|
+
expect(
|
|
419
|
+
params.parse({ addClockSegments: 6, updateNpcBond: 4, timeOfDay: "night" }),
|
|
420
|
+
).toBeTruthy();
|
|
421
|
+
|
|
422
|
+
const slotParams = saveGame.parameters!;
|
|
423
|
+
expect(() => slotParams.parse({ slot: "../../etc" })).toThrow();
|
|
424
|
+
expect(() => slotParams.parse({ slot: "a".repeat(40) })).toThrow();
|
|
425
|
+
expect(slotParams.parse({ slot: "chapter-2" })).toBeTruthy();
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
// ── save_game / load_game: cross-session persistence via ctx.db ──────────────
|
|
430
|
+
|
|
431
|
+
describe("save_game / load_game", () => {
|
|
432
|
+
test("a save made in one session loads in a later session", async () => {
|
|
433
|
+
const { db, rows } = makeDb();
|
|
434
|
+
|
|
435
|
+
// Session A plays and saves.
|
|
436
|
+
const sessionA = makeCtx("session-a", db);
|
|
437
|
+
const played = playingState();
|
|
438
|
+
played.playerName = "Kael";
|
|
439
|
+
played.sceneCount = 7;
|
|
440
|
+
saveGameState(sessionA, played);
|
|
441
|
+
const saved = (await saveGame.execute({ slot: "chapter-2" }, sessionA)) as Record<
|
|
442
|
+
string,
|
|
443
|
+
unknown
|
|
444
|
+
>;
|
|
445
|
+
expect(saved.saved).toBe(true);
|
|
446
|
+
expect(saved.slot).toBe("chapter-2");
|
|
447
|
+
expect(rows.get("save:chapter-2")).toMatchObject({ playerName: "Kael", sceneCount: 7 });
|
|
448
|
+
|
|
449
|
+
// Session B (fresh ctx.state, same app db) resumes it.
|
|
450
|
+
const sessionB = makeCtx("session-b", db);
|
|
451
|
+
const loaded = (await loadGame.execute({ slot: "chapter-2" }, sessionB)) as Record<
|
|
452
|
+
string,
|
|
453
|
+
unknown
|
|
454
|
+
>;
|
|
455
|
+
expect(loaded.loaded).toBe(true);
|
|
456
|
+
expect(loaded.playerName).toBe("Kael");
|
|
457
|
+
expect(loaded.sceneCount).toBe(7);
|
|
458
|
+
expect(getGameState(sessionB).playerName).toBe("Kael");
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
test("loading a missing slot reports an error instead of resetting the game", async () => {
|
|
462
|
+
const ctx = makeCtx();
|
|
463
|
+
const result = (await loadGame.execute({ slot: "nope" }, ctx)) as { error?: string };
|
|
464
|
+
expect(result.error).toMatch(/No save found/);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
test("saving twice to one slot upserts — the newer save wins", async () => {
|
|
468
|
+
const { db, rows } = makeDb();
|
|
469
|
+
const ctx = makeCtx("session-a", db);
|
|
470
|
+
saveGameState(ctx, playingState());
|
|
471
|
+
await saveGame.execute({}, ctx); // autosave
|
|
472
|
+
getGameState(ctx).sceneCount = 9;
|
|
473
|
+
await saveGame.execute({}, ctx);
|
|
474
|
+
expect(rows.size).toBe(1);
|
|
475
|
+
expect(rows.get("save:autosave")).toMatchObject({ sceneCount: 9 });
|
|
476
|
+
});
|
|
477
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { agent, assemblyAIPipeline } from "@alexkroman1/aai";
|
|
2
|
+
import { DEFAULT_STATE, type StateSlot } from "./shared.ts";
|
|
3
|
+
import systemPrompt from "./system-prompt.md?raw";
|
|
4
|
+
import { actionRoll } from "./tools/action_roll.ts";
|
|
5
|
+
import { burnMomentum } from "./tools/burn_momentum.ts";
|
|
6
|
+
import { checkState } from "./tools/check_state.ts";
|
|
7
|
+
import { loadGame } from "./tools/load_game.ts";
|
|
8
|
+
import { oracle } from "./tools/oracle.ts";
|
|
9
|
+
import { saveGame } from "./tools/save_game.ts";
|
|
10
|
+
import { setupCharacter } from "./tools/setup_character.ts";
|
|
11
|
+
import { updateState } from "./tools/update_state.ts";
|
|
12
|
+
|
|
13
|
+
export default agent({
|
|
14
|
+
name: "Solo RPG",
|
|
15
|
+
...assemblyAIPipeline(),
|
|
16
|
+
systemPrompt,
|
|
17
|
+
greeting:
|
|
18
|
+
"Welcome. Tell me your name, or describe the kind of story you want, and we will begin. You can say something like, dark fantasy warrior named Kael, or just give me a name and I will build a world around you.",
|
|
19
|
+
sttPrompt:
|
|
20
|
+
"Solo RPG terms: strong hit, weak hit, miss, momentum, chaos factor, clock, disposition, bond, edge, heart, iron, shadow, wits, face danger, compel, gather information, secure advantage, clash, strike, endure harm, endure stress, make connection, test bond, resupply, world shaping",
|
|
21
|
+
maxSteps: 8,
|
|
22
|
+
|
|
23
|
+
// One declaration replaces a `ctx.send("game_state", state)` in every
|
|
24
|
+
// state-mutating tool — six of them, and adding a seventh meant
|
|
25
|
+
// remembering to push or watching the UI quietly fall out of sync.
|
|
26
|
+
syncState: (s: StateSlot) => s.game ?? DEFAULT_STATE,
|
|
27
|
+
tools: {
|
|
28
|
+
action_roll: actionRoll,
|
|
29
|
+
burn_momentum: burnMomentum,
|
|
30
|
+
check_state: checkState,
|
|
31
|
+
load_game: loadGame,
|
|
32
|
+
oracle,
|
|
33
|
+
save_game: saveGame,
|
|
34
|
+
setup_character: setupCharacter,
|
|
35
|
+
update_state: updateState,
|
|
36
|
+
},
|
|
37
|
+
});
|