@vsuryav/agent-sim 0.1.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/README.md +25 -0
- package/bin/agent-sim.js +25 -0
- package/package.json +72 -0
- package/src/app-paths.ts +29 -0
- package/src/app-sync.test.ts +75 -0
- package/src/app-sync.ts +110 -0
- package/src/cli.ts +129 -0
- package/src/collector/claude-code.test.ts +102 -0
- package/src/collector/claude-code.ts +133 -0
- package/src/collector/codex-cli.test.ts +116 -0
- package/src/collector/codex-cli.ts +149 -0
- package/src/collector/db.test.ts +59 -0
- package/src/collector/db.ts +125 -0
- package/src/collector/names.test.ts +21 -0
- package/src/collector/names.ts +28 -0
- package/src/collector/personality.test.ts +40 -0
- package/src/collector/personality.ts +46 -0
- package/src/collector/remote-sync.test.ts +31 -0
- package/src/collector/remote-sync.ts +171 -0
- package/src/collector/sync.test.ts +67 -0
- package/src/collector/sync.ts +148 -0
- package/src/collector/types.ts +1 -0
- package/src/engine/bootstrap/state.ts +3 -0
- package/src/engine/buddy/CompanionSprite.tsx +371 -0
- package/src/engine/buddy/companion.ts +133 -0
- package/src/engine/buddy/prompt.ts +36 -0
- package/src/engine/buddy/sprites.ts +514 -0
- package/src/engine/buddy/types.ts +148 -0
- package/src/engine/buddy/useBuddyNotification.tsx +98 -0
- package/src/engine/ink/Ansi.tsx +292 -0
- package/src/engine/ink/bidi.ts +139 -0
- package/src/engine/ink/clearTerminal.ts +74 -0
- package/src/engine/ink/colorize.ts +231 -0
- package/src/engine/ink/components/AlternateScreen.tsx +80 -0
- package/src/engine/ink/components/App.tsx +658 -0
- package/src/engine/ink/components/AppContext.ts +21 -0
- package/src/engine/ink/components/Box.tsx +214 -0
- package/src/engine/ink/components/Button.tsx +192 -0
- package/src/engine/ink/components/ClockContext.tsx +112 -0
- package/src/engine/ink/components/CursorDeclarationContext.ts +32 -0
- package/src/engine/ink/components/ErrorOverview.tsx +109 -0
- package/src/engine/ink/components/Link.tsx +42 -0
- package/src/engine/ink/components/Newline.tsx +39 -0
- package/src/engine/ink/components/NoSelect.tsx +68 -0
- package/src/engine/ink/components/RawAnsi.tsx +57 -0
- package/src/engine/ink/components/ScrollBox.tsx +237 -0
- package/src/engine/ink/components/Spacer.tsx +20 -0
- package/src/engine/ink/components/StdinContext.ts +49 -0
- package/src/engine/ink/components/TerminalFocusContext.tsx +52 -0
- package/src/engine/ink/components/TerminalSizeContext.tsx +7 -0
- package/src/engine/ink/components/Text.tsx +254 -0
- package/src/engine/ink/constants.ts +2 -0
- package/src/engine/ink/dom.ts +484 -0
- package/src/engine/ink/events/click-event.ts +38 -0
- package/src/engine/ink/events/dispatcher.ts +233 -0
- package/src/engine/ink/events/emitter.ts +39 -0
- package/src/engine/ink/events/event-handlers.ts +73 -0
- package/src/engine/ink/events/event.ts +11 -0
- package/src/engine/ink/events/focus-event.ts +21 -0
- package/src/engine/ink/events/input-event.ts +205 -0
- package/src/engine/ink/events/keyboard-event.ts +51 -0
- package/src/engine/ink/events/terminal-event.ts +107 -0
- package/src/engine/ink/events/terminal-focus-event.ts +19 -0
- package/src/engine/ink/focus.ts +181 -0
- package/src/engine/ink/frame.ts +124 -0
- package/src/engine/ink/get-max-width.ts +27 -0
- package/src/engine/ink/global.d.ts +18 -0
- package/src/engine/ink/hit-test.ts +130 -0
- package/src/engine/ink/hooks/use-animation-frame.ts +57 -0
- package/src/engine/ink/hooks/use-app.ts +8 -0
- package/src/engine/ink/hooks/use-declared-cursor.ts +73 -0
- package/src/engine/ink/hooks/use-input.ts +92 -0
- package/src/engine/ink/hooks/use-interval.ts +67 -0
- package/src/engine/ink/hooks/use-search-highlight.ts +53 -0
- package/src/engine/ink/hooks/use-selection.ts +104 -0
- package/src/engine/ink/hooks/use-stdin.ts +8 -0
- package/src/engine/ink/hooks/use-tab-status.ts +72 -0
- package/src/engine/ink/hooks/use-terminal-focus.ts +16 -0
- package/src/engine/ink/hooks/use-terminal-title.ts +31 -0
- package/src/engine/ink/hooks/use-terminal-viewport.ts +96 -0
- package/src/engine/ink/ink.tsx +1723 -0
- package/src/engine/ink/instances.ts +10 -0
- package/src/engine/ink/layout/engine.ts +6 -0
- package/src/engine/ink/layout/geometry.ts +97 -0
- package/src/engine/ink/layout/node.ts +152 -0
- package/src/engine/ink/layout/yoga.ts +308 -0
- package/src/engine/ink/line-width-cache.ts +24 -0
- package/src/engine/ink/log-update.ts +773 -0
- package/src/engine/ink/measure-element.ts +23 -0
- package/src/engine/ink/measure-text.ts +47 -0
- package/src/engine/ink/node-cache.ts +54 -0
- package/src/engine/ink/optimizer.ts +93 -0
- package/src/engine/ink/output.ts +797 -0
- package/src/engine/ink/parse-keypress.ts +801 -0
- package/src/engine/ink/reconciler.ts +512 -0
- package/src/engine/ink/render-border.ts +231 -0
- package/src/engine/ink/render-node-to-output.ts +1462 -0
- package/src/engine/ink/render-to-screen.ts +231 -0
- package/src/engine/ink/renderer.ts +178 -0
- package/src/engine/ink/root.ts +184 -0
- package/src/engine/ink/screen.ts +1486 -0
- package/src/engine/ink/searchHighlight.ts +93 -0
- package/src/engine/ink/selection.ts +917 -0
- package/src/engine/ink/squash-text-nodes.ts +92 -0
- package/src/engine/ink/stringWidth.ts +222 -0
- package/src/engine/ink/styles.ts +771 -0
- package/src/engine/ink/supports-hyperlinks.ts +57 -0
- package/src/engine/ink/tabstops.ts +46 -0
- package/src/engine/ink/terminal-focus-state.ts +47 -0
- package/src/engine/ink/terminal-querier.ts +212 -0
- package/src/engine/ink/terminal.ts +248 -0
- package/src/engine/ink/termio/ansi.ts +75 -0
- package/src/engine/ink/termio/csi.ts +319 -0
- package/src/engine/ink/termio/dec.ts +60 -0
- package/src/engine/ink/termio/esc.ts +67 -0
- package/src/engine/ink/termio/osc.ts +493 -0
- package/src/engine/ink/termio/parser.ts +394 -0
- package/src/engine/ink/termio/sgr.ts +308 -0
- package/src/engine/ink/termio/tokenize.ts +319 -0
- package/src/engine/ink/termio/types.ts +236 -0
- package/src/engine/ink/useTerminalNotification.ts +126 -0
- package/src/engine/ink/warn.ts +9 -0
- package/src/engine/ink/widest-line.ts +19 -0
- package/src/engine/ink/wrap-text.ts +74 -0
- package/src/engine/ink/wrapAnsi.ts +20 -0
- package/src/engine/native-ts/yoga-layout/enums.ts +134 -0
- package/src/engine/native-ts/yoga-layout/index.ts +2578 -0
- package/src/engine/stubs/bootstrap-state.ts +4 -0
- package/src/engine/stubs/debug.ts +6 -0
- package/src/engine/stubs/log.ts +4 -0
- package/src/engine/utils/debug.ts +5 -0
- package/src/engine/utils/earlyInput.ts +4 -0
- package/src/engine/utils/env.ts +15 -0
- package/src/engine/utils/envUtils.ts +4 -0
- package/src/engine/utils/execFileNoThrow.ts +24 -0
- package/src/engine/utils/fullscreen.ts +4 -0
- package/src/engine/utils/intl.ts +9 -0
- package/src/engine/utils/log.ts +3 -0
- package/src/engine/utils/semver.ts +13 -0
- package/src/engine/utils/sliceAnsi.ts +10 -0
- package/src/engine/utils/theme.ts +17 -0
- package/src/game/App.tsx +141 -0
- package/src/game/agents/behavior.ts +249 -0
- package/src/game/agents/speech.ts +57 -0
- package/src/game/canvas.ts +98 -0
- package/src/game/launch.ts +36 -0
- package/src/game/ship/ShipView.tsx +145 -0
- package/src/game/ship/ship-map.ts +172 -0
- package/src/game/ui/AgentBio.tsx +72 -0
- package/src/game/ui/HUD.tsx +63 -0
- package/src/game/ui/StatusBar.tsx +49 -0
- package/src/game/useKeyboard.ts +62 -0
- package/src/main.tsx +22 -0
- package/src/run-interactive.ts +74 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Terminal environment detection stub
|
|
2
|
+
export const env = {
|
|
3
|
+
terminal: detectTerminal(),
|
|
4
|
+
isTTY: process.stdout.isTTY ?? false,
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
function detectTerminal(): string {
|
|
8
|
+
const term = process.env.TERM_PROGRAM?.toLowerCase() ?? '';
|
|
9
|
+
if (term.includes('kitty')) return 'kitty';
|
|
10
|
+
if (term.includes('iterm')) return 'iterm2';
|
|
11
|
+
if (term.includes('wezterm')) return 'wezterm';
|
|
12
|
+
if (term.includes('alacritty')) return 'alacritty';
|
|
13
|
+
if (term.includes('vscode')) return 'vscode';
|
|
14
|
+
return term || 'unknown';
|
|
15
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Stub for exec utility used in clipboard operations
|
|
2
|
+
import { execFile as execFileCb } from 'child_process';
|
|
3
|
+
|
|
4
|
+
interface ExecResult {
|
|
5
|
+
code: number | null;
|
|
6
|
+
stdout: string;
|
|
7
|
+
stderr: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function execFileNoThrow(
|
|
11
|
+
cmd: string,
|
|
12
|
+
args: string[],
|
|
13
|
+
opts: { input?: string; timeout?: number } = {},
|
|
14
|
+
): Promise<ExecResult> {
|
|
15
|
+
return new Promise(resolve => {
|
|
16
|
+
const child = execFileCb(cmd, args, { timeout: opts.timeout ?? 5000 }, (err, stdout, stderr) => {
|
|
17
|
+
resolve({ code: err ? (err as any).code ?? 1 : 0, stdout: stdout ?? '', stderr: stderr ?? '' });
|
|
18
|
+
});
|
|
19
|
+
if (opts.input && child.stdin) {
|
|
20
|
+
child.stdin.write(opts.input);
|
|
21
|
+
child.stdin.end();
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Minimal semver comparison stub
|
|
2
|
+
export function gte(version: string, range: string): boolean {
|
|
3
|
+
const parse = (v: string) => v.split('.').map(Number);
|
|
4
|
+
const a = parse(version);
|
|
5
|
+
const b = parse(range);
|
|
6
|
+
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
7
|
+
const av = a[i] ?? 0;
|
|
8
|
+
const bv = b[i] ?? 0;
|
|
9
|
+
if (av > bv) return true;
|
|
10
|
+
if (av < bv) return false;
|
|
11
|
+
}
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// ANSI-aware string slicing
|
|
2
|
+
import stripAnsi from 'strip-ansi';
|
|
3
|
+
|
|
4
|
+
export default function sliceAnsi(text: string, start: number, end?: number): string {
|
|
5
|
+
const stripped = stripAnsi(text);
|
|
6
|
+
const sliced = stripped.slice(start, end);
|
|
7
|
+
// Simple version: just return the stripped slice
|
|
8
|
+
// Full version would preserve ANSI codes, but this works for our use case
|
|
9
|
+
return sliced;
|
|
10
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Minimal theme stub for buddy sprite compatibility
|
|
2
|
+
export interface Theme {
|
|
3
|
+
inactive: string;
|
|
4
|
+
success: string;
|
|
5
|
+
permission: string;
|
|
6
|
+
autoAccept: string;
|
|
7
|
+
warning: string;
|
|
8
|
+
[key: string]: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const defaultTheme: Theme = {
|
|
12
|
+
inactive: '#666666',
|
|
13
|
+
success: '#44cc44',
|
|
14
|
+
permission: '#4488ff',
|
|
15
|
+
autoAccept: '#ff44aa',
|
|
16
|
+
warning: '#ffcc44',
|
|
17
|
+
};
|
package/src/game/App.tsx
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Main game shell. Wraps everything in AlternateScreen.
|
|
2
|
+
// Manages: view state, agent selection, simulation loop, keyboard input.
|
|
3
|
+
|
|
4
|
+
import React, { useContext, useEffect, useMemo, useRef, useState } from 'react';
|
|
5
|
+
import { AlternateScreen } from '../engine/ink/components/AlternateScreen.js';
|
|
6
|
+
import Box from '../engine/ink/components/Box.js';
|
|
7
|
+
import Text from '../engine/ink/components/Text.js';
|
|
8
|
+
import { TerminalSizeContext } from '../engine/ink/components/TerminalSizeContext.js';
|
|
9
|
+
import { generateShip } from './ship/ship-map.js';
|
|
10
|
+
import { ShipView } from './ship/ShipView.js';
|
|
11
|
+
import { createAgentSim, tickAgent, type AgentSim } from './agents/behavior.js';
|
|
12
|
+
import { HUD } from './ui/HUD.js';
|
|
13
|
+
import { StatusBar } from './ui/StatusBar.js';
|
|
14
|
+
import { AgentBio } from './ui/AgentBio.js';
|
|
15
|
+
import { useKeyboard } from './useKeyboard.js';
|
|
16
|
+
|
|
17
|
+
type View = 'ship' | 'bio';
|
|
18
|
+
|
|
19
|
+
interface AgentRow {
|
|
20
|
+
id: string;
|
|
21
|
+
name: string;
|
|
22
|
+
clan: string;
|
|
23
|
+
maturity: string;
|
|
24
|
+
trait_builder: number;
|
|
25
|
+
trait_creator: number;
|
|
26
|
+
trait_explorer: number;
|
|
27
|
+
trait_leader: number;
|
|
28
|
+
trait_thinker: number;
|
|
29
|
+
trait_scholar: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface GameProps {
|
|
33
|
+
agents: AgentRow[];
|
|
34
|
+
clans: Array<{ name: string; color: string }>;
|
|
35
|
+
phase: string;
|
|
36
|
+
day: number;
|
|
37
|
+
seed: number;
|
|
38
|
+
onQuit: () => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function GameApp({ agents: agentRows, clans, phase, day, seed, onQuit }: GameProps): React.ReactNode {
|
|
42
|
+
const size = useContext(TerminalSizeContext);
|
|
43
|
+
const cols = size?.columns ?? 80;
|
|
44
|
+
const rows = size?.rows ?? 24;
|
|
45
|
+
|
|
46
|
+
const ship = useMemo(() => generateShip(agentRows.length), [agentRows.length]);
|
|
47
|
+
|
|
48
|
+
// Agent sims (mutable)
|
|
49
|
+
const agentSimsRef = useRef<AgentSim[]>([]);
|
|
50
|
+
if (agentSimsRef.current.length === 0) {
|
|
51
|
+
const clanNames = [...new Set(agentRows.map(a => a.clan))];
|
|
52
|
+
agentSimsRef.current = agentRows.map((a, i) => {
|
|
53
|
+
const clanIdx = clanNames.indexOf(a.clan);
|
|
54
|
+
const spawn = ship.spawnPoints[i % ship.spawnPoints.length]!;
|
|
55
|
+
return createAgentSim(a, spawn.x, spawn.y, clanIdx, seed);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const [view, setView] = useState<View>('ship');
|
|
60
|
+
const [selectedIdx, setSelectedIdx] = useState(0);
|
|
61
|
+
const [paused, setPaused] = useState(false);
|
|
62
|
+
|
|
63
|
+
const selectedAgent = agentSimsRef.current[selectedIdx % agentSimsRef.current.length] ?? null;
|
|
64
|
+
|
|
65
|
+
// Simulation loop (10Hz)
|
|
66
|
+
const pausedRef = useRef(paused);
|
|
67
|
+
pausedRef.current = paused;
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
const interval = setInterval(() => {
|
|
70
|
+
if (pausedRef.current) return;
|
|
71
|
+
const agents = agentSimsRef.current;
|
|
72
|
+
for (const agent of agents) {
|
|
73
|
+
tickAgent(agent, ship, agents);
|
|
74
|
+
}
|
|
75
|
+
}, 100);
|
|
76
|
+
return () => clearInterval(interval);
|
|
77
|
+
}, [ship]);
|
|
78
|
+
|
|
79
|
+
// Keyboard
|
|
80
|
+
useKeyboard((event) => {
|
|
81
|
+
if (event.key === 'q' || (event.ctrl && event.key === 'c')) {
|
|
82
|
+
onQuit();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (event.tab) {
|
|
86
|
+
setSelectedIdx(i => (i + 1) % Math.max(1, agentSimsRef.current.length));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (event.space) {
|
|
90
|
+
setPaused(p => !p);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (event.key === 'b') {
|
|
94
|
+
setView(v => v === 'bio' ? 'ship' : 'bio');
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (event.escape) {
|
|
98
|
+
setView('ship');
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const viewHeight = rows - 2;
|
|
104
|
+
const viewWidth = cols;
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<AlternateScreen mouseTracking={false}>
|
|
108
|
+
<Box flexDirection="column" width="100%" height={rows}>
|
|
109
|
+
<HUD
|
|
110
|
+
phase={phase}
|
|
111
|
+
agentCount={agentSimsRef.current.length}
|
|
112
|
+
clanCount={clans.length}
|
|
113
|
+
day={day}
|
|
114
|
+
selectedAgent={selectedAgent}
|
|
115
|
+
paused={paused}
|
|
116
|
+
/>
|
|
117
|
+
|
|
118
|
+
{view === 'ship' ? (
|
|
119
|
+
<ShipView
|
|
120
|
+
ship={ship}
|
|
121
|
+
agents={agentSimsRef.current}
|
|
122
|
+
viewWidth={viewWidth}
|
|
123
|
+
viewHeight={viewHeight}
|
|
124
|
+
seed={seed}
|
|
125
|
+
selectedAgentId={selectedAgent?.id ?? null}
|
|
126
|
+
paused={paused}
|
|
127
|
+
/>
|
|
128
|
+
) : view === 'bio' && selectedAgent ? (
|
|
129
|
+
<AgentBio
|
|
130
|
+
agent={selectedAgent}
|
|
131
|
+
onClose={() => setView('ship')}
|
|
132
|
+
/>
|
|
133
|
+
) : (
|
|
134
|
+
<Box flexGrow={1} />
|
|
135
|
+
)}
|
|
136
|
+
|
|
137
|
+
<StatusBar view={view} />
|
|
138
|
+
</Box>
|
|
139
|
+
</AlternateScreen>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// Agent autonomous behavior state machine.
|
|
2
|
+
// Each agent has a state, energy, and target position.
|
|
3
|
+
// Behavior selection is weighted by personality traits from real coding sessions.
|
|
4
|
+
|
|
5
|
+
import { createSeededRandom, hashString } from '@vsuryav/agent-sim-core';
|
|
6
|
+
import type { ShipLayout } from '../ship/ship-map.js';
|
|
7
|
+
|
|
8
|
+
export type AgentState = 'idle' | 'walking' | 'sleeping' | 'chatting' | 'working' | 'exploring';
|
|
9
|
+
|
|
10
|
+
export interface AgentSim {
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
clan: string;
|
|
14
|
+
maturity: string;
|
|
15
|
+
// Position (world coordinates)
|
|
16
|
+
x: number;
|
|
17
|
+
y: number;
|
|
18
|
+
// Smooth animation position (lerps toward x,y)
|
|
19
|
+
renderX: number;
|
|
20
|
+
renderY: number;
|
|
21
|
+
// State machine
|
|
22
|
+
state: AgentState;
|
|
23
|
+
stateTimer: number; // ticks remaining in current state
|
|
24
|
+
targetX: number;
|
|
25
|
+
targetY: number;
|
|
26
|
+
// Stats
|
|
27
|
+
energy: number; // 0-100
|
|
28
|
+
// Personality (from real data)
|
|
29
|
+
traits: {
|
|
30
|
+
builder: number;
|
|
31
|
+
creator: number;
|
|
32
|
+
explorer: number;
|
|
33
|
+
leader: number;
|
|
34
|
+
thinker: number;
|
|
35
|
+
scholar: number;
|
|
36
|
+
};
|
|
37
|
+
// Visual
|
|
38
|
+
color: string;
|
|
39
|
+
sprite: string; // 1-2 char display
|
|
40
|
+
// Speech
|
|
41
|
+
speech: string | null;
|
|
42
|
+
speechTimer: number;
|
|
43
|
+
// Animation
|
|
44
|
+
facing: 'left' | 'right';
|
|
45
|
+
animFrame: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Clan colors palette
|
|
49
|
+
const CLAN_COLORS = [
|
|
50
|
+
'#ff6b6b', '#ffd93d', '#6bcb77', '#4d96ff',
|
|
51
|
+
'#c084fc', '#fb7185', '#38bdf8', '#a3e635',
|
|
52
|
+
'#f472b6', '#facc15', '#34d399', '#60a5fa',
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
// Maturity sprites
|
|
56
|
+
const MATURITY_SPRITES: Record<string, string> = {
|
|
57
|
+
spark: '·',
|
|
58
|
+
newborn: 'o',
|
|
59
|
+
worker: '☺',
|
|
60
|
+
elder: '♦',
|
|
61
|
+
legend: '★',
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export function createAgentSim(
|
|
65
|
+
agent: {
|
|
66
|
+
id: string; name: string; clan: string; maturity: string;
|
|
67
|
+
trait_builder: number; trait_creator: number; trait_explorer: number;
|
|
68
|
+
trait_leader: number; trait_thinker: number; trait_scholar: number;
|
|
69
|
+
},
|
|
70
|
+
spawnX: number,
|
|
71
|
+
spawnY: number,
|
|
72
|
+
clanIndex: number,
|
|
73
|
+
worldSeed: number,
|
|
74
|
+
): AgentSim {
|
|
75
|
+
const rand = createSeededRandom(hashString(`${worldSeed}:${agent.id}`));
|
|
76
|
+
return {
|
|
77
|
+
id: agent.id,
|
|
78
|
+
name: agent.name,
|
|
79
|
+
clan: agent.clan,
|
|
80
|
+
maturity: agent.maturity,
|
|
81
|
+
x: spawnX,
|
|
82
|
+
y: spawnY,
|
|
83
|
+
renderX: spawnX,
|
|
84
|
+
renderY: spawnY,
|
|
85
|
+
state: 'idle',
|
|
86
|
+
stateTimer: 20 + Math.floor(rand() * 40),
|
|
87
|
+
targetX: spawnX,
|
|
88
|
+
targetY: spawnY,
|
|
89
|
+
energy: 80 + Math.floor(rand() * 20),
|
|
90
|
+
traits: {
|
|
91
|
+
builder: agent.trait_builder,
|
|
92
|
+
creator: agent.trait_creator,
|
|
93
|
+
explorer: agent.trait_explorer,
|
|
94
|
+
leader: agent.trait_leader,
|
|
95
|
+
thinker: agent.trait_thinker,
|
|
96
|
+
scholar: agent.trait_scholar,
|
|
97
|
+
},
|
|
98
|
+
color: CLAN_COLORS[clanIndex % CLAN_COLORS.length]!,
|
|
99
|
+
sprite: MATURITY_SPRITES[agent.maturity] ?? '☺',
|
|
100
|
+
speech: null,
|
|
101
|
+
speechTimer: 0,
|
|
102
|
+
facing: rand() > 0.5 ? 'right' : 'left',
|
|
103
|
+
animFrame: 0,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Pick a random walkable position on the ship
|
|
108
|
+
function randomWalkable(ship: ShipLayout): { x: number; y: number } {
|
|
109
|
+
if (ship.spawnPoints.length === 0) return { x: 0, y: 0 };
|
|
110
|
+
return ship.spawnPoints[Math.floor(Math.random() * ship.spawnPoints.length)]!;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Select next behavior based on personality traits
|
|
114
|
+
function pickBehavior(agent: AgentSim): AgentState {
|
|
115
|
+
const t = agent.traits;
|
|
116
|
+
const tired = (100 - agent.energy) / 100;
|
|
117
|
+
|
|
118
|
+
const weights: Array<[AgentState, number]> = [
|
|
119
|
+
['working', (t.builder + t.creator) * 0.3],
|
|
120
|
+
['exploring', t.explorer * 0.25],
|
|
121
|
+
['chatting', t.leader * 0.2],
|
|
122
|
+
['idle', t.thinker * 0.15 + 0.1],
|
|
123
|
+
['sleeping', tired * 0.4],
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
// Sparks and newborns sleep more
|
|
127
|
+
if (agent.maturity === 'spark' || agent.maturity === 'newborn') {
|
|
128
|
+
weights.push(['sleeping', 0.3]);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const total = weights.reduce((sum, [, w]) => sum + w, 0);
|
|
132
|
+
let roll = Math.random() * total;
|
|
133
|
+
for (const [state, weight] of weights) {
|
|
134
|
+
roll -= weight;
|
|
135
|
+
if (roll <= 0) return state;
|
|
136
|
+
}
|
|
137
|
+
return 'idle';
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Tick agent behavior (called at 10Hz simulation rate)
|
|
141
|
+
export function tickAgent(agent: AgentSim, ship: ShipLayout, allAgents: AgentSim[]): void {
|
|
142
|
+
agent.stateTimer--;
|
|
143
|
+
agent.animFrame++;
|
|
144
|
+
|
|
145
|
+
// Drain energy slowly
|
|
146
|
+
if (agent.state !== 'sleeping') {
|
|
147
|
+
agent.energy = Math.max(0, agent.energy - 0.1);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Clear expired speech
|
|
151
|
+
if (agent.speechTimer > 0) {
|
|
152
|
+
agent.speechTimer--;
|
|
153
|
+
if (agent.speechTimer <= 0) {
|
|
154
|
+
agent.speech = null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// State-specific behavior
|
|
159
|
+
switch (agent.state) {
|
|
160
|
+
case 'walking': {
|
|
161
|
+
// Move toward target
|
|
162
|
+
const dx = agent.targetX - agent.x;
|
|
163
|
+
const dy = agent.targetY - agent.y;
|
|
164
|
+
if (Math.abs(dx) + Math.abs(dy) <= 1) {
|
|
165
|
+
// Arrived at target
|
|
166
|
+
agent.x = agent.targetX;
|
|
167
|
+
agent.y = agent.targetY;
|
|
168
|
+
agent.state = 'idle';
|
|
169
|
+
agent.stateTimer = 10 + Math.floor(Math.random() * 20);
|
|
170
|
+
} else {
|
|
171
|
+
// Step toward target (one tile per tick)
|
|
172
|
+
if (Math.abs(dx) > Math.abs(dy)) {
|
|
173
|
+
const nextX = agent.x + Math.sign(dx);
|
|
174
|
+
const tile = ship.tiles[agent.y]?.[nextX];
|
|
175
|
+
if (tile === 'floor' || tile === 'door') {
|
|
176
|
+
agent.x = nextX;
|
|
177
|
+
agent.facing = dx > 0 ? 'right' : 'left';
|
|
178
|
+
}
|
|
179
|
+
} else {
|
|
180
|
+
const nextY = agent.y + Math.sign(dy);
|
|
181
|
+
const tile = ship.tiles[nextY]?.[agent.x];
|
|
182
|
+
if (tile === 'floor' || tile === 'door') {
|
|
183
|
+
agent.y = nextY;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
case 'sleeping':
|
|
191
|
+
agent.energy = Math.min(100, agent.energy + 0.5);
|
|
192
|
+
if (agent.energy >= 95 || agent.stateTimer <= 0) {
|
|
193
|
+
agent.state = 'idle';
|
|
194
|
+
agent.stateTimer = 5;
|
|
195
|
+
}
|
|
196
|
+
break;
|
|
197
|
+
|
|
198
|
+
case 'chatting': {
|
|
199
|
+
// Look for nearby agent to face
|
|
200
|
+
const nearby = allAgents.find(
|
|
201
|
+
a => a.id !== agent.id &&
|
|
202
|
+
Math.abs(a.x - agent.x) <= 2 && Math.abs(a.y - agent.y) <= 2
|
|
203
|
+
);
|
|
204
|
+
if (nearby) {
|
|
205
|
+
agent.facing = nearby.x > agent.x ? 'right' : 'left';
|
|
206
|
+
}
|
|
207
|
+
if (agent.stateTimer <= 0) {
|
|
208
|
+
agent.state = 'idle';
|
|
209
|
+
agent.stateTimer = 10;
|
|
210
|
+
}
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
default:
|
|
215
|
+
// idle, working, exploring — wait out timer
|
|
216
|
+
if (agent.stateTimer <= 0) {
|
|
217
|
+
// Pick next behavior
|
|
218
|
+
const next = pickBehavior(agent);
|
|
219
|
+
agent.state = next;
|
|
220
|
+
|
|
221
|
+
if (next === 'walking' || next === 'exploring') {
|
|
222
|
+
const target = randomWalkable(ship);
|
|
223
|
+
agent.targetX = target.x;
|
|
224
|
+
agent.targetY = target.y;
|
|
225
|
+
agent.state = 'walking';
|
|
226
|
+
agent.stateTimer = 100;
|
|
227
|
+
} else if (next === 'chatting') {
|
|
228
|
+
agent.stateTimer = 20 + Math.floor(Math.random() * 30);
|
|
229
|
+
} else if (next === 'sleeping') {
|
|
230
|
+
agent.stateTimer = 40 + Math.floor(Math.random() * 60);
|
|
231
|
+
} else if (next === 'working') {
|
|
232
|
+
agent.stateTimer = 20 + Math.floor(Math.random() * 30);
|
|
233
|
+
} else {
|
|
234
|
+
agent.stateTimer = 15 + Math.floor(Math.random() * 25);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Smooth render position toward actual position (called at 60Hz)
|
|
242
|
+
export function lerpAgent(agent: AgentSim, dt: number): void {
|
|
243
|
+
const speed = 0.15;
|
|
244
|
+
agent.renderX += (agent.x - agent.renderX) * speed;
|
|
245
|
+
agent.renderY += (agent.y - agent.renderY) * speed;
|
|
246
|
+
// Snap when very close
|
|
247
|
+
if (Math.abs(agent.x - agent.renderX) < 0.05) agent.renderX = agent.x;
|
|
248
|
+
if (Math.abs(agent.y - agent.renderY) < 0.05) agent.renderY = agent.y;
|
|
249
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Cute gibberish speech bubble generator for agent chatter.
|
|
2
|
+
// Agents speak in nonsense syllables that feel whimsical.
|
|
3
|
+
|
|
4
|
+
const SYLLABLES = [
|
|
5
|
+
'bip', 'bop', 'blip', 'blorp', 'pip', 'pop', 'pew',
|
|
6
|
+
'zip', 'zap', 'zorp', 'nib', 'nub', 'mip', 'mop',
|
|
7
|
+
'fwee', 'twee', 'doot', 'toot', 'beep', 'boop',
|
|
8
|
+
'wub', 'dub', 'flop', 'plip', 'glub', 'squee',
|
|
9
|
+
'eep', 'oop', 'yip', 'yap', 'chirp', 'burp',
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
const EMOTES = ['!', '?', '~', '...', '!!', '?!', '♪', '♥', '✦', '☆'];
|
|
13
|
+
|
|
14
|
+
const GREETINGS = [
|
|
15
|
+
'hi hi!', 'henlo~', 'ohai!', '*wave*', 'yooo~',
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const EXCLAMATIONS = [
|
|
19
|
+
'*gasp*', 'ooh!', 'wow~', 'neat!', 'hehe~', 'whoa!', 'hmm...',
|
|
20
|
+
'*yawn*', 'zzz...', '*stretch*', '*hum*', '♪♫♪',
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
function pick<T>(arr: T[]): T {
|
|
24
|
+
return arr[Math.floor(Math.random() * arr.length)]!;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function generateSpeech(): string {
|
|
28
|
+
const roll = Math.random();
|
|
29
|
+
|
|
30
|
+
if (roll < 0.2) {
|
|
31
|
+
// Greeting
|
|
32
|
+
return pick(GREETINGS);
|
|
33
|
+
}
|
|
34
|
+
if (roll < 0.4) {
|
|
35
|
+
// Exclamation
|
|
36
|
+
return pick(EXCLAMATIONS);
|
|
37
|
+
}
|
|
38
|
+
if (roll < 0.7) {
|
|
39
|
+
// Gibberish sentence (2-3 syllables + emote)
|
|
40
|
+
const count = 2 + Math.floor(Math.random() * 2);
|
|
41
|
+
const words = Array.from({ length: count }, () => pick(SYLLABLES));
|
|
42
|
+
return words.join(' ') + pick(EMOTES);
|
|
43
|
+
}
|
|
44
|
+
// Single emote
|
|
45
|
+
return pick(EMOTES);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// State-specific speech
|
|
49
|
+
export function stateSpeech(state: string): string | null {
|
|
50
|
+
switch (state) {
|
|
51
|
+
case 'sleeping': return Math.random() < 0.1 ? 'zzz...' : null;
|
|
52
|
+
case 'chatting': return Math.random() < 0.4 ? generateSpeech() : null;
|
|
53
|
+
case 'working': return Math.random() < 0.15 ? pick(['*tap tap*', '*click*', '♪', 'hmm...', '*type type*']) : null;
|
|
54
|
+
case 'exploring': return Math.random() < 0.1 ? pick(['ooh!', 'what\'s this?', '✦', '*peek*']) : null;
|
|
55
|
+
default: return Math.random() < 0.05 ? generateSpeech() : null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// 2D character buffer for game rendering.
|
|
2
|
+
// Instead of producing ANSI strings, produces structured row data
|
|
3
|
+
// that can be rendered as React Text segments.
|
|
4
|
+
|
|
5
|
+
export interface Cell {
|
|
6
|
+
char: string;
|
|
7
|
+
fg: string;
|
|
8
|
+
bg: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface TextSpan {
|
|
12
|
+
text: string;
|
|
13
|
+
fg: string;
|
|
14
|
+
bg: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const DEFAULT_FG = '#aaaaaa';
|
|
18
|
+
const DEFAULT_BG = '#111111';
|
|
19
|
+
|
|
20
|
+
export class Canvas {
|
|
21
|
+
readonly width: number;
|
|
22
|
+
readonly height: number;
|
|
23
|
+
private cells: Cell[][];
|
|
24
|
+
|
|
25
|
+
constructor(width: number, height: number) {
|
|
26
|
+
this.width = width;
|
|
27
|
+
this.height = height;
|
|
28
|
+
this.cells = [];
|
|
29
|
+
for (let y = 0; y < height; y++) {
|
|
30
|
+
const row: Cell[] = [];
|
|
31
|
+
for (let x = 0; x < width; x++) {
|
|
32
|
+
row.push({ char: ' ', fg: DEFAULT_FG, bg: DEFAULT_BG });
|
|
33
|
+
}
|
|
34
|
+
this.cells.push(row);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
clear(fg = DEFAULT_FG, bg = DEFAULT_BG): void {
|
|
39
|
+
for (let y = 0; y < this.height; y++) {
|
|
40
|
+
for (let x = 0; x < this.width; x++) {
|
|
41
|
+
const c = this.cells[y]![x]!;
|
|
42
|
+
c.char = ' ';
|
|
43
|
+
c.fg = fg;
|
|
44
|
+
c.bg = bg;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
set(x: number, y: number, char: string, fg?: string, bg?: string): void {
|
|
50
|
+
if (x < 0 || x >= this.width || y < 0 || y >= this.height) return;
|
|
51
|
+
const c = this.cells[y]![x]!;
|
|
52
|
+
c.char = char;
|
|
53
|
+
if (fg) c.fg = fg;
|
|
54
|
+
if (bg) c.bg = bg;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
get(x: number, y: number): Cell | null {
|
|
58
|
+
if (x < 0 || x >= this.width || y < 0 || y >= this.height) return null;
|
|
59
|
+
return this.cells[y]![x]!;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
drawText(x: number, y: number, text: string, fg?: string, bg?: string): void {
|
|
63
|
+
for (let i = 0; i < text.length; i++) {
|
|
64
|
+
this.set(x + i, y, text[i]!, fg, bg);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Convert each row to run-length-encoded spans of same-colored text.
|
|
69
|
+
// This minimizes the number of React Text elements needed.
|
|
70
|
+
toSpanRows(): TextSpan[][] {
|
|
71
|
+
const rows: TextSpan[][] = [];
|
|
72
|
+
for (let y = 0; y < this.height; y++) {
|
|
73
|
+
const spans: TextSpan[] = [];
|
|
74
|
+
let spanFg = '';
|
|
75
|
+
let spanBg = '';
|
|
76
|
+
let spanChars = '';
|
|
77
|
+
|
|
78
|
+
for (let x = 0; x < this.width; x++) {
|
|
79
|
+
const c = this.cells[y]![x]!;
|
|
80
|
+
if (c.fg !== spanFg || c.bg !== spanBg) {
|
|
81
|
+
if (spanChars.length > 0) {
|
|
82
|
+
spans.push({ text: spanChars, fg: spanFg, bg: spanBg });
|
|
83
|
+
}
|
|
84
|
+
spanFg = c.fg;
|
|
85
|
+
spanBg = c.bg;
|
|
86
|
+
spanChars = c.char;
|
|
87
|
+
} else {
|
|
88
|
+
spanChars += c.char;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (spanChars.length > 0) {
|
|
92
|
+
spans.push({ text: spanChars, fg: spanFg, bg: spanBg });
|
|
93
|
+
}
|
|
94
|
+
rows.push(spans);
|
|
95
|
+
}
|
|
96
|
+
return rows;
|
|
97
|
+
}
|
|
98
|
+
}
|