ai-control-center 1.15.2

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.
Files changed (154) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +584 -0
  3. package/bin/aicc.js +772 -0
  4. package/lib/actions/approve.js +71 -0
  5. package/lib/actions/assign-project.js +132 -0
  6. package/lib/actions/browser-test.js +64 -0
  7. package/lib/actions/cleanup.js +174 -0
  8. package/lib/actions/debug.js +298 -0
  9. package/lib/actions/deploy.js +1229 -0
  10. package/lib/actions/fix-bug.js +134 -0
  11. package/lib/actions/new-feature.js +255 -0
  12. package/lib/actions/reject.js +307 -0
  13. package/lib/actions/review.js +706 -0
  14. package/lib/actions/status.js +47 -0
  15. package/lib/agents/browser-qa-agent.js +611 -0
  16. package/lib/agents/payment-agent.js +116 -0
  17. package/lib/agents/suggestion-agent.js +88 -0
  18. package/lib/cli.js +303 -0
  19. package/lib/config.js +243 -0
  20. package/lib/hub/hub-server.js +440 -0
  21. package/lib/hub/project-poller.js +75 -0
  22. package/lib/hub/skill-registry.js +89 -0
  23. package/lib/hub/state-aggregator.js +204 -0
  24. package/lib/index.js +471 -0
  25. package/lib/init/doctor.js +523 -0
  26. package/lib/init/presets.js +222 -0
  27. package/lib/init/skill-fetcher.js +77 -0
  28. package/lib/init/wizard.js +973 -0
  29. package/lib/integrations/codex-runner.js +128 -0
  30. package/lib/integrations/github-actions.js +248 -0
  31. package/lib/integrations/github-reporter.js +229 -0
  32. package/lib/integrations/screenshot-store.js +102 -0
  33. package/lib/openclaw/bridge.js +650 -0
  34. package/lib/openclaw/generate-skill.js +235 -0
  35. package/lib/openclaw/openclaw.json +64 -0
  36. package/lib/orchestrator/autonomous-loop.js +429 -0
  37. package/lib/orchestrator/thread-triggers.js +63 -0
  38. package/lib/roleplay/agent-messenger.js +75 -0
  39. package/lib/roleplay/discussion-threads.js +303 -0
  40. package/lib/roleplay/health-monitor.js +121 -0
  41. package/lib/roleplay/pm-agent.js +513 -0
  42. package/lib/roleplay/roleplay-config.js +25 -0
  43. package/lib/roleplay/room.js +164 -0
  44. package/lib/shared/action-runner.js +2330 -0
  45. package/lib/shared/event-bus.js +185 -0
  46. package/lib/slack/bot.js +378 -0
  47. package/lib/telegram/bot.js +416 -0
  48. package/lib/telegram/commands.js +1267 -0
  49. package/lib/telegram/keyboards.js +113 -0
  50. package/lib/telegram/notifications.js +247 -0
  51. package/lib/twitch/bot.js +354 -0
  52. package/lib/twitch/commands.js +302 -0
  53. package/lib/twitch/notifications.js +63 -0
  54. package/lib/utils/achievements.js +191 -0
  55. package/lib/utils/activity-log.js +182 -0
  56. package/lib/utils/agent-leaderboard.js +119 -0
  57. package/lib/utils/audit-logger.js +232 -0
  58. package/lib/utils/codebase-context.js +288 -0
  59. package/lib/utils/codebase-indexer.js +381 -0
  60. package/lib/utils/config-schema.js +230 -0
  61. package/lib/utils/context-compressor.js +172 -0
  62. package/lib/utils/correlation.js +63 -0
  63. package/lib/utils/cost-tracker.js +423 -0
  64. package/lib/utils/cron-scheduler.js +53 -0
  65. package/lib/utils/db-adapter.js +293 -0
  66. package/lib/utils/display.js +272 -0
  67. package/lib/utils/errors.js +116 -0
  68. package/lib/utils/format.js +134 -0
  69. package/lib/utils/intent-engine.js +464 -0
  70. package/lib/utils/mcp-client.js +238 -0
  71. package/lib/utils/model-ab-test.js +164 -0
  72. package/lib/utils/notify.js +122 -0
  73. package/lib/utils/persona-loader.js +80 -0
  74. package/lib/utils/pipeline-lock.js +73 -0
  75. package/lib/utils/pipeline.js +214 -0
  76. package/lib/utils/plugin-runner.js +234 -0
  77. package/lib/utils/rate-limiter.js +84 -0
  78. package/lib/utils/rbac.js +74 -0
  79. package/lib/utils/runner.js +1809 -0
  80. package/lib/utils/security.js +191 -0
  81. package/lib/utils/self-healer.js +144 -0
  82. package/lib/utils/skill-loader.js +255 -0
  83. package/lib/utils/spinner.js +132 -0
  84. package/lib/utils/stage-queue.js +50 -0
  85. package/lib/utils/state-machine.js +89 -0
  86. package/lib/utils/status-bar.js +327 -0
  87. package/lib/utils/token-estimator.js +101 -0
  88. package/lib/utils/ux-analyzer.js +101 -0
  89. package/lib/utils/webhook-emitter.js +83 -0
  90. package/lib/web/public/css/styles.css +417 -0
  91. package/lib/web/public/dark-mode.js +44 -0
  92. package/lib/web/public/hub/kanban.html +206 -0
  93. package/lib/web/public/index.html +45 -0
  94. package/lib/web/public/js/app.js +71 -0
  95. package/lib/web/public/js/ask.js +110 -0
  96. package/lib/web/public/js/dashboard.js +165 -0
  97. package/lib/web/public/js/deploy.js +72 -0
  98. package/lib/web/public/js/feature.js +79 -0
  99. package/lib/web/public/js/health.js +65 -0
  100. package/lib/web/public/js/logs.js +93 -0
  101. package/lib/web/public/js/review.js +123 -0
  102. package/lib/web/public/js/ws-client.js +82 -0
  103. package/lib/web/public/office/css/office.css +678 -0
  104. package/lib/web/public/office/index.html +148 -0
  105. package/lib/web/public/office/js/achievements-ui.js +117 -0
  106. package/lib/web/public/office/js/character.js +1056 -0
  107. package/lib/web/public/office/js/chat-bubbles.js +177 -0
  108. package/lib/web/public/office/js/cost-overlay.js +123 -0
  109. package/lib/web/public/office/js/day-night.js +68 -0
  110. package/lib/web/public/office/js/effects.js +632 -0
  111. package/lib/web/public/office/js/engine.js +146 -0
  112. package/lib/web/public/office/js/feature-ticket.js +216 -0
  113. package/lib/web/public/office/js/hub-client.js +60 -0
  114. package/lib/web/public/office/js/main.js +1757 -0
  115. package/lib/web/public/office/js/office-layout.js +1524 -0
  116. package/lib/web/public/office/js/pathfinding.js +144 -0
  117. package/lib/web/public/office/js/pixel-sprites.js +1454 -0
  118. package/lib/web/public/office/js/progress-bars.js +117 -0
  119. package/lib/web/public/office/js/replay.js +191 -0
  120. package/lib/web/public/office/js/sound-effects.js +91 -0
  121. package/lib/web/public/office/js/sprite-renderer.js +211 -0
  122. package/lib/web/public/office/js/stamina-system.js +89 -0
  123. package/lib/web/public/office/js/ui.js +107 -0
  124. package/lib/web/public/onboarding/index.html +243 -0
  125. package/lib/web/public/timeline/index.html +195 -0
  126. package/lib/web/routes/api.js +499 -0
  127. package/lib/web/routes/logs.js +20 -0
  128. package/lib/web/routes/metrics.js +99 -0
  129. package/lib/web/server.js +183 -0
  130. package/lib/web/ws/handler.js +65 -0
  131. package/package.json +67 -0
  132. package/templates/agent-architect.md +69 -0
  133. package/templates/agent-gemini-pm.md +49 -0
  134. package/templates/agent-gemini-reviewer.md +52 -0
  135. package/templates/copilot-instructions.md +36 -0
  136. package/templates/pipelines/mobile.json +27 -0
  137. package/templates/pipelines/nodejs-api.json +27 -0
  138. package/templates/pipelines/python.json +27 -0
  139. package/templates/pipelines/react.json +27 -0
  140. package/templates/pipelines/salesforce.json +27 -0
  141. package/templates/role-gemini.md +97 -0
  142. package/templates/skill-architect.md +114 -0
  143. package/templates/skill-browser-qa.md +50 -0
  144. package/templates/skill-bug-from-qa.md +58 -0
  145. package/templates/skill-chatbot.md +93 -0
  146. package/templates/skill-implement.md +78 -0
  147. package/templates/skill-openclaw.md +174 -0
  148. package/templates/skill-payment.md +110 -0
  149. package/templates/skill-pm-spec.md +77 -0
  150. package/templates/skill-requirement-capture.md +97 -0
  151. package/templates/skill-review.md +108 -0
  152. package/templates/skill-reviewer-qa.md +44 -0
  153. package/templates/skill-suggestion.md +45 -0
  154. package/templates/skill-template.md +142 -0
@@ -0,0 +1,1056 @@
1
+ /**
2
+ * Character — pixel art RPG character with state machine, pathfinding,
3
+ * sprite animation, and idle social behaviors (wander, gossip, gym, eat, sleep).
4
+ */
5
+
6
+ import { renderSprite, renderPixelText, PIXEL_SIZE } from './sprite-renderer.js';
7
+ import { getSprite, ROLE_COLORS } from './pixel-sprites.js';
8
+ import { getEffects } from './effects.js';
9
+ import { getPathfinder } from './pathfinding.js';
10
+
11
+ // ── Pipeline stage → character states ──────────────────────────────────────────
12
+ const STAGE_MAP = {
13
+ '': { pm: 'idle', arch: 'idle', coder: 'idle', deployer: 'idle' },
14
+ 'inbox': { pm: 'working', arch: 'idle', coder: 'idle', deployer: 'idle' },
15
+ 'spec': { pm: 'working', arch: 'idle', coder: 'idle', deployer: 'idle' },
16
+ 'spec_complete': { pm: 'celebrating', arch: 'walking', coder: 'idle', deployer: 'idle' },
17
+ 'architecture': { pm: 'idle', arch: 'designing', coder: 'idle', deployer: 'idle' },
18
+ 'arch_complete': { pm: 'idle', arch: 'celebrating', coder: 'walking', deployer: 'idle' },
19
+ 'implementation': { pm: 'idle', arch: 'idle', coder: 'working', deployer: 'idle' },
20
+ 'implementation_complete': { pm: 'idle', arch: 'idle', coder: 'celebrating', deployer: 'idle' },
21
+ 'review': { pm: 'reviewing', arch: 'idle', coder: 'waiting_input', deployer: 'idle' },
22
+ 'review_complete': { pm: 'idle', arch: 'idle', coder: 'idle', deployer: 'idle' },
23
+ 'approved': { pm: 'celebrating', arch: 'celebrating', coder: 'celebrating', deployer: 'waiting_input' },
24
+ 'deploying': { pm: 'idle', arch: 'idle', coder: 'idle', deployer: 'deploying' },
25
+ 'deployed': { pm: 'celebrating', arch: 'celebrating', coder: 'celebrating', deployer: 'celebrating' },
26
+ 'rejected': { pm: 'frustrated', arch: 'idle', coder: 'fighting_bug', deployer: 'idle' },
27
+ };
28
+
29
+ const ROLES = {
30
+ pm: { label: 'PM', spriteKey: 'pm', ai: 'PM', color: ROLE_COLORS.pm },
31
+ arch: { label: 'Architect', spriteKey: 'architect', ai: 'Architect', color: ROLE_COLORS.architect },
32
+ coder: { label: 'Coder', spriteKey: 'coder', ai: 'Coder', color: ROLE_COLORS.coder },
33
+ deployer: { label: 'Deployer', spriteKey: 'deployer', ai: 'Deployer', color: ROLE_COLORS.deployer },
34
+ };
35
+
36
+ const WALK_STATES = new Set(['walking', 'handing_off']);
37
+ const SPRITE_W = 16 * PIXEL_SIZE;
38
+ const SPRITE_H = 24 * PIXEL_SIZE;
39
+
40
+ // ── Idle behavior types ──────────────────────────────────────────────────────
41
+ const IDLE_BEHAVIORS = ['wander', 'gossip', 'gossip', 'gym', 'breakRoom', 'coffee', 'sleep', 'poke', 'highfive', 'phone', 'stretch', 'meeting'];
42
+
43
+ // Gossip phrases (what characters say when chatting idle)
44
+ const GOSSIP_LINES = [
45
+ // Work-related casual
46
+ 'Did you see the new feature?',
47
+ 'The tests are flaky again...',
48
+ 'Need more coffee...',
49
+ 'Sprint review tomorrow!',
50
+ 'That bug was tricky!',
51
+ 'Who broke the build?',
52
+ 'Lunch plans?',
53
+ 'Nice refactor!',
54
+ 'PR looks good to me',
55
+ 'Ship it!',
56
+ 'Standup in 5 min',
57
+ 'API is slow today...',
58
+ 'Love the new UI!',
59
+ 'Merge conflicts again...',
60
+ 'Great code review!',
61
+ 'Time for a break',
62
+ 'The deploy went smooth!',
63
+ 'Any blockers?',
64
+ 'Working on the fix now',
65
+ 'Almost done!',
66
+ // Office banter
67
+ 'Who ate my lunch?',
68
+ 'Meeting could be an email',
69
+ 'The WiFi is slow again',
70
+ 'Love this weather!',
71
+ 'Weekend plans?',
72
+ 'Did you watch the game?',
73
+ 'New phone who dis?',
74
+ 'Coffee run anyone?',
75
+ 'The AC is freezing!',
76
+ 'Happy Friday!',
77
+ // Tech humor
78
+ 'It works on my machine',
79
+ 'Have you tried restarting?',
80
+ 'Stack Overflow saves lives',
81
+ 'Git blame says it was you',
82
+ 'Tabs or spaces?',
83
+ 'Is it a feature or a bug?',
84
+ 'The cloud is just servers',
85
+ '404 motivation not found',
86
+ 'sudo make me a sandwich',
87
+ 'There are 10 types of people',
88
+ // Social
89
+ 'Great job yesterday!',
90
+ 'How is your project going?',
91
+ 'Any fun plans tonight?',
92
+ 'That presentation was awesome',
93
+ 'New coffee machine rocks!',
94
+ 'The gym here is nice',
95
+ 'Have you tried the new place?',
96
+ 'Team dinner this week?',
97
+ 'Congrats on the promotion!',
98
+ 'Love your desk setup!',
99
+ // Philosophical
100
+ 'Is AI coming for our jobs?',
101
+ 'Agile or waterfall?',
102
+ 'Monolith or microservices?',
103
+ 'REST or GraphQL?',
104
+ 'To deploy or not to deploy',
105
+ 'The code review was rough',
106
+ 'Thats a clever solution!',
107
+ 'Who writes the docs anyway?',
108
+ // Daily life
109
+ 'Traffic was terrible today',
110
+ 'My cat stepped on my keyboard',
111
+ 'Dog ate my USB cable',
112
+ 'Forgot my badge again',
113
+ 'Elevator is broken again',
114
+ 'Parking was a nightmare',
115
+ 'The vending machine is empty',
116
+ 'Did you see the sunset?',
117
+ // Celebrations
118
+ 'We hit the milestone!',
119
+ 'Another sprint done!',
120
+ 'Zero bugs this week!',
121
+ 'Client loved the demo!',
122
+ 'Performance is up 40%!',
123
+ 'All tests green!',
124
+ 'Deployment was flawless!',
125
+ 'New record uptime!',
126
+ // Complaints
127
+ 'JIRA is down again',
128
+ 'Too many meetings today',
129
+ 'My back hurts from sitting',
130
+ 'Need a standing desk',
131
+ 'Slack notifications overload',
132
+ 'Email inbox is a disaster',
133
+ 'The printer never works',
134
+ 'VPN disconnected again',
135
+ // Random fun
136
+ 'Anyone want to play ping pong?',
137
+ 'Movie night this Friday?',
138
+ 'Boba tea run?',
139
+ 'Pizza or sushi for lunch?',
140
+ 'New season is out on Netflix',
141
+ 'That meme was hilarious',
142
+ 'Best commit message ever',
143
+ 'My code compiled first try!',
144
+ 'Rubber duck debugging works',
145
+ 'The intern fixed the bug!',
146
+ 'Senior dev was wrong!',
147
+ 'Pair programming is fun',
148
+ 'Code kata this afternoon?',
149
+ 'Hackathon next month!',
150
+ 'Open source contribution day',
151
+ 'Tech conference next week',
152
+ 'New framework dropped!',
153
+ 'TypeScript saves lives',
154
+ 'Linting catches everything',
155
+ 'CI/CD pipeline is beautiful',
156
+ ];
157
+
158
+ // Thought bubbles shown while characters are actively working
159
+ const WORK_MESSAGES = {
160
+ pm: ['📝 Writing spec...', '🤔 Requirements?', '📊 Roadmap!', '🎯 Scope defined', '💼 Stakeholders', '🗺 User stories', '✅ Acceptance criteria'],
161
+ arch: ['🏗 Architecture...', '💡 Design pattern?', '🔧 Scalability!', '📐 Diagrams...', '⚡ Optimize!', '🧩 Microservices?', '📦 Clean layers'],
162
+ coder: ['💻 Coding...', '🔍 Debugging...', '🐛 Found it!', '⌨️ Refactor...', '✅ Tests pass!', '🔥 Edge case!', '💡 Better way?', '🚀 Almost done!'],
163
+ deployer: ['🚀 Deploying...', '⚙️ Config check', '🐳 Build image', '✅ Health check', '🔄 Rollback ready', '📦 Packaging...', '🌐 DNS updated'],
164
+ deployer_standby: ['📦 Package ready!', '🚀 Awaiting signal...', '⚙️ Preflight check...', '🔑 Credentials OK', '🐳 Docker image built', '✅ Env vars set', '🌐 Prod cluster ready', '🔄 Rollback plan set', '📋 Deploy checklist ✓', '💾 DB backup done'],
165
+ };
166
+
167
+ // Feature-related discussion lines (used instead of gossip when team is working)
168
+ const FEATURE_DISCUSS_LINES = [
169
+ 'About this feature...',
170
+ 'The spec looks good!',
171
+ 'Any edge cases?',
172
+ 'Architecture ready?',
173
+ 'Need your input!',
174
+ 'Tests for this?',
175
+ 'Lets sync up!',
176
+ 'Good progress!',
177
+ 'Blocking issue?',
178
+ 'Almost there!',
179
+ 'Deploy plan?',
180
+ 'Review notes...',
181
+ 'Scope change?',
182
+ 'Nice design!',
183
+ 'QA check?',
184
+ // Technical discussions
185
+ 'What about error handling?',
186
+ 'Should we add caching?',
187
+ 'The API contract looks solid',
188
+ 'Need a migration script?',
189
+ 'Backwards compatible?',
190
+ 'Performance impact?',
191
+ 'Security review needed',
192
+ 'Database schema change?',
193
+ 'Unit test coverage?',
194
+ 'Integration test plan?',
195
+ 'Load testing results?',
196
+ 'The mock data is ready',
197
+ 'Code review comments addressed',
198
+ 'Docs updated for this?',
199
+ 'Release notes drafted',
200
+ // Planning
201
+ 'Sprint capacity check?',
202
+ 'Dependencies resolved?',
203
+ 'Stakeholder approval?',
204
+ 'UAT environment ready?',
205
+ 'Feature flag needed?',
206
+ 'Rollback strategy?',
207
+ 'Monitoring in place?',
208
+ 'Logging sufficient?',
209
+ 'Alert thresholds set?',
210
+ 'Timeline on track?',
211
+ // Collaboration
212
+ 'Lets whiteboard this',
213
+ 'Can you pair on this?',
214
+ 'Review my approach?',
215
+ 'Second opinion needed',
216
+ 'Knowledge transfer done?',
217
+ 'Handoff checklist ready',
218
+ 'Demo prep started?',
219
+ 'Client feedback incorporated',
220
+ 'Tech debt noted for later',
221
+ 'Clean up after merge',
222
+ ];
223
+
224
+ // Rejection-escalating messages for coder (shown when fighting bugs after rejection)
225
+ const REJECTION_MESSAGES = [
226
+ ['😤 Fix review...', '🐛 Hunt the bug', '💻 Rework it!', '🔍 What changed?'],
227
+ ['😰 2nd reject!', '💦 Sweating...', '🙏 Please pass!', '😓 Nearly there'],
228
+ ['😭 3rd reject!', '🙏🙏 Praying...', '⛪ Dear God...', '💦 Full panic!', '🆘 HELP!!!'],
229
+ ['🤯 4th reject!', '🙏🙏🙏 GOD PLEASE', '😱 Why won\'t it pass', '💀 I give up...'],
230
+ ];
231
+
232
+ // Social interaction lines
233
+ const POKE_LINES = [
234
+ '👉 Hey!', '👉 Wake up!', '😜 Gotcha!', '👊 Boop!',
235
+ '🤪 Surprise!', '😏 Guess who?', '👋 Yo!', '🫵 Tag!',
236
+ '😝 Poke poke!', '🤭 Hi there!', '😂 Made you look!',
237
+ '🤓 Did you know?', '🧐 Ahem!', '🫠 Bored!',
238
+ ];
239
+
240
+ const HIGHFIVE_LINES = [
241
+ '✋ High five!', '🙌 Teamwork!', '🤝 Nice one!', '👏 Bravo!',
242
+ '💪 Nailed it!', '🎯 Bullseye!', '⭐ Star player!', '🏆 Champions!',
243
+ '🔥 On fire!', '💯 Perfect!', '🚀 To the moon!', '🎉 Celebrate!',
244
+ ];
245
+
246
+ const MEETING_LINES = [
247
+ 'Lets sync on priorities',
248
+ 'Quick status update?',
249
+ 'Blocker discussion',
250
+ 'Sprint retro notes',
251
+ 'Timeline check-in',
252
+ 'Dependency update',
253
+ 'Risk assessment',
254
+ 'Capacity planning',
255
+ 'Action items review',
256
+ 'Quick standup!',
257
+ ];
258
+
259
+ /**
260
+ * Time-of-day weighted behavior selection.
261
+ * Returns a behavior from IDLE_BEHAVIORS with preferences based on hour.
262
+ */
263
+ function pickTimeAwareBehavior() {
264
+ const hour = new Date().getHours();
265
+
266
+ // Time-of-day preferences: [behavior, extraWeight]
267
+ const boosts = [];
268
+
269
+ if (hour >= 6 && hour <= 9) {
270
+ // Morning: prefer coffee
271
+ boosts.push(['coffee', 4]);
272
+ }
273
+ if (hour >= 22 || hour <= 5) {
274
+ // Late night: prefer sleep
275
+ boosts.push(['sleep', 5]);
276
+ }
277
+ if (hour >= 12 && hour <= 14) {
278
+ // Lunch time: prefer gym / breakRoom
279
+ boosts.push(['gym', 3]);
280
+ boosts.push(['breakRoom', 3]);
281
+ }
282
+
283
+ // Build weighted pool: start with base list (weight 1 each)
284
+ const pool = IDLE_BEHAVIORS.slice();
285
+
286
+ // Add extra copies for boosted behaviors
287
+ for (const [beh, weight] of boosts) {
288
+ for (let i = 0; i < weight; i++) pool.push(beh);
289
+ }
290
+
291
+ return pool[Math.floor(Math.random() * pool.length)];
292
+ }
293
+
294
+ export class Character {
295
+ constructor(role, projectName, x, y) {
296
+ this.role = role;
297
+ this.project = projectName;
298
+ this.meta = ROLES[role];
299
+
300
+ // Position
301
+ this.x = x;
302
+ this.y = y;
303
+ this.targetX = x;
304
+ this.targetY = y;
305
+ this.homeX = x; // workstation position
306
+ this.homeY = y;
307
+ this.speed = 50;
308
+
309
+ // Pathfinding
310
+ this.path = [];
311
+ this.pathIndex = 0;
312
+
313
+ // Facing direction
314
+ this.facing = 'down';
315
+
316
+ // State
317
+ this.state = 'idle';
318
+ this.prevState = 'idle';
319
+ this.stateTime = 0;
320
+ this.isWorking = false; // true when pipeline assigns work
321
+ this.isAtDesk = false; // true when working character has reached their desk
322
+ this.isHandingOff = false; // true when delivering a ticket to another character
323
+
324
+ // Project-specific color badge (set by createCharacters in main.js)
325
+ this.projectColor = '#888888';
326
+ this.projectIndex = 0;
327
+ this.rejectionCount = 0; // escalates desperate reactions after each review rejection
328
+ this.currentFeature = ''; // current feature name for work discussions
329
+
330
+ // Task progress (driven by pipeline stage duration)
331
+ this.taskProgress = 0; // 0.0 – 1.0
332
+ this.taskDuration = 0; // estimated seconds for current phase
333
+
334
+ // Idle behavior
335
+ this.idleBehavior = null; // current idle activity
336
+ this.idleTimer = 0; // countdown to next idle action
337
+ this.idleDuration = 0; // how long current idle activity lasts
338
+ this.gossipTarget = null; // character we're gossiping with
339
+ this.gossipLine = ''; // what we're saying
340
+ this.gossipShowTime = 0; // when to show the bubble
341
+
342
+ // Phone / stretch idle timers
343
+ this.phoneTimer = 0;
344
+ this.stretchTimer = 0;
345
+
346
+ // Work thought bubbles (shown while working)
347
+ this.workBubble = '';
348
+ this.workBubbleTimer = 2 + Math.random() * 4; // first bubble delay
349
+ this.workBubbleDuration = 0;
350
+
351
+ // Animation
352
+ this.animFrame = 0;
353
+ this.animTimer = 0;
354
+ this.animSpeed = 0.22;
355
+
356
+ // Rendering
357
+ this.bobOffset = 0;
358
+ this.shadow = true;
359
+
360
+ // Randomize initial idle timer so characters don't all act at once
361
+ this.idleTimer = 3 + Math.random() * 8;
362
+ }
363
+
364
+ /** Update state from pipeline stage */
365
+ setState(pipelineStage) {
366
+ const map = STAGE_MAP[pipelineStage] || STAGE_MAP[''];
367
+ const newState = map[this.role] || 'idle';
368
+
369
+ // Check if this is a "working" state
370
+ const workingStates = new Set(['working', 'designing', 'reviewing', 'deploying', 'fighting_bug', 'celebrating', 'frustrated', 'waiting_input', 'handing_off']);
371
+ this.isWorking = workingStates.has(newState);
372
+
373
+ if (newState !== this.state) {
374
+ this.prevState = this.state;
375
+ this.state = newState;
376
+ this.stateTime = 0;
377
+ this.animFrame = 0;
378
+
379
+ // If transitioning TO work, cancel idle behavior
380
+ if (this.isWorking) {
381
+ this.idleBehavior = null;
382
+ this.gossipTarget = null;
383
+ this.gossipLine = '';
384
+ }
385
+ }
386
+ }
387
+
388
+ /**
389
+ * Temporarily override state for a reaction, then restore.
390
+ * @param {string} reactionState - e.g. 'celebrating', 'frustrated', 'waiting_input'
391
+ * @param {string} bubbleText - optional speech bubble text
392
+ * @param {number} durationSec - how long before reverting
393
+ */
394
+ react(reactionState, bubbleText = '', durationSec = 3) {
395
+ if (this._reactionTimer) clearTimeout(this._reactionTimer);
396
+ const saved = this.state;
397
+ this.prevState = saved;
398
+ this.state = reactionState;
399
+ this.stateTime = 0;
400
+ this.animFrame = 0;
401
+ if (bubbleText) {
402
+ this._reactionBubble = bubbleText;
403
+ this._reactionBubbleTimer = durationSec;
404
+ }
405
+ this._reactionTimer = setTimeout(() => {
406
+ this._reactionTimer = null;
407
+ this._reactionBubble = '';
408
+ this.state = saved;
409
+ this.stateTime = 0;
410
+ }, durationSec * 1000);
411
+ }
412
+
413
+ /** Set home position (workstation) */
414
+ setHome(x, y) {
415
+ this.homeX = x;
416
+ this.homeY = y;
417
+ }
418
+
419
+ /** Move to target with pathfinding */
420
+ moveTo(tx, ty) {
421
+ this.targetX = tx;
422
+ this.targetY = ty;
423
+ const pf = getPathfinder();
424
+ this.path = pf.findPath(this.project, this.x, this.y, tx, ty);
425
+ this.pathIndex = 0;
426
+ }
427
+
428
+ /** Start an idle behavior (called from main.js idle system) */
429
+ startIdleBehavior(behavior, targetPos, gossipChar) {
430
+ // HARD BLOCK: working characters NEVER start idle behaviors
431
+ if (this.isWorking) return;
432
+
433
+ this.idleBehavior = behavior;
434
+ this.idleDuration = 5 + Math.random() * 10;
435
+
436
+ if (targetPos) {
437
+ this.moveTo(targetPos.x, targetPos.y);
438
+ }
439
+
440
+ if (behavior === 'gossip' && gossipChar) {
441
+ this.gossipTarget = gossipChar;
442
+ // Use feature discussion lines when working, random gossip when idle
443
+ if (this.currentFeature) {
444
+ this.gossipLine = FEATURE_DISCUSS_LINES[Math.floor(Math.random() * FEATURE_DISCUSS_LINES.length)];
445
+ } else {
446
+ this.gossipLine = GOSSIP_LINES[Math.floor(Math.random() * GOSSIP_LINES.length)];
447
+ }
448
+ this.gossipShowTime = 0;
449
+ } else if (behavior === 'poke' && gossipChar) {
450
+ this.gossipTarget = gossipChar;
451
+ this.gossipLine = POKE_LINES[Math.floor(Math.random() * POKE_LINES.length)];
452
+ this.gossipShowTime = 0;
453
+ this.idleDuration = 3 + Math.random() * 3;
454
+ } else if (behavior === 'highfive' && gossipChar) {
455
+ this.gossipTarget = gossipChar;
456
+ this.gossipLine = HIGHFIVE_LINES[Math.floor(Math.random() * HIGHFIVE_LINES.length)];
457
+ gossipChar.gossipLine = HIGHFIVE_LINES[Math.floor(Math.random() * HIGHFIVE_LINES.length)];
458
+ gossipChar.gossipShowTime = 0;
459
+ this.gossipShowTime = 0;
460
+ this.idleDuration = 2 + Math.random() * 2;
461
+ } else if (behavior === 'phone') {
462
+ // Stay in place, check phone for 4-6 seconds
463
+ this.phoneTimer = 4 + Math.random() * 2;
464
+ this.idleDuration = this.phoneTimer + 0.5;
465
+ } else if (behavior === 'stretch') {
466
+ // Stay in place, stretch for 3 seconds
467
+ this.stretchTimer = 3;
468
+ this.idleDuration = 3.5;
469
+ } else if (behavior === 'meeting' && gossipChar) {
470
+ this.gossipTarget = gossipChar;
471
+ this.gossipLine = MEETING_LINES[Math.floor(Math.random() * MEETING_LINES.length)];
472
+ gossipChar.gossipLine = MEETING_LINES[Math.floor(Math.random() * MEETING_LINES.length)];
473
+ this.gossipShowTime = 0;
474
+ gossipChar.gossipShowTime = 0;
475
+ this.idleDuration = 6 + Math.random() * 4;
476
+ } else if (behavior === 'sleep') {
477
+ this.state = 'sleeping';
478
+ } else if (behavior === 'gym') {
479
+ this.state = 'working'; // reuse working animation for gym
480
+ } else if (behavior === 'coffee') {
481
+ this.state = 'coffee_break';
482
+ }
483
+ }
484
+
485
+ /** Update position, animation, and idle behavior */
486
+ update(dt) {
487
+ this.stateTime += dt;
488
+ this.animTimer += dt;
489
+
490
+ // Auto-expire 'celebrating' after 7s — don't celebrate forever after deploy
491
+ if (this.state === 'celebrating' && this.stateTime > 7) {
492
+ this.state = 'idle';
493
+ this.isWorking = false;
494
+ this.stateTime = 0;
495
+ }
496
+
497
+ // Advance animation frame
498
+ if (this.animTimer >= this.animSpeed) {
499
+ this.animTimer = 0;
500
+ this.animFrame = (this.animFrame + 1) % 4;
501
+ }
502
+
503
+ // Idle behavior timer
504
+ if (!this.isWorking && this.idleBehavior) {
505
+ this.idleDuration -= dt;
506
+ if (this.idleDuration <= 0) {
507
+ // Return home
508
+ this.idleBehavior = null;
509
+ this.gossipTarget = null;
510
+ this.gossipLine = '';
511
+ this.state = 'idle';
512
+ this.moveTo(this.homeX, this.homeY);
513
+ this.idleTimer = 4 + Math.random() * 8;
514
+ }
515
+
516
+ // Gossip bubble timing
517
+ if (this.idleBehavior === 'gossip' || this.idleBehavior === 'meeting') {
518
+ this.gossipShowTime += dt;
519
+ }
520
+
521
+ // Phone timer countdown
522
+ if (this.idleBehavior === 'phone' && this.phoneTimer > 0) {
523
+ this.phoneTimer -= dt;
524
+ }
525
+
526
+ // Stretch timer countdown
527
+ if (this.idleBehavior === 'stretch' && this.stretchTimer > 0) {
528
+ this.stretchTimer -= dt;
529
+ }
530
+ }
531
+
532
+ // Idle timer (pick new behavior when timer runs out)
533
+ if (!this.isWorking && !this.idleBehavior) {
534
+ this.idleTimer -= dt;
535
+ }
536
+
537
+ // Work thought bubbles (shown periodically while working)
538
+ if (this.isWorking) {
539
+ this.workBubbleTimer -= dt;
540
+ if (this.workBubbleTimer <= 0) {
541
+ // Use rejection-escalating messages for coder in fighting_bug state
542
+ let msgs;
543
+ if (this.state === 'fighting_bug' && this.rejectionCount > 0) {
544
+ const tier = Math.min(this.rejectionCount - 1, REJECTION_MESSAGES.length - 1);
545
+ msgs = REJECTION_MESSAGES[tier];
546
+ } else if (this.role === 'deployer' && this.state === 'waiting_input') {
547
+ msgs = WORK_MESSAGES.deployer_standby;
548
+ } else {
549
+ msgs = WORK_MESSAGES[this.role] || WORK_MESSAGES.coder;
550
+ }
551
+ this.workBubble = msgs[Math.floor(Math.random() * msgs.length)];
552
+ this.workBubbleDuration = 3.5;
553
+ // Deployer standby: cycle bubbles faster to look active
554
+ this.workBubbleTimer = (this.role === 'deployer' && this.state === 'waiting_input')
555
+ ? 2 + Math.random() * 2
556
+ : 2 + Math.random() * 3;
557
+ }
558
+ if (this.workBubbleDuration > 0) {
559
+ this.workBubbleDuration -= dt;
560
+ if (this.workBubbleDuration <= 0) this.workBubble = '';
561
+ }
562
+ } else {
563
+ this.workBubble = '';
564
+ this.workBubbleTimer = 3 + Math.random() * 4;
565
+ }
566
+
567
+ // Working characters stay at their desk — snap home and stop moving
568
+ if (this.isWorking && this.state !== 'handing_off') {
569
+ // If not yet at home, walk there first
570
+ const hx = this.homeX - this.x;
571
+ const hy = this.homeY - this.y;
572
+ const homeDist = Math.sqrt(hx * hx + hy * hy);
573
+ if (homeDist > 4) {
574
+ // Walk toward home desk — mark as not yet seated
575
+ this.isAtDesk = false;
576
+ const step = Math.min(this.speed * dt, homeDist);
577
+ this.x += (hx / homeDist) * step;
578
+ this.y += (hy / homeDist) * step;
579
+ if (Math.abs(hx) > Math.abs(hy)) {
580
+ this.facing = hx > 0 ? 'right' : 'left';
581
+ } else {
582
+ this.facing = hy > 0 ? 'down' : 'up';
583
+ }
584
+ } else {
585
+ // At desk — stay put, face forward
586
+ this.isAtDesk = true;
587
+ this.x = this.homeX;
588
+ this.y = this.homeY;
589
+ this.path = [];
590
+ this.pathIndex = 0;
591
+ this.facing = 'down';
592
+ }
593
+ } else {
594
+ // Follow path or move toward target (idle / walking characters)
595
+ let moveTargetX = this.targetX;
596
+ let moveTargetY = this.targetY;
597
+
598
+ if (this.path.length > 0 && this.pathIndex < this.path.length) {
599
+ moveTargetX = this.path[this.pathIndex].x;
600
+ moveTargetY = this.path[this.pathIndex].y;
601
+ }
602
+
603
+ const dx = moveTargetX - this.x;
604
+ const dy = moveTargetY - this.y;
605
+ const dist = Math.sqrt(dx * dx + dy * dy);
606
+
607
+ if (dist > 2) {
608
+ const step = Math.min(this.speed * dt, dist);
609
+ this.x += (dx / dist) * step;
610
+ this.y += (dy / dist) * step;
611
+
612
+ if (Math.abs(dx) > Math.abs(dy)) {
613
+ this.facing = dx > 0 ? 'right' : 'left';
614
+ } else {
615
+ this.facing = dy > 0 ? 'down' : 'up';
616
+ }
617
+ } else {
618
+ this.x = moveTargetX;
619
+ this.y = moveTargetY;
620
+ if (this.path.length > 0 && this.pathIndex < this.path.length) {
621
+ this.pathIndex++;
622
+ }
623
+ }
624
+ }
625
+
626
+ // Bob animation
627
+ if (this.state === 'working' || this.state === 'designing') {
628
+ this.bobOffset = Math.sin(this.stateTime * 4) * 1.5;
629
+ } else if (this.state === 'celebrating') {
630
+ this.bobOffset = Math.abs(Math.sin(this.stateTime * 6)) * -8;
631
+ } else if (this.state === 'sleeping') {
632
+ this.bobOffset = Math.sin(this.stateTime * 1.5) * 1;
633
+ } else if (this.state === 'frustrated' || this.state === 'rate_limited') {
634
+ this.bobOffset = Math.sin(this.stateTime * 12) * 1;
635
+ } else if (this.idleBehavior === 'gym') {
636
+ this.bobOffset = Math.abs(Math.sin(this.stateTime * 5)) * -3; // exercise bounce
637
+ } else if (this.idleBehavior === 'stretch' && this.stretchTimer > 0) {
638
+ // Alternate between normal and slightly taller to simulate stretching
639
+ this.bobOffset = Math.sin(this.stateTime * 3) * -4;
640
+ } else {
641
+ this.bobOffset = 0;
642
+ }
643
+ }
644
+
645
+ /** Render pixel art character */
646
+ render(ctx) {
647
+ const effects = getEffects();
648
+
649
+ // Determine sprite state
650
+ let spriteState = this.state;
651
+ const isMoving = Math.abs(this.targetX - this.x) > 2 || Math.abs(this.targetY - this.y) > 2;
652
+
653
+ if (isMoving || WALK_STATES.has(this.state)) {
654
+ if (this.facing === 'left' || this.facing === 'right') {
655
+ spriteState = 'walk_side';
656
+ } else if (this.facing === 'up') {
657
+ spriteState = 'walk_up';
658
+ } else {
659
+ spriteState = 'walk_down';
660
+ }
661
+ }
662
+
663
+ const pixelData = getSprite(this.meta.spriteKey, spriteState, this.animFrame, this.project);
664
+ if (!pixelData) return;
665
+
666
+ // Shadow
667
+ if (this.shadow) {
668
+ ctx.save();
669
+ ctx.globalAlpha = 0.3;
670
+ ctx.fillStyle = '#000';
671
+ ctx.beginPath();
672
+ ctx.ellipse(this.x, this.y + 2, SPRITE_W / 3, 4, 0, 0, Math.PI * 2);
673
+ ctx.fill();
674
+ ctx.restore();
675
+ }
676
+
677
+ // Sprite — apply team-specific tint overlay for visual distinction
678
+ renderSprite(ctx, pixelData, this.x, this.y, {
679
+ flipX: this.facing === 'left',
680
+ offsetY: this.bobOffset,
681
+ });
682
+
683
+ // Team-colored name banner (replaces old badge — clearly shows which project)
684
+ const labelText = `${this.meta.label}`;
685
+ const labelW = labelText.length * 4 + 4;
686
+ ctx.fillStyle = this.projectColor;
687
+ ctx.globalAlpha = 0.85;
688
+ ctx.fillRect(this.x - labelW / 2 - 1, this.y + 2, labelW + 2, 8);
689
+ ctx.globalAlpha = 1;
690
+ renderPixelText(ctx, labelText, this.x, this.y + 3, '#fff', 1);
691
+
692
+ // State effects (lightbulb, confetti, etc.) — pass isAtDesk so work effects only show at desk
693
+ effects.renderStateOverlay(ctx, this.state, this.x, this.y, this.stateTime, this.isAtDesk);
694
+
695
+ // Gossip / meeting bubble
696
+ if ((this.idleBehavior === 'gossip' || this.idleBehavior === 'meeting') && this.gossipLine && this.gossipShowTime > 0.5) {
697
+ this._renderGossipBubble(ctx);
698
+ }
699
+
700
+ // Reaction bubble (from react() call — shows immediately, higher priority)
701
+ if (this._reactionBubble) {
702
+ const savedBubble = this.workBubble;
703
+ this.workBubble = this._reactionBubble;
704
+ this.isWorking = true;
705
+ this._renderWorkBubble(ctx);
706
+ this.workBubble = savedBubble;
707
+ this.isWorking = !!savedBubble;
708
+ } else if (this.isWorking && this.workBubble) {
709
+ // Work thought bubble
710
+ this._renderWorkBubble(ctx);
711
+ }
712
+
713
+ // Rejection desperation overlay (sweat drops + prayer hands above head)
714
+ if (this.state === 'fighting_bug' && this.rejectionCount >= 2) {
715
+ this._renderPrayerOverlay(ctx);
716
+ }
717
+
718
+ // Idle activity indicator
719
+ if (this.idleBehavior && !isMoving) {
720
+ this._renderIdleIndicator(ctx);
721
+ }
722
+ }
723
+
724
+ /** Status badge shown above the character — only visible while working */
725
+ _renderStatusBadge(ctx) {
726
+ // Only show badge when actively working — hide when idle/ready/sleeping
727
+ if (!this.isWorking) return;
728
+
729
+ const workLabels = {
730
+ working: '⚡ WORKING',
731
+ designing: '📐 DESIGNING',
732
+ reviewing: '🔍 REVIEWING',
733
+ deploying: '🚀 DEPLOYING',
734
+ fighting_bug: '🐛 FIXING BUG',
735
+ celebrating: '🎉 DONE!',
736
+ frustrated: '😤 REJECTED',
737
+ waiting_input: this.role === 'deployer' ? '🚀 STANDBY' : '⏳ WAITING',
738
+ handing_off: '📄 HANDING OFF',
739
+ };
740
+ const statusText = workLabels[this.state] || '⚡ WORKING';
741
+ const bgColor = this.state === 'celebrating' ? '#27ae60' :
742
+ this.state === 'frustrated' ? '#c0392b' :
743
+ this.state === 'waiting_input' ? '#f39c12' :
744
+ '#2980b9';
745
+
746
+ // Draw badge above sprite (just below any progress bar / bubbles)
747
+ const badgeY = this.y - SPRITE_H + this.bobOffset - 6;
748
+ // Use native canvas text for emoji + crispness
749
+ ctx.save();
750
+ ctx.font = 'bold 7px monospace';
751
+ const metrics = ctx.measureText(statusText);
752
+ const bw = metrics.width + 6;
753
+ const bh = 10;
754
+ const bx = this.x - bw / 2;
755
+
756
+ // Background pill
757
+ ctx.globalAlpha = 0.88;
758
+ ctx.fillStyle = bgColor;
759
+ ctx.beginPath();
760
+ ctx.roundRect(bx, badgeY - bh, bw, bh, 3);
761
+ ctx.fill();
762
+
763
+ // Text
764
+ ctx.globalAlpha = 1;
765
+ ctx.fillStyle = '#fff';
766
+ ctx.textAlign = 'center';
767
+ ctx.textBaseline = 'middle';
768
+ ctx.fillText(statusText, this.x, badgeY - bh / 2);
769
+ ctx.restore();
770
+ }
771
+
772
+ _renderGossipBubble(ctx) {
773
+ // Count only renderable chars for accurate bubble sizing
774
+ const upperText = this.gossipLine.toUpperCase();
775
+ const renderableLen = upperText.split('').filter(ch =>
776
+ /[A-Z0-9 .!?:\-/',()+=#_]/.test(ch)
777
+ ).length;
778
+ const bubbleW = Math.min(renderableLen * 4 + 12, 100);
779
+ const bubbleH = 14;
780
+ // Offset bubble AWAY from gossip partner so they don't overlap
781
+ let xOffset = 0;
782
+ if (this.gossipTarget) {
783
+ xOffset = this.x <= this.gossipTarget.x ? -(bubbleW / 2 + 6) : (bubbleW / 2 + 6);
784
+ }
785
+ const bx = this.x + xOffset - bubbleW / 2;
786
+ const by = this.y - SPRITE_H - bubbleH - 8;
787
+ // Text center matches bubble center
788
+ const textCenterX = bx + bubbleW / 2;
789
+
790
+ // Bubble bg
791
+ ctx.fillStyle = '#fff';
792
+ ctx.fillRect(bx + 2, by, bubbleW - 4, bubbleH);
793
+ ctx.fillRect(bx, by + 2, bubbleW, bubbleH - 4);
794
+
795
+ // Border
796
+ ctx.fillStyle = '#555';
797
+ ctx.fillRect(bx + 2, by - 1, bubbleW - 4, 1);
798
+ ctx.fillRect(bx + 2, by + bubbleH, bubbleW - 4, 1);
799
+ ctx.fillRect(bx - 1, by + 2, 1, bubbleH - 4);
800
+ ctx.fillRect(bx + bubbleW, by + 2, 1, bubbleH - 4);
801
+
802
+ // Tail — point from bubble toward character
803
+ ctx.fillStyle = '#fff';
804
+ const tailX = Math.max(bx + 4, Math.min(this.x, bx + bubbleW - 6));
805
+ ctx.fillRect(tailX - 2, by + bubbleH, 3, 3);
806
+ ctx.fillRect(tailX, by + bubbleH + 3, 2, 2);
807
+
808
+ // Text (truncated to fit, centered in bubble)
809
+ const maxChars = Math.floor((bubbleW - 8) / 4);
810
+ const text = this.gossipLine.slice(0, maxChars).toUpperCase();
811
+ renderPixelText(ctx, text, textCenterX, by + 4, '#333', 1);
812
+
813
+ // Fade out after a while
814
+ if (this.gossipShowTime > 4) {
815
+ this.gossipShowTime = 0;
816
+ this.gossipLine = GOSSIP_LINES[Math.floor(Math.random() * GOSSIP_LINES.length)];
817
+ }
818
+ }
819
+
820
+ _renderWorkBubble(ctx) {
821
+ const text = this.workBubble;
822
+ // Emoji chars are wider — estimate width more generously
823
+ const hasEmoji = /[\u{1F000}-\u{1FFFF}]/u.test(text);
824
+ const charW = hasEmoji ? 5 : 4;
825
+ const bubbleW = Math.min(text.length * charW + 16, 120);
826
+ const bubbleH = 16;
827
+ const bx = this.x - bubbleW / 2;
828
+ const by = this.y - SPRITE_H - bubbleH - 10;
829
+
830
+ // Thought bubble style (rounded, lighter)
831
+ ctx.fillStyle = '#e8f4ff';
832
+ ctx.fillRect(bx + 2, by, bubbleW - 4, bubbleH);
833
+ ctx.fillRect(bx, by + 2, bubbleW, bubbleH - 4);
834
+
835
+ // Border (blue tint for thought bubble)
836
+ ctx.fillStyle = '#4080d0';
837
+ ctx.fillRect(bx + 2, by - 1, bubbleW - 4, 1);
838
+ ctx.fillRect(bx + 2, by + bubbleH, bubbleW - 4, 1);
839
+ ctx.fillRect(bx - 1, by + 2, 1, bubbleH - 4);
840
+ ctx.fillRect(bx + bubbleW, by + 2, 1, bubbleH - 4);
841
+
842
+ // Thought dots tail (instead of speech tail)
843
+ ctx.fillStyle = '#e8f4ff';
844
+ ctx.fillRect(this.x - 1, by + bubbleH, 2, 2);
845
+ ctx.fillStyle = '#4080d0';
846
+ ctx.fillRect(this.x, by + bubbleH + 2, 2, 2);
847
+ ctx.fillRect(this.x + 1, by + bubbleH + 5, 2, 2);
848
+
849
+ // Text — render emoji natively, pixel text for ASCII
850
+ if (hasEmoji) {
851
+ ctx.save();
852
+ ctx.font = '9px sans-serif';
853
+ ctx.textAlign = 'center';
854
+ ctx.textBaseline = 'middle';
855
+ ctx.fillStyle = '#1a3a7a';
856
+ ctx.fillText(text.slice(0, 20), this.x, by + bubbleH / 2);
857
+ ctx.restore();
858
+ } else {
859
+ const maxChars = Math.floor((bubbleW - 8) / 4);
860
+ renderPixelText(ctx, text.slice(0, maxChars), this.x, by + 4, '#1a3a7a', 1);
861
+ }
862
+ }
863
+
864
+ _renderPrayerOverlay(ctx) {
865
+ const t = this.stateTime;
866
+ const count = this.rejectionCount;
867
+ const px = PIXEL_SIZE;
868
+
869
+ // Animated sweat drops (intensity increases with rejection count)
870
+ const drops = Math.min(count + 1, 4);
871
+ ctx.fillStyle = '#60a0ff';
872
+ for (let i = 0; i < drops; i++) {
873
+ const angle = (t * 2.5 + i * (Math.PI * 2 / drops)) % (Math.PI * 2);
874
+ const radius = 12 + i * 2;
875
+ const sx = this.x + Math.cos(angle) * radius;
876
+ const sy = (this.y - SPRITE_H * 0.6) + Math.sin(angle) * 6;
877
+ const size = 1 + (Math.sin(t * 3 + i) * 0.5 + 0.5);
878
+ ctx.fillRect(sx, sy, px * size, px * size * 2);
879
+ }
880
+
881
+ // Prayer hands emoji above head (pulsing)
882
+ if (count >= 2) {
883
+ const pulse = 0.85 + Math.sin(t * 4) * 0.15;
884
+ ctx.save();
885
+ ctx.font = `${Math.round(11 * pulse)}px serif`;
886
+ ctx.textAlign = 'center';
887
+ ctx.globalAlpha = 0.9;
888
+ ctx.fillText('🙏', this.x, this.y - SPRITE_H - 18);
889
+ ctx.restore();
890
+ }
891
+
892
+ // Desperation sparkles / stress marks for count >= 3
893
+ if (count >= 3) {
894
+ ctx.fillStyle = '#ff6040';
895
+ const stressAngle = t * 5;
896
+ ctx.fillRect(this.x + Math.cos(stressAngle) * 16 - 1, this.y - SPRITE_H * 0.8 + Math.sin(stressAngle) * 8 - 1, 3, 3);
897
+ ctx.fillRect(this.x - Math.cos(stressAngle) * 14 - 1, this.y - SPRITE_H * 0.7 - Math.sin(stressAngle) * 6 - 1, 2, 2);
898
+ }
899
+ }
900
+
901
+ _renderIdleIndicator(ctx) {
902
+ const pw = PIXEL_SIZE;
903
+ const ix = this.x;
904
+ const iy = this.y - SPRITE_H - 4;
905
+
906
+ switch (this.idleBehavior) {
907
+ case 'gym':
908
+ // Dumbbell icon above head
909
+ ctx.fillStyle = '#808088';
910
+ ctx.fillRect(ix - 6, iy, 12, 2);
911
+ ctx.fillStyle = '#606068';
912
+ ctx.fillRect(ix - 8, iy - 2, 4, 6);
913
+ ctx.fillRect(ix + 4, iy - 2, 4, 6);
914
+ break;
915
+ case 'breakRoom':
916
+ // Fork/knife icon
917
+ ctx.fillStyle = '#c0c0c0';
918
+ ctx.fillRect(ix - 3, iy - 2, 1, 6);
919
+ ctx.fillRect(ix + 2, iy - 2, 1, 6);
920
+ ctx.fillRect(ix - 4, iy - 2, 2, 2);
921
+ ctx.fillRect(ix + 2, iy, 2, 1);
922
+ break;
923
+ case 'phone':
924
+ // Small phone screen rectangle near character's hand
925
+ ctx.fillStyle = '#1a1a2e';
926
+ ctx.fillRect(ix + 6, iy + 4, 5, 8); // phone body
927
+ ctx.fillStyle = '#60c0ff'; // bright screen
928
+ ctx.fillRect(ix + 7, iy + 5, 3, 5); // screen
929
+ // Screen glow flicker
930
+ if (Math.sin(this.stateTime * 4) > 0) {
931
+ ctx.fillStyle = '#a0e0ff';
932
+ ctx.fillRect(ix + 7, iy + 5, 3, 1);
933
+ }
934
+ break;
935
+ case 'stretch':
936
+ // Motion lines "~" above the character
937
+ ctx.fillStyle = '#a0a0b0';
938
+ const wave = Math.sin(this.stateTime * 6);
939
+ // Left motion line
940
+ ctx.fillRect(ix - 8, iy - 4 + wave * 2, 4, 1);
941
+ ctx.fillRect(ix - 7, iy - 3 + wave * 2, 4, 1);
942
+ // Right motion line
943
+ ctx.fillRect(ix + 4, iy - 6 - wave * 2, 4, 1);
944
+ ctx.fillRect(ix + 5, iy - 5 - wave * 2, 4, 1);
945
+ // Top motion line
946
+ ctx.fillRect(ix - 2, iy - 8 + wave, 5, 1);
947
+ break;
948
+ case 'meeting':
949
+ // Small clipboard icon above head
950
+ ctx.fillStyle = '#c8a050';
951
+ ctx.fillRect(ix - 3, iy - 2, 6, 8); // clipboard body
952
+ ctx.fillStyle = '#f0e8d0';
953
+ ctx.fillRect(ix - 2, iy, 4, 5); // paper
954
+ ctx.fillStyle = '#888';
955
+ ctx.fillRect(ix - 1, iy - 3, 2, 2); // clip
956
+ break;
957
+ }
958
+ }
959
+
960
+ /** Draw task progress bar above character sprite */
961
+ _drawProgressBar(ctx) {
962
+ const barW = 28;
963
+ const barH = 4;
964
+ const barX = this.x - barW / 2;
965
+ const barY = this.y - SPRITE_H + this.bobOffset - barH - 3;
966
+
967
+ // Background
968
+ ctx.fillStyle = '#1a1a2e';
969
+ ctx.fillRect(barX - 1, barY - 1, barW + 2, barH + 2);
970
+
971
+ // Empty track
972
+ ctx.fillStyle = '#2a2a4a';
973
+ ctx.fillRect(barX, barY, barW, barH);
974
+
975
+ // Filled portion — color shifts by progress
976
+ const p = Math.max(0, Math.min(1, this.taskProgress));
977
+ const fillW = Math.floor(barW * p);
978
+ if (fillW > 0) {
979
+ if (p < 0.3) ctx.fillStyle = '#d04040';
980
+ else if (p < 0.7) ctx.fillStyle = '#d8c020';
981
+ else ctx.fillStyle = '#40d060';
982
+ ctx.fillRect(barX, barY, fillW, barH);
983
+ // Shine
984
+ ctx.fillStyle = '#ffffff30';
985
+ ctx.fillRect(barX, barY, fillW, 1);
986
+ }
987
+
988
+ // Percentage
989
+ ctx.fillStyle = '#ffffff';
990
+ ctx.font = 'bold 5px monospace';
991
+ ctx.textAlign = 'center';
992
+ ctx.fillText(`${Math.floor(p * 100)}%`, this.x, barY + barH - 1);
993
+ ctx.textAlign = 'start';
994
+ }
995
+
996
+ /** Check if character wants a new idle behavior */
997
+ wantsIdleAction() {
998
+ return !this.isWorking && !this.isHandingOff && this.state !== 'rate_limited' && !this.idleBehavior && this.idleTimer <= 0;
999
+ }
1000
+
1001
+ hitTest(wx, wy) {
1002
+ const halfW = SPRITE_W / 2;
1003
+ return wx >= this.x - halfW && wx <= this.x + halfW &&
1004
+ wy >= this.y - SPRITE_H && wy <= this.y + 8;
1005
+ }
1006
+
1007
+ getInfo() {
1008
+ let displayState = this.state;
1009
+ if (this.isWorking) {
1010
+ const workNames = {
1011
+ working: '⚡ Working',
1012
+ designing: '📐 Designing architecture',
1013
+ reviewing: '🔍 Reviewing code',
1014
+ deploying: '🚀 Deploying',
1015
+ fighting_bug: '🐛 Fixing bug',
1016
+ celebrating: '🎉 Celebrating',
1017
+ frustrated: '😤 Frustrated (rejected)',
1018
+ waiting_input: '⏳ Waiting for input',
1019
+ handing_off: '📄 Handing off',
1020
+ };
1021
+ displayState = workNames[this.state] || '⚡ Working';
1022
+ } else if (this.idleBehavior) {
1023
+ const idleNames = {
1024
+ wander: 'Wandering around',
1025
+ gossip: 'Chatting',
1026
+ gym: 'Working out',
1027
+ breakRoom: 'Having lunch',
1028
+ coffee: 'Getting coffee',
1029
+ sleep: 'Sleeping',
1030
+ poke: 'Poking around',
1031
+ highfive: 'High five!',
1032
+ phone: 'Checking phone',
1033
+ stretch: 'Stretching',
1034
+ meeting: 'Quick meeting',
1035
+ };
1036
+ displayState = idleNames[this.idleBehavior] || this.state;
1037
+ } else {
1038
+ displayState = '🟢 Ready';
1039
+ }
1040
+
1041
+ return {
1042
+ role: this.meta.label,
1043
+ ai: this.meta.ai,
1044
+ project: this.project,
1045
+ state: displayState,
1046
+ rawState: this.state,
1047
+ color: this.meta.color,
1048
+ spriteKey: this.meta.spriteKey,
1049
+ isPM: this.role === 'pm',
1050
+ isWorking: this.isWorking,
1051
+ idleBehavior: this.idleBehavior,
1052
+ };
1053
+ }
1054
+ }
1055
+
1056
+ export { STAGE_MAP, ROLES, IDLE_BEHAVIORS, FEATURE_DISCUSS_LINES, pickTimeAwareBehavior };