Skip to content

Commit d1c861b

Browse files
mirror: sync from hq/multiverse
Automated by mirror-to-agentis workflow. Source commit: f7a31b0b305b95d86f86dee863ed827b8b933738
1 parent 384ecf0 commit d1c861b

6 files changed

Lines changed: 116 additions & 50 deletions

File tree

package.json

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,7 @@
1212
"test:watch": "vitest",
1313
"test:coverage": "vitest run --coverage",
1414
"test:e2e": "playwright test",
15-
"test:e2e:headed": "playwright test --headed",
16-
"build:local": "./scripts/build-local.sh",
17-
"local:run": "node packages/local-runner/bin/agentis-local.js",
18-
"local:pack-run": "cd packages/local-runner && npm pack --ignore-scripts && npx ./$(ls -t *.tgz | head -1)",
19-
"local:clean": "rm -rf packages/local-runner/bundle packages/local-runner/*.tgz"
15+
"test:e2e:headed": "playwright test --headed"
2016
},
2117
"devDependencies": {
2218
"@playwright/test": "^1.58.2",

packages/engine/src/__tests__/monster-panel-vm.test.ts

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -426,14 +426,14 @@ describe('panelTitle', () => {
426426
const monster = makeMonster({
427427
error_details: { message: 'ECONNREFUSED on port 3000', tool_name: 'Deploy' },
428428
})
429-
expect(panelTitle(monster, 'local_failure')).toBe('Deploy Connection refused')
429+
expect(panelTitle(monster, 'local_failure')).toBe('Deploy: Connection refused')
430430
})
431431

432-
it('returns "Tool failed" when only tool present', () => {
432+
it('returns "Tool failure" when only tool present', () => {
433433
const monster = makeMonster({
434434
error_details: { message: 'Something broke', tool_name: 'Bash' },
435435
})
436-
expect(panelTitle(monster, 'local_failure')).toBe('Bash failed')
436+
expect(panelTitle(monster, 'local_failure')).toBe('Bash failure')
437437
})
438438

439439
it('returns signature when only signature present', () => {
@@ -488,16 +488,18 @@ describe('generateErrorCodename', () => {
488488
expect(warning).not.toBe(critical)
489489
})
490490

491-
it('handles collisions with Roman numeral suffixes', () => {
492-
// Force two different IDs that produce the same codename by generating many
493-
// and checking if any duplicates get suffixed
491+
it('handles collisions with word-based expansion (no digits)', () => {
494492
const names: string[] = []
495493
for (let i = 0; i < 100; i++) {
496494
names.push(generateErrorCodename(`monster_collision_${i}`, 'error'))
497495
}
498-
// All names should be unique (collisions get suffixed)
496+
// All names should be unique (collisions get expanded with epithets)
499497
const unique = new Set(names)
500498
expect(unique.size).toBe(names.length)
499+
// No name should contain any digit
500+
for (const name of names) {
501+
expect(name).not.toMatch(/\d/)
502+
}
501503
})
502504

503505
it('codenames do not overlap with agent name pools', () => {
@@ -537,7 +539,7 @@ describe('deriveErrorName', () => {
537539
expect(result.short.split(' ').length).toBeGreaterThanOrEqual(2)
538540
expect(result.codename).toBe(result.short)
539541
// full should be the technical descriptor
540-
expect(result.full).toBe('Deploy Connection refused')
542+
expect(result.full).toBe('Deploy: Connection refused')
541543
expect(result.descriptor).toBe(result.full)
542544
})
543545

@@ -546,9 +548,9 @@ describe('deriveErrorName', () => {
546548
expect(result.descriptor).toBe('Type error')
547549
})
548550

549-
it('returns tool failed descriptor when no signature', () => {
551+
it('returns tool failure descriptor when no signature', () => {
550552
const result = deriveErrorName({ message: 'Something went wrong', tool_name: 'Bash' }, 'error', 'monster_test_3')
551-
expect(result.descriptor).toBe('Bash failed')
553+
expect(result.descriptor).toBe('Bash failure')
552554
})
553555

554556
it('extracts excerpt from raw message when no tool/signature', () => {

packages/engine/src/components/monster-panel-vm.ts

Lines changed: 77 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -99,36 +99,53 @@ function fnv1a(str: string): number {
9999

100100
/**
101101
* Severity-aware codename pools.
102-
* Each pool has [prefix[], core[]] tuples. The codename is formed as
102+
* Each pool has prefixes[], cores[], and epithets[]. The codename is formed as
103103
* "Prefix Core" by picking one from each using hash(monsterId).
104+
* On collision, a third word (epithet) is appended instead of a number.
104105
*
105106
* These pools are intentionally disjoint from agent name pools
106107
* (Nova, Forge, Atlas, Alpha, Beta, etc.).
107108
*/
108-
const CODENAME_POOLS: Record<string, { prefixes: readonly string[]; cores: readonly string[] }> = {
109+
const CODENAME_POOLS: Record<string, {
110+
prefixes: readonly string[]
111+
cores: readonly string[]
112+
epithets: readonly string[]
113+
}> = {
109114
warning: {
110-
prefixes: ['Grey', 'Faint', 'Pale', 'Dusk', 'Mist', 'Haze', 'Thin', 'Dim'],
111-
cores: ['Wisp', 'Moth', 'Shade', 'Flicker', 'Murmur', 'Ember', 'Glint', 'Ripple'],
115+
prefixes: ['Grey', 'Faint', 'Pale', 'Dusk', 'Mist', 'Haze', 'Thin', 'Dim',
116+
'Wan', 'Soft', 'Low', 'Still', 'Cold', 'Dry', 'Slow', 'Bare'],
117+
cores: ['Wisp', 'Moth', 'Shade', 'Flicker', 'Murmur', 'Ember', 'Glint', 'Ripple',
118+
'Sigh', 'Drift', 'Hush', 'Trace', 'Echo', 'Veil', 'Frost', 'Spark'],
119+
epithets: ['Ashen', 'Hollow', 'Silent', 'Fading', 'Distant', 'Waning', 'Fleeting', 'Sunken'],
112120
},
113121
error: {
114-
prefixes: ['Iron', 'Rust', 'Bitter', 'Bleak', 'Ashen', 'Thorn', 'Bane', 'Grim'],
115-
cores: ['Fang', 'Specter', 'Wraith', 'Vortex', 'Rend', 'Scourge', 'Blight', 'Fracture'],
122+
prefixes: ['Iron', 'Rust', 'Bitter', 'Bleak', 'Ashen', 'Thorn', 'Bane', 'Grim',
123+
'Dusk', 'Sable', 'Keen', 'Stark', 'Nether', 'Riven', 'Wicked', 'Fell'],
124+
cores: ['Fang', 'Specter', 'Wraith', 'Vortex', 'Rend', 'Scourge', 'Blight', 'Fracture',
125+
'Maw', 'Claw', 'Shard', 'Surge', 'Rot', 'Hex', 'Torment', 'Ruin'],
126+
epithets: ['Risen', 'Unbound', 'Cursed', 'Scarred', 'Twisted', 'Ragged', 'Barbed', 'Hollow'],
116127
},
117128
critical: {
118-
prefixes: ['Dire', 'Dread', 'Crimson', 'Void', 'Fell', 'Wrath', 'Doom', 'Black'],
119-
cores: ['Hydra', 'Leviathan', 'Titan', 'Colossus', 'Behemoth', 'Reaper', 'Tyrant', 'Inferno'],
129+
prefixes: ['Dire', 'Dread', 'Crimson', 'Void', 'Fell', 'Wrath', 'Doom', 'Black',
130+
'Blood', 'Shadow', 'Storm', 'Death', 'Night', 'Dark', 'Chaos', 'Bone'],
131+
cores: ['Hydra', 'Leviathan', 'Titan', 'Colossus', 'Behemoth', 'Reaper', 'Tyrant', 'Inferno',
132+
'Warden', 'Phantom', 'Sovereign', 'Herald', 'Juggernaut', 'Harbinger', 'Sentinel', 'Monolith'],
133+
epithets: ['Eternal', 'Ancient', 'Unchained', 'Supreme', 'Undying', 'Merciless', 'Relentless', 'Forsaken'],
120134
},
121135
outage: {
122-
prefixes: ['Abyssal', 'Cataclysm', 'Oblivion', 'Ruin', 'Eclipse', 'Null', 'Extinction', 'Omega'],
123-
cores: ['Drake', 'Kraken', 'Annihilator', 'Maelstrom', 'Cataclysm', 'Worldbreaker', 'Tempest', 'Ravager'],
136+
prefixes: ['Abyssal', 'Oblivion', 'Ruin', 'Eclipse', 'Null', 'Extinction', 'Omega', 'End',
137+
'Nether', 'Hollow', 'Sundered', 'Shattered', 'Forsaken', 'Eldritch', 'Prime', 'Apex'],
138+
cores: ['Drake', 'Kraken', 'Annihilator', 'Maelstrom', 'Worldbreaker', 'Tempest', 'Ravager', 'Scourge',
139+
'Devourer', 'Obliterator', 'Cataclysm', 'Dominion', 'Nexus', 'Terminus', 'Sovereign', 'Abyss'],
140+
epithets: ['Ascended', 'Absolute', 'Infinite', 'Ultimate', 'Primordial', 'Boundless', 'Transcendent', 'Final'],
124141
},
125142
}
126143

127144
/** Default pool used when severity is unknown */
128145
const DEFAULT_POOL = CODENAME_POOLS['error']!
129146

130147
/** Session-level codename deduplication registry */
131-
const usedCodenames = new Map<string, number>()
148+
const usedCodenames = new Set<string>()
132149

133150
/** Reset the dedup registry (call on scenario reload) */
134151
export function resetErrorCodenames(): void {
@@ -139,12 +156,15 @@ export function resetErrorCodenames(): void {
139156
* Generate a deterministic, human-friendly codename for an error/monster.
140157
*
141158
* Uses severity-aware word pools and FNV-1a hash of the monster ID for
142-
* deterministic selection. Handles collisions within a session by appending
143-
* a Roman numeral suffix (II, III, IV...).
159+
* deterministic selection. On collision, appends a third word (epithet)
160+
* from a dedicated pool — never digits or Roman numerals.
161+
*
162+
* With 16 prefixes x 16 cores = 256 base combinations per severity,
163+
* plus 8 epithets for collision expansion = 2048 total unique names.
144164
*
145165
* @param monsterId - Unique monster ID (required for determinism)
146166
* @param severity - Monster severity level (selects the word pool)
147-
* @returns A codename like "Iron Specter" or "Dire Hydra II"
167+
* @returns A codename like "Iron Specter" or "Dire Hydra Eternal"
148168
*/
149169
export function generateErrorCodename(monsterId: string, severity: string): string {
150170
const pool = CODENAME_POOLS[severity] ?? DEFAULT_POOL
@@ -155,16 +175,49 @@ export function generateErrorCodename(monsterId: string, severity: string): stri
155175
const core = pool.cores[(hash >>> 16) % pool.cores.length]!
156176
const baseName = `${prefix} ${core}`
157177

158-
// Dedup within session: if this exact codename was already issued, suffix it
159-
const count = usedCodenames.get(baseName) ?? 0
160-
usedCodenames.set(baseName, count + 1)
178+
// If base name is unique in this session, use it directly
179+
if (!usedCodenames.has(baseName)) {
180+
usedCodenames.add(baseName)
181+
return baseName
182+
}
183+
184+
// Collision: append an epithet chosen by a different hash mix
185+
const epithetHash = fnv1a(monsterId + ':epithet')
186+
const epithet = pool.epithets[epithetHash % pool.epithets.length]!
187+
const expanded = `${baseName} ${epithet}`
188+
189+
if (!usedCodenames.has(expanded)) {
190+
usedCodenames.add(expanded)
191+
return expanded
192+
}
161193

162-
if (count === 0) return baseName
194+
// Extremely rare double-collision: try remaining epithets deterministically
195+
for (let i = 0; i < pool.epithets.length; i++) {
196+
const candidate = `${baseName} ${pool.epithets[i]!}`
197+
if (!usedCodenames.has(candidate)) {
198+
usedCodenames.add(candidate)
199+
return candidate
200+
}
201+
}
202+
203+
// Exhausted all epithets: use a unique word-based suffix from the hash
204+
const fallback = `${baseName} ${hashWord(monsterId)}`
205+
usedCodenames.add(fallback)
206+
return fallback
207+
}
163208

164-
// Roman numeral suffixes for collisions (deterministic, readable)
165-
const ROMAN = ['II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X']
166-
const suffix = count <= ROMAN.length ? ROMAN[count - 1]! : `${count + 1}`
167-
return `${baseName} ${suffix}`
209+
/** Generate a pronounceable word-like suffix from a hash (no digits) */
210+
function hashWord(id: string): string {
211+
const h = fnv1a(id + ':word')
212+
const consonants = 'bcdfghjklmnprstvwz'
213+
const vowels = 'aeiou'
214+
// Build a 4-letter pronounceable token: CVCV
215+
const c1 = consonants[h % consonants.length]!
216+
const v1 = vowels[(h >>> 4) % vowels.length]!
217+
const c2 = consonants[(h >>> 8) % consonants.length]!
218+
const v2 = vowels[(h >>> 12) % vowels.length]!
219+
const word = c1 + v1 + c2 + v2
220+
return word.charAt(0).toUpperCase() + word.slice(1)
168221
}
169222

170223
// ---------------------------------------------------------------------------
@@ -220,9 +273,9 @@ function deriveDescriptor(
220273
const tool = errorDetails?.tool_name
221274
const sig = extractSignature(errorDetails?.message)
222275

223-
if (tool && sig) return `${tool} ${sig}`
276+
if (tool && sig) return tool + ': ' + sig
224277
if (sig) return sig
225-
if (tool) return `${tool} failed`
278+
if (tool) return tool + ' failure'
226279

227280
const excerpt = extractExcerpt(errorDetails?.message)
228281
if (excerpt) return excerpt

packages/engine/src/engine/entity-sprite-map.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -83,15 +83,16 @@ const DEFAULT_CONFIG: EntitySpriteConfig = {
8383
}
8484

8585
// ---------------------------------------------------------------------------
86-
// String hash (djb2) — deterministic, fast, collision-resistant enough
86+
// FNV-1a 32-bit hash — better avalanche than djb2 for similar agent IDs
8787
// ---------------------------------------------------------------------------
8888

89-
function djb2Hash(str: string): number {
90-
let hash = 5381
89+
function fnv1aHash(str: string): number {
90+
let hash = 0x811c9dc5
9191
for (let i = 0; i < str.length; i++) {
92-
hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0
92+
hash ^= str.charCodeAt(i)
93+
hash = Math.imul(hash, 0x01000193)
9394
}
94-
return Math.abs(hash)
95+
return hash >>> 0
9596
}
9697

9798
// ---------------------------------------------------------------------------
@@ -244,8 +245,8 @@ export class EntitySpriteMap {
244245
return agentConfig.base
245246
}
246247

247-
// Deterministic variant: hash the agent ID
248-
const idx = djb2Hash(agentId) % variants.length
248+
// Deterministic variant: hash the agent ID with FNV-1a for better spread
249+
const idx = fnv1aHash(agentId) % variants.length
249250
const key = variants[idx] ?? agentConfig.base
250251
this.agentCache.set(agentId, key)
251252
return key
@@ -308,7 +309,7 @@ export class EntitySpriteMap {
308309
if (entry.length === 0) return FALLBACK_EVENT_KEY
309310
if (!eventId || entry.length === 1) return entry[0] ?? FALLBACK_EVENT_KEY
310311

311-
const idx = djb2Hash(eventId) % entry.length
312+
const idx = fnv1aHash(eventId) % entry.length
312313
return entry[idx] ?? entry[0] ?? FALLBACK_EVENT_KEY
313314
}
314315

packages/engine/src/stores/eventStore.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -653,13 +653,24 @@ function dispatchEvent(event: AgentEvent): void {
653653
const occupied = collectOccupiedPositions(agentMap, monsters.monsters)
654654
const finalPosition = findNonOverlappingPosition(position, occupied)
655655

656+
// Deterministically assign agent type from pool based on ID
657+
// so different subagents get visually distinct sprites
658+
const AGENT_TYPES = ['claude', 'cursor', 'codex', 'gemini', 'openclaw'] as const
659+
const spawnHash = ((): number => {
660+
let h = 5381
661+
const s = newAgentId + ':type'
662+
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0
663+
return Math.abs(h)
664+
})()
665+
const agentType = AGENT_TYPES[spawnHash % AGENT_TYPES.length]!
666+
656667
agentMap.set(newAgentId, {
657668
id: newAgentId,
658669
universe_id: 'universe_imported',
659670
name: `Agent ${newAgentId.slice(-6)}`,
660-
type: 'claude',
671+
type: agentType,
661672
sprite_config: {
662-
sprite_sheet: 'agents/claude',
673+
sprite_sheet: `agents/${agentType}`,
663674
idle_animation: 'idle',
664675
walk_animation: 'walk',
665676
combat_animation: 'combat',

packages/world-model/src/adapter.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -507,7 +507,10 @@ function toAgent(actor: ActorRef, snapshot: WorldModelSnapshot, index: number):
507507
const x = workLoc ? workLoc.x + jitterX : 10
508508
const y = workLoc ? workLoc.y + jitterY : 10
509509

510-
const agentType = 'claude'
510+
// Deterministically assign agent type from pool based on actor ID
511+
// so different agents get visually distinct sprites
512+
const AGENT_TYPES = ['claude', 'cursor', 'codex', 'gemini', 'openclaw'] as const
513+
const agentType = AGENT_TYPES[djb2(actor.id + ':type') % AGENT_TYPES.length]!
511514

512515
// Collect unique tools used across all work units for this agent
513516
const usedToolNames = collectUsedTools(actor.id, snapshot)

0 commit comments

Comments
 (0)