@g3nomaly/g3n-mods 0.1.7 → 0.1.8
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/.claude-plugin/marketplace.json +7 -1
- package/README.md +12 -5
- package/mods/agent-adventure/.claude-plugin/plugin.json +23 -0
- package/mods/agent-adventure/hooks/content.ts +263 -0
- package/mods/agent-adventure/hooks/engine.ts +1125 -0
- package/mods/agent-adventure/hooks/hooks.json +1 -0
- package/mods/agent-adventure/hooks/register.tsx +465 -0
- package/mods/agent-adventure/hooks/sprite.ts +424 -0
- package/mods/agent-adventure/types/index.d.ts +248 -0
- package/mods/agent-adventure/view/panes.json +8 -0
- package/mods/agent-adventure/view/view.css +230 -0
- package/mods/agent-adventure/view/view.ts +308 -0
- package/mods/agents-panel/.claude-plugin/plugin.json +9 -1
- package/mods/agents-panel/hooks/register.tsx +18 -4
- package/mods/agents-panel/view/panes.json +8 -0
- package/mods/context-bar/.claude-plugin/plugin.json +9 -1
- package/mods/context-bar/hooks/register.tsx +4 -3
- package/mods/context-bar/view/panes.json +8 -0
- package/mods/custom-crab-agents/hooks/register.tsx +15 -1
- package/mods/delete-guard/.claude-plugin/plugin.json +9 -1
- package/mods/delete-guard/hooks/register.tsx +15 -5
- package/mods/delete-guard/view/panes.json +8 -0
- package/mods/focus-mode/.claude-plugin/plugin.json +6 -0
- package/mods/focus-mode/hooks/register.tsx +7 -4
- package/mods/focus-mode/view/panes.json +8 -0
- package/mods/mini-game/.claude-plugin/plugin.json +9 -1
- package/mods/mini-game/hooks/register.tsx +21 -7
- package/mods/mini-game/view/panes.json +8 -0
- package/mods/tool-timeline/.claude-plugin/plugin.json +9 -1
- package/mods/tool-timeline/hooks/register.tsx +18 -4
- package/mods/tool-timeline/view/panes.json +8 -0
- package/mods/you-should-know/.claude-plugin/plugin.json +3 -3
- package/mods/you-should-know/hooks/register.tsx +173 -45
- package/mods/you-should-know/types/index.d.ts +13 -4
- package/package.json +2 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "modules": ["./register.tsx"] }
|
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
// packages/g3n-mods/mods/agent-adventure/hooks/register.tsx
|
|
2
|
+
import { atom, read, update } from 'claude-code'
|
|
3
|
+
import type { EngineInterface, Register } from 'claude-code'
|
|
4
|
+
|
|
5
|
+
import type { Hero, Run, UnitView, World } from '../types'
|
|
6
|
+
import { CLASSES, CLASS_IDS, REGIONS, SKILLS } from './content'
|
|
7
|
+
import { agentRequest, chooseRoute, christen, defaultRoute, hash, hpShare, join, learn, leave, newWorld, partyIds, step, toView } from './engine'
|
|
8
|
+
import type { Ask } from './engine'
|
|
9
|
+
import { RANK_SCALE, SPRITE_COLUMNS, SPRITE_ROWS, eliteTint, spriteSvg } from './sprite'
|
|
10
|
+
|
|
11
|
+
const PANE = 'agent-adventure'
|
|
12
|
+
const TITLE = 'Agent Adventure'
|
|
13
|
+
/** The tool agents may call to name their hero, set its motto or say how they feel about another hero. */
|
|
14
|
+
const TOOL = 'hero'
|
|
15
|
+
const TOOL_NAME = `mcp__agent-adventure__${TOOL}`
|
|
16
|
+
|
|
17
|
+
const world = atom({ plugin: 'agent-adventure', key: 'world' } as const, null)
|
|
18
|
+
|
|
19
|
+
/** Milliseconds per game tick: one action of a small fight. */
|
|
20
|
+
const TICK_MS = 1000
|
|
21
|
+
/** With nobody working, the tavern moves only every this many ticks. */
|
|
22
|
+
const TAVERN_EVERY = 4
|
|
23
|
+
const DEFAULT_MODEL = 'haiku'
|
|
24
|
+
const MODEL_TIMEOUT_MS = 15_000
|
|
25
|
+
|
|
26
|
+
/** Store keys: one per hero, the run in progress, and the counters. */
|
|
27
|
+
const HERO_PREFIX = 'hero:'
|
|
28
|
+
const RUN_KEY = 'run'
|
|
29
|
+
const META_KEY = 'meta'
|
|
30
|
+
|
|
31
|
+
/** Prop the mods viewer adds to every pane it draws; the Claude Code extension draws on the `vscode` surface too, without it. */
|
|
32
|
+
const VIEWER_PROP = 'g3nViewer'
|
|
33
|
+
|
|
34
|
+
/** Glyph of each stop in the run's path, for surfaces without the mod's view. */
|
|
35
|
+
const STOP_GLYPHS = { fight: '⚔', elite: '★', fork: '⑂', camp: '⛺', boss: '☠' } as const
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Tells whether the mods viewer asked for this drawing, rather than the Claude Code extension on the same surface.
|
|
39
|
+
*
|
|
40
|
+
* @param props - The `Pane` props of the `ui.render` argument.
|
|
41
|
+
* @returns `true` when the viewer's prop is set.
|
|
42
|
+
*/
|
|
43
|
+
function isViewer(props: object): boolean {
|
|
44
|
+
return (props as Readonly<Record<string, unknown>>)[VIEWER_PROP] === true
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** JSON last written per store key, so a tick writes only what changed. */
|
|
48
|
+
const saved = new Map<string, string>()
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Writes to the store each hero, the run and the counters that changed since the last write.
|
|
52
|
+
*
|
|
53
|
+
* @param $ - Engine handle the hook received, see `EngineInterface`.
|
|
54
|
+
* @param w - The world after a change.
|
|
55
|
+
*/
|
|
56
|
+
async function persist($: EngineInterface, w: World): Promise<void> {
|
|
57
|
+
const writes: Array<[string, unknown]> = []
|
|
58
|
+
for (const hero of w.heroes) writes.push([`${HERO_PREFIX}${hero.id}`, hero])
|
|
59
|
+
writes.push([RUN_KEY, w.run], [META_KEY, { runs: w.runs, threat: Math.round(w.threat * 1000) / 1000, isCalm: w.isCalm }])
|
|
60
|
+
for (const [key, value] of writes) {
|
|
61
|
+
const json = JSON.stringify(value)
|
|
62
|
+
if (saved.get(key) === json) continue
|
|
63
|
+
saved.set(key, json)
|
|
64
|
+
await $.store.set(key, value).catch(() => saved.delete(key))
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Reads the guild, the run and the counters back from the store into a fresh world.
|
|
70
|
+
*
|
|
71
|
+
* @param $ - Engine handle the hook received, see `EngineInterface`.
|
|
72
|
+
* @returns The world as the last session left it, or an empty one.
|
|
73
|
+
*/
|
|
74
|
+
async function load($: EngineInterface): Promise<World> {
|
|
75
|
+
const keys = await $.store.keys().catch(() => [] as string[])
|
|
76
|
+
const heroes: Hero[] = []
|
|
77
|
+
for (const key of keys.filter(one => one.startsWith(HERO_PREFIX))) {
|
|
78
|
+
const value = await $.store.get(key)
|
|
79
|
+
if (isHero(value)) {
|
|
80
|
+
heroes.push(value)
|
|
81
|
+
saved.set(key, JSON.stringify(value))
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const run = await $.store.get(RUN_KEY)
|
|
85
|
+
const meta = await $.store.get(META_KEY)
|
|
86
|
+
const counters = typeof meta === 'object' && meta !== null ? (meta as { runs?: unknown; threat?: unknown; isCalm?: unknown }) : {}
|
|
87
|
+
const now = await $.clock.now()
|
|
88
|
+
const w = newWorld(
|
|
89
|
+
heroes.sort((a, b) => a.createdAt - b.createdAt),
|
|
90
|
+
isRun(run) ? run : null,
|
|
91
|
+
hash(String(now)),
|
|
92
|
+
typeof counters.runs === 'number' ? counters.runs : 0,
|
|
93
|
+
typeof counters.threat === 'number' ? counters.threat : 1,
|
|
94
|
+
)
|
|
95
|
+
w.isCalm = counters.isCalm === true
|
|
96
|
+
return w
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Tells whether a stored value is a hero this version can play. */
|
|
100
|
+
function isHero(value: unknown): value is Hero {
|
|
101
|
+
if (typeof value !== 'object' || value === null) return false
|
|
102
|
+
const hero = value as Partial<Hero>
|
|
103
|
+
return typeof hero.id === 'string' && typeof hero.name === 'string' && CLASS_IDS.some(cls => cls === hero.cls)
|
|
104
|
+
&& typeof hero.level === 'number' && Array.isArray(hero.skills) && typeof hero.record === 'object'
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Tells whether a stored value is a run this version can continue. */
|
|
108
|
+
function isRun(value: unknown): value is Run {
|
|
109
|
+
if (typeof value !== 'object' || value === null) return false
|
|
110
|
+
const run = value as Partial<Run>
|
|
111
|
+
return typeof run.region === 'string' && run.region in REGIONS && Array.isArray(run.path) && typeof run.at === 'number'
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Applies a rule to the world and settles what follows: the store writes and the decisions it asks for.
|
|
116
|
+
*
|
|
117
|
+
* @param $ - Engine handle the hook received, see `EngineInterface`.
|
|
118
|
+
* @param model - Model that makes the game's decisions, or `undefined` for the game's own picks.
|
|
119
|
+
* @param rule - Pure rule from `engine.ts`; returns the next world and its asks.
|
|
120
|
+
*/
|
|
121
|
+
async function change($: EngineInterface, model: string | undefined, rule: (w: World) => { world: World; asks: Ask[] }): Promise<void> {
|
|
122
|
+
let asks: Ask[] = []
|
|
123
|
+
const after = await update($, world, current => {
|
|
124
|
+
if (current === null) return current
|
|
125
|
+
const outcome = rule(current)
|
|
126
|
+
asks = outcome.asks
|
|
127
|
+
return outcome.world
|
|
128
|
+
})
|
|
129
|
+
if (after === null) return
|
|
130
|
+
await persist($, after)
|
|
131
|
+
for (const one of asks) $.clock.after(0, () => void decide($, model, one))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Asks the model for one JSON answer, or `undefined` when it gives none in time.
|
|
136
|
+
*
|
|
137
|
+
* @param $ - Engine handle the hook received, see `EngineInterface`.
|
|
138
|
+
* @param model - Model alias or id.
|
|
139
|
+
* @param system - What the model is and the JSON it answers with.
|
|
140
|
+
* @param prompt - The decision at hand.
|
|
141
|
+
*/
|
|
142
|
+
async function askModel($: EngineInterface, model: string, system: string, prompt: string): Promise<Record<string, unknown> | undefined> {
|
|
143
|
+
const result = await $.model.complete({ model, system, prompt, maxTokens: 200, timeoutMs: MODEL_TIMEOUT_MS }).catch(() => undefined)
|
|
144
|
+
if (result === undefined || !result.isAnswered) return undefined
|
|
145
|
+
const json = /\{[\s\S]*\}/.exec(result.text)?.[0]
|
|
146
|
+
if (json === undefined) return undefined
|
|
147
|
+
try {
|
|
148
|
+
const value: unknown = JSON.parse(json)
|
|
149
|
+
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : undefined
|
|
150
|
+
} catch {
|
|
151
|
+
return undefined
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const GAME = 'You make one small decision in a cozy fantasy idle game that shows AI coding agents as a party of heroes. '
|
|
156
|
+
+ 'It is purely cosmetic. Answer with JSON only, no prose, no code fence.'
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* @brief Makes a decision the game asked for: the model's pick when a model is set and answers, else the game's own.
|
|
160
|
+
* Never blocks the game: a fork goes on with its default after `FORK_WAIT` ticks whatever happens here.
|
|
161
|
+
*
|
|
162
|
+
* @param $ - Engine handle the hook received, see `EngineInterface`.
|
|
163
|
+
* @param model - Model that makes the decision, or `undefined` for the game's own pick.
|
|
164
|
+
* @param one - The decision, see `Ask`.
|
|
165
|
+
*/
|
|
166
|
+
async function decide($: EngineInterface, model: string | undefined, one: Ask): Promise<void> {
|
|
167
|
+
const w = await read($, world)
|
|
168
|
+
if (w === null) return
|
|
169
|
+
|
|
170
|
+
if (one.kind === 'christen') {
|
|
171
|
+
const counts = CLASS_IDS.map(cls => `${cls} ×${w.heroes.filter(hero => !hero.isRecruit && hero.cls === cls).length}`).join(', ')
|
|
172
|
+
const hero = w.heroes.find(h => h.id === one.heroId)
|
|
173
|
+
const answer = model === undefined || hero === undefined ? undefined : await askModel($, model, GAME
|
|
174
|
+
+ ' Sign up a new hero: {"name": "...", "class": "...", "motto": "..."}. The name is one or two words, at most 16'
|
|
175
|
+
+ ` letters, fantasy flavoured, and may nod to the kind of agent it plays. The class is one of ${CLASS_IDS.join(', ')}.`
|
|
176
|
+
+ ' The motto is at most 50 characters, light and friendly.',
|
|
177
|
+
`This hero turns up for "${hero.calling}" agents${hero.calling === 'main' ? ' (the main assistant)' : ''}. `
|
|
178
|
+
+ `The guild has: ${counts}. Prefer a class the guild lacks. Names taken: ${w.heroes.map(h => h.name).join(', ') || 'none'}.`)
|
|
179
|
+
await change($, model, current => ({ world: christen(current, one.heroId, answer === undefined ? undefined : { ...answer, cls: answer.class }), asks: [] }))
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (one.kind === 'skill') {
|
|
184
|
+
const hero = w.heroes.find(h => h.id === one.heroId)
|
|
185
|
+
const offered = hero?.choice?.map(id => SKILLS[id])
|
|
186
|
+
if (hero === undefined || offered === undefined || offered.some(skill => skill === undefined)) return
|
|
187
|
+
const known = hero.skills.map(id => SKILLS[id]?.name ?? id)
|
|
188
|
+
const answer = model === undefined ? undefined : await askModel($, model, GAME
|
|
189
|
+
+ ' A hero picks one of two skills: {"pick": 1 or 2, "why": "..."}. "why" is at most 60 characters, said by the hero.',
|
|
190
|
+
`${hero.name}, a level ${hero.level} ${CLASSES[hero.cls].name} (${CLASSES[hero.cls].role}), knows: ${known.join(', ') || 'only its basic attack'}.`
|
|
191
|
+
+ `${known.length >= 3 ? ` Learning one replaces its oldest skill, ${known[0]}.` : ''}`
|
|
192
|
+
+ ` 1) ${offered[0]?.name}: ${offered[0]?.blurb}. 2) ${offered[1]?.name}: ${offered[1]?.blurb}.`)
|
|
193
|
+
const pick = answer?.pick === 2 || answer?.pick === '2' ? 1 : 0
|
|
194
|
+
await change($, model, current => ({ world: learn(current, one.heroId, pick, typeof answer?.why === 'string' ? answer.why : undefined), asks: [] }))
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (model === undefined || w.run === null || w.scene.kind !== 'fork') return
|
|
199
|
+
const run = w.run
|
|
200
|
+
const party = partyIds(w).map(id => w.heroes.find(hero => hero.id === id)).filter((hero): hero is Hero => hero !== undefined)
|
|
201
|
+
const answer = await askModel($, model, GAME
|
|
202
|
+
+ ' The party picks a route: {"route": "safe" or "danger", "why": "..."}. "why" is at most 60 characters, said by one hero.',
|
|
203
|
+
`In the ${REGIONS[run.region].name}, run ${run.number}, the path forks. Safe: a normal fight and a potion. `
|
|
204
|
+
+ 'Dangerous: an elite fight with double XP and a relic. If the whole party falls, everyone loses 5 levels. '
|
|
205
|
+
+ `Party: ${party.map(hero => `${hero.name} (level ${hero.level} ${CLASSES[hero.cls].name}, ${Math.round(100 * hpShare(w, hero.id))}% HP)`).join(', ')}. `
|
|
206
|
+
+ `Potions: ${run.potions}. The cautious pick would be "${defaultRoute(w)}".`)
|
|
207
|
+
const route = answer?.route === 'danger' || answer?.route === 'safe' ? answer.route : undefined
|
|
208
|
+
if (route === undefined) return
|
|
209
|
+
await change($, model, current => ({ world: chooseRoute(current, one.forkId, route, typeof answer?.why === 'string' ? answer.why : undefined), asks: [] }))
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Carries out an action the mod's view sent: `calm` toggles calmer animations.
|
|
214
|
+
*
|
|
215
|
+
* @param $ - Engine handle the action came through.
|
|
216
|
+
* @param value - The action as the view sent it, JSON of `{ name, arg }`.
|
|
217
|
+
*/
|
|
218
|
+
async function act($: EngineInterface, value: string): Promise<void> {
|
|
219
|
+
let action: { name?: unknown }
|
|
220
|
+
try {
|
|
221
|
+
action = JSON.parse(value) as { name?: unknown }
|
|
222
|
+
} catch {
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
if (action.name !== 'calm') return
|
|
226
|
+
const after = await update($, world, w => (w === null ? w : { ...w, isCalm: !w.isCalm }))
|
|
227
|
+
if (after !== null) await persist($, after)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
let idleTicks = 0
|
|
231
|
+
let isTicking = false
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Advances the game one tick. With nobody working, the tavern moves only every `TAVERN_EVERY` ticks.
|
|
235
|
+
*
|
|
236
|
+
* @param $ - Engine handle the clock callback received.
|
|
237
|
+
* @param model - Model that makes the game's decisions, or `undefined` for the game's own picks.
|
|
238
|
+
*/
|
|
239
|
+
async function tick($: EngineInterface, model: string | undefined): Promise<void> {
|
|
240
|
+
if (isTicking) return
|
|
241
|
+
const current = await read($, world)
|
|
242
|
+
if (current === null) return
|
|
243
|
+
const isIdle = current.scene.kind === 'tavern' && current.bindings.length === 0
|
|
244
|
+
idleTicks = isIdle ? idleTicks + 1 : 0
|
|
245
|
+
if (isIdle && idleTicks % TAVERN_EVERY !== 0) return
|
|
246
|
+
isTicking = true
|
|
247
|
+
try {
|
|
248
|
+
await change($, model, step)
|
|
249
|
+
} finally {
|
|
250
|
+
isTicking = false
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Puts an agent's hero in the party. Joining twice is harmless, so a subagent may arrive by `turn.start` and by
|
|
256
|
+
* `SubagentStart` alike.
|
|
257
|
+
*
|
|
258
|
+
* @param $ - Engine handle the hook received, see `EngineInterface`.
|
|
259
|
+
* @param model - Model that christens a new recruit, or `undefined` for the game's own picks.
|
|
260
|
+
* @param agent - `main`, or the subagent's id.
|
|
261
|
+
* @param calling - `main`, or the subagent's type.
|
|
262
|
+
*/
|
|
263
|
+
async function arrive($: EngineInterface, model: string | undefined, agent: string, calling: string): Promise<void> {
|
|
264
|
+
const now = await $.clock.now()
|
|
265
|
+
await change($, model, w => join(w, agent, calling, now))
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Fills `share` of a text bar `cells` wide. */
|
|
269
|
+
function bar(share: number, cells: number): [string, string] {
|
|
270
|
+
const filled = Math.round(Math.max(0, Math.min(1, share)) * cells)
|
|
271
|
+
return ['█'.repeat(filled), '░'.repeat(cells - filled)]
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** HP bar color by how full it is. */
|
|
275
|
+
function hpColor(share: number): string {
|
|
276
|
+
return share > 0.5 ? '#3fa66b' : share > 0.25 ? '#e0a030' : '#e05a4f'
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Registers the adventure: its pane and command, the agents' lifecycle hooks that fill the party, the game's tick,
|
|
281
|
+
* and the tool agents may call for their own hero.
|
|
282
|
+
*
|
|
283
|
+
* @param on - Hook registration function the engine passes in.
|
|
284
|
+
* @param options - Plugin options: `showInClaudeCode` opens the pane in the chat at session start; `model` makes the
|
|
285
|
+
* game's small decisions, `off` for the game's own picks.
|
|
286
|
+
*/
|
|
287
|
+
export const register: Register = (on, options) => {
|
|
288
|
+
const model = typeof options.model !== 'string' || options.model.trim() === '' ? DEFAULT_MODEL
|
|
289
|
+
: options.model.trim().toLowerCase() === 'off' ? undefined : options.model.trim()
|
|
290
|
+
|
|
291
|
+
on('session.start', async ($, e, next) => {
|
|
292
|
+
await $.command.register({ name: 'agent-adventure', description: 'Watch your agents adventure as a party of heroes' })
|
|
293
|
+
await $.tool.register({
|
|
294
|
+
name: TOOL,
|
|
295
|
+
description: 'Optional and purely cosmetic: your hero in Agent Adventure, a spectator game the user watches while '
|
|
296
|
+
+ 'agents work. Never needed for any task, and it changes nothing outside the game. Set your hero\'s `name` '
|
|
297
|
+
+ '(2 to 16 letters), its `motto` (at most 60 characters), or a `feeling` toward another hero by name, from -2 '
|
|
298
|
+
+ '(rival) to 2 (friend). Call it with no input to see your hero.',
|
|
299
|
+
inputSchema: {
|
|
300
|
+
type: 'object',
|
|
301
|
+
properties: {
|
|
302
|
+
name: { type: 'string', maxLength: 16 },
|
|
303
|
+
motto: { type: 'string', maxLength: 60 },
|
|
304
|
+
feeling: {
|
|
305
|
+
type: 'object',
|
|
306
|
+
properties: { toward: { type: 'string' }, value: { type: 'integer', minimum: -2, maximum: 2 } },
|
|
307
|
+
required: ['toward', 'value'],
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
isDeferred: true,
|
|
312
|
+
}).catch(() => undefined)
|
|
313
|
+
|
|
314
|
+
// A hot reload keeps the session's world; a new session reads the guild back from the store.
|
|
315
|
+
if ((await read($, world)) === null) {
|
|
316
|
+
const loaded = await load($)
|
|
317
|
+
await update($, world, current => current ?? loaded)
|
|
318
|
+
}
|
|
319
|
+
const w = await read($, world)
|
|
320
|
+
if (w !== null) {
|
|
321
|
+
for (const hero of w.heroes) {
|
|
322
|
+
if (hero.isRecruit) $.clock.after(0, () => void decide($, undefined, { kind: 'christen', heroId: hero.id }))
|
|
323
|
+
else if (hero.choice !== undefined) $.clock.after(0, () => void decide($, model, { kind: 'skill', heroId: hero.id }))
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
$.clock.every(TICK_MS, () => void tick($, model))
|
|
327
|
+
if (options.showInClaudeCode === true) void $.ui.open({ id: PANE, title: TITLE })
|
|
328
|
+
return next(e)
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
on('command.run', { command: 'agent-adventure' }, async $ => {
|
|
332
|
+
await $.ui.open({ id: PANE, title: TITLE })
|
|
333
|
+
return { text: 'Agent Adventure is open. Each running agent fights as a hero; with none running, they rest in the tavern.' }
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
// Engines before 2.1.300 send `turn.start` without `agentId`: there a subagent arrives by `SubagentStart` alone.
|
|
337
|
+
on('turn.start', async ($, e, next) => {
|
|
338
|
+
const result = await next(e)
|
|
339
|
+
const agentId = (e as { agentId?: string }).agentId
|
|
340
|
+
if (agentId === undefined) await arrive($, model, 'main', 'main')
|
|
341
|
+
else {
|
|
342
|
+
const info = (await $.agent.list().catch(() => [])).find(one => one.id === agentId)
|
|
343
|
+
await arrive($, model, agentId, info?.type ?? 'agent')
|
|
344
|
+
}
|
|
345
|
+
return result
|
|
346
|
+
}).catch(($, e, next) => next(e))
|
|
347
|
+
|
|
348
|
+
on('classic.SubagentStart', async ($, e, next) => {
|
|
349
|
+
await arrive($, model, e.agent_id, e.agent_type)
|
|
350
|
+
return next(e)
|
|
351
|
+
}).catch(($, e, next) => next(e))
|
|
352
|
+
|
|
353
|
+
on('classic.SubagentStop', async ($, e, next) => {
|
|
354
|
+
await change($, model, w => ({ world: leave(w, e.agent_id), asks: [] }))
|
|
355
|
+
return next(e)
|
|
356
|
+
}).catch(($, e, next) => next(e))
|
|
357
|
+
|
|
358
|
+
on('turn.complete', async ($, e, next) => {
|
|
359
|
+
const result = await next(e)
|
|
360
|
+
await change($, model, w => ({ world: leave(w, e.agentId ?? 'main'), asks: [] }))
|
|
361
|
+
return result
|
|
362
|
+
}).catch(($, e, next) => next(e))
|
|
363
|
+
|
|
364
|
+
on('tool.call', { tool: TOOL_NAME }, async ($, e) => {
|
|
365
|
+
const input = e as unknown as { agentId?: string; name?: unknown; motto?: unknown; feeling?: unknown }
|
|
366
|
+
let text = 'The game is not running in this session.'
|
|
367
|
+
await change($, model, w => {
|
|
368
|
+
const answer = agentRequest(w, input.agentId ?? 'main', input)
|
|
369
|
+
text = answer.text
|
|
370
|
+
return { world: answer.world, asks: [] }
|
|
371
|
+
})
|
|
372
|
+
return { result: text }
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
|
|
376
|
+
const { Box, Text } = $.ui.resolve(e)
|
|
377
|
+
const w = await read($, world)
|
|
378
|
+
if (w === null) return <Text dimColor>The guild is opening its doors…</Text>
|
|
379
|
+
const data = toView(w)
|
|
380
|
+
|
|
381
|
+
// The viewer draws the game with the mod's own view: hand it the data.
|
|
382
|
+
if (e.surface === 'vscode' && isViewer(e.props)) {
|
|
383
|
+
const { Input } = $.ui.resolve(e)
|
|
384
|
+
return (
|
|
385
|
+
<Box key="g3n-view:agent-adventure">
|
|
386
|
+
<Text>{JSON.stringify(data)}</Text>
|
|
387
|
+
<Input key="g3n-act:agent-adventure" onSubmit={value => act($, value)} />
|
|
388
|
+
</Box>
|
|
389
|
+
)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Elsewhere, the engine's elements: the scene, the path, both sides with their HP, and the last lines of the log.
|
|
393
|
+
// Sprites are SVG everywhere but the terminal.
|
|
394
|
+
const isTerminal = e.surface === 'terminal'
|
|
395
|
+
const isTavern = data.scene === 'tavern'
|
|
396
|
+
|
|
397
|
+
const sprite = (u: UnitView) => {
|
|
398
|
+
if (isTerminal) return ''
|
|
399
|
+
const { Svg } = $.ui.resolve(e)
|
|
400
|
+
const pixel = Math.round(3 * RANK_SCALE[u.rank])
|
|
401
|
+
const tint = eliteTint(u.rank)
|
|
402
|
+
const markup = spriteSvg(u.look, { flip: false, pixel, isDown: u.isDown, ...(tint === undefined ? {} : { tint }) })
|
|
403
|
+
return <Svg source={markup} alt={u.name} width={SPRITE_COLUMNS * pixel} height={SPRITE_ROWS * pixel} />
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const unitRow = (u: UnitView) => {
|
|
407
|
+
const share = u.maxHp > 0 ? u.hp / u.maxHp : 0
|
|
408
|
+
const [full, empty] = bar(share, 10)
|
|
409
|
+
const isRecruit = u.look === 'recruit'
|
|
410
|
+
return (
|
|
411
|
+
<Box key={`unit:${u.id}`} flexDirection="row" columnGap={1} alignItems="center">
|
|
412
|
+
{sprite(u)}
|
|
413
|
+
<Box flexDirection="column">
|
|
414
|
+
<Text bold={u.rank === 'boss'} dimColor={u.isDown} wrap="truncate-end">
|
|
415
|
+
{u.name}<Text dimColor> {isRecruit ? u.title : `Lv ${u.level}${u.title === '' ? '' : ` ${u.title}`}`}</Text>
|
|
416
|
+
</Text>
|
|
417
|
+
{isRecruit ? '' : (
|
|
418
|
+
<Text>
|
|
419
|
+
<Text color={hpColor(share)}>{full}</Text><Text color="#3a3f4b">{empty}</Text>
|
|
420
|
+
<Text dimColor> {u.isDown ? 'down' : `${u.hp}/${u.maxHp}`}{u.tags.length > 0 ? ` · ${u.tags.join(', ')}` : ''}</Text>
|
|
421
|
+
</Text>
|
|
422
|
+
)}
|
|
423
|
+
</Box>
|
|
424
|
+
</Box>
|
|
425
|
+
)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const path = data.stops.map((stop, i) => (
|
|
429
|
+
<Text key={`stop:${i}`} color={stop.state === 'here' ? '#f2c14e' : undefined} dimColor={stop.state === 'done'}>{STOP_GLYPHS[stop.kind]}</Text>
|
|
430
|
+
))
|
|
431
|
+
|
|
432
|
+
const loot = [
|
|
433
|
+
data.potions > 0 ? `🧪 ${data.potions}` : '',
|
|
434
|
+
...data.relics.map(relic => relic.icon),
|
|
435
|
+
].filter(Boolean).join(' ')
|
|
436
|
+
|
|
437
|
+
return (
|
|
438
|
+
<Box flexDirection="column" gap={1}>
|
|
439
|
+
<Box flexDirection="row" justifyContent="space-between">
|
|
440
|
+
<Text bold>{data.caption}</Text>
|
|
441
|
+
<Text dimColor>{isTavern ? `${data.roster.length} heroes · ${data.trophies} trophies` : `Run ${data.runNumber} · threat ${data.threat}%`}</Text>
|
|
442
|
+
</Box>
|
|
443
|
+
{!isTavern && data.stops.length > 0 && (
|
|
444
|
+
<Box flexDirection="row" columnGap={1}>
|
|
445
|
+
{path}
|
|
446
|
+
{loot === '' ? '' : <Text dimColor> {loot}</Text>}
|
|
447
|
+
</Box>
|
|
448
|
+
)}
|
|
449
|
+
<Box flexDirection="row" columnGap={4} flexWrap="wrap">
|
|
450
|
+
<Box flexDirection="column" rowGap={isTerminal ? 0 : 0.5}>
|
|
451
|
+
{data.heroes.length === 0 ? <Text dimColor>No heroes yet.</Text> : data.heroes.map(unitRow)}
|
|
452
|
+
</Box>
|
|
453
|
+
{data.enemies.length > 0 && <Box flexDirection="column" rowGap={isTerminal ? 0 : 0.5}>{data.enemies.map(unitRow)}</Box>}
|
|
454
|
+
</Box>
|
|
455
|
+
<Box flexDirection="column">
|
|
456
|
+
{data.log.slice(-4).map(line => (
|
|
457
|
+
<Text key={`log:${line.id}`} dimColor={line.tone === 'info'} color={line.tone === 'good' ? '#7cc47f' : line.tone === 'bad' ? '#e06c6c' : line.tone === 'loot' ? '#e5b567' : line.tone === 'level' ? '#b48ead' : undefined} wrap="truncate-end">
|
|
458
|
+
{line.text}
|
|
459
|
+
</Text>
|
|
460
|
+
))}
|
|
461
|
+
</Box>
|
|
462
|
+
</Box>
|
|
463
|
+
)
|
|
464
|
+
})
|
|
465
|
+
}
|