coworking-agents 0.2.3 → 0.3.1

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 (46) hide show
  1. package/README.md +23 -6
  2. package/docs/achievements.png +0 -0
  3. package/docs/building.png +0 -0
  4. package/docs/camera.png +0 -0
  5. package/docs/christmas.png +0 -0
  6. package/docs/customise.png +0 -0
  7. package/docs/game-room.png +0 -0
  8. package/docs/halloween.png +0 -0
  9. package/docs/house-wall.png +0 -0
  10. package/docs/mobile.png +0 -0
  11. package/docs/office-day.png +0 -0
  12. package/docs/office-night.png +0 -0
  13. package/docs/panel.png +0 -0
  14. package/docs/paper.png +0 -0
  15. package/docs/servers.png +0 -0
  16. package/docs/talk.png +0 -0
  17. package/docs/top-bar.png +0 -0
  18. package/docs/trophy-room.png +0 -0
  19. package/docs/usage.png +0 -0
  20. package/docs/work-site.png +0 -0
  21. package/package.json +1 -1
  22. package/public/art.js +1 -1
  23. package/public/index.html +4 -0
  24. package/public/js/achievements.js +14 -3
  25. package/public/js/collection.js +21 -4
  26. package/public/js/fun2.js +233 -0
  27. package/public/js/i18n.js +56 -8
  28. package/public/js/keys.js +1 -0
  29. package/public/js/life.js +6 -3
  30. package/public/js/main.js +1 -1
  31. package/public/js/overlay.js +5 -2
  32. package/public/js/panel.js +4 -0
  33. package/public/js/personal.js +23 -9
  34. package/public/js/player.js +4 -3
  35. package/public/js/report.js +4 -1
  36. package/public/js/rooms.js +9 -5
  37. package/public/js/scene.js +1 -0
  38. package/public/js/talk.js +1 -1
  39. package/public/js/topbar.js +3 -1
  40. package/public/js/views.js +169 -0
  41. package/public/js/work.js +93 -0
  42. package/public/style.css +28 -0
  43. package/src/demo.js +3 -1
  44. package/src/focus.js +14 -1
  45. package/src/server.js +12 -1
  46. package/src/usage.js +6 -3
package/README.md CHANGED
@@ -24,7 +24,7 @@
24
24
  | ![Panel](docs/panel.png) | **Agent panel.** Now, last request and reply, last-hour timeline, today's numbers, tokens, context, files, subagents, certificates. |
25
25
  | ![Usage](docs/usage.png) | **Usage report.** Tokens per day for 14 days, all-time totals per AI and per model, rate-limit windows with reset times. |
26
26
  | ![Work site](docs/work-site.png) | **Arrivals and departures.** A new session gets its room boarded up and built (planks, dust, caution tape); a closed one walks out and its desk is demolished. |
27
- | ![Achievements](docs/achievements.png) | **Achievements.** 75 of them, five tiers each, coins for every tier, the agent of the month on top. |
27
+ | ![Achievements](docs/achievements.png) | **Achievements.** 86 of them, five tiers each, coins for every tier, the agent of the month on top. |
28
28
  | ![Paper](docs/paper.png) | **The Office Times.** Your week in headlines, written from measured history. |
29
29
  | ![Trophy room](docs/trophy-room.png) | **Trophy room.** Glass cabinets with lit shelves, legend trophies in a glass case under spotlights, a crown under a dome for Legend of Legends. Click a trophy for its card. |
30
30
  | ![Christmas](docs/christmas.png) | **Holidays.** Lights, snow and Santa hats at Christmas; pumpkins, bats and witch hats at Halloween; flags at Festa Junina. |
@@ -126,6 +126,11 @@ Agents away from their desks (kitchen, meeting room, nap corner) carry a name ta
126
126
  - **Git on the door:** each room sign shows uncommitted files (●), commits to push (↑) and to pull (↓), read with `git status` in the background (no fetch).
127
127
  - **History and daily report:** the last 24 hours of arrivals, departures, requests and finished work; a daily report with active time, tools and files that copies as Markdown for a standup; your reply times.
128
128
  - **Estimated cost:** what the tokens would cost at official API list prices (table in `src/prices.js`, dated; override in `~/.config/coworking-agents/prices.json`), priced per reply with Claude's cache-write split. Unknown models are never guessed. On a subscription this is not what you pay.
129
+ - **The queue** (`Q`): everyone waiting on you, longest wait first, with a button to answer each one.
130
+ - **Quick replies:** one click fills the reply box with "Go ahead", "Run the tests", "Commit and push" and more; you still choose to send.
131
+ - **Welcome back:** after 15+ minutes away, a card tells you what finished, who arrived or left and who is waiting.
132
+ - **Daily goal and cost alert:** set a goal of agent hours and a limit for the estimated cost of the day (daily report tab); you get told when either is reached.
133
+ - **New session:** ☰ → New session opens a Terminal tab in one of your folders and starts Claude Code or Codex (macOS; only an existing folder and those two commands are accepted).
129
134
  - **Focus mode:** sounds and system notifications pause for 25 or 50 minutes.
130
135
  - **Replay:** scrub or play back the last hour.
131
136
 
@@ -143,20 +148,31 @@ A marble portal leads to the **trophy room**, its own screen: legends with wings
143
148
 
144
149
  ![The trophy room](docs/trophy-room.png)
145
150
 
151
+ ## The elevator, the rooftop and the server room
152
+
153
+ Click the elevator (or press Enter next to it) to ride between floors, up to the **rooftop**, where agents whose turn ended relax on lounge chairs under the real sky with the city lights below, and down to the **server room**, where every open session is a rack whose LEDs blink with its real state and whose bar shows its output today. Click someone on the rooftop or a rack to open that session.
154
+
155
+ ![The server room](docs/servers.png)
156
+
146
157
  ## Achievements, coins and the shop
147
158
 
148
- - **75 achievements**, each with five tiers from bronze to legend, measured from your history (hours, actions, streaks, nights, Friday evenings, weeks and months with work, tokens, cache, models, cost), the live office (parallel sessions, subagents, giant contexts, commits and pushes, repos and branches, quick answers, clean desks) and what you do in the office (walking, talking, the radio, the shop, customising). A few are secret.
159
+ - **86 achievements**, each with five tiers from bronze to legend, measured from your history (hours, actions, streaks, nights, Friday evenings, weeks and months with work, tokens, cache, models, cost), the live office (parallel sessions, subagents, giant contexts, commits and pushes, repos and branches, quick answers, clean desks) and what you do in the office (walking, talking, the radio, the shop, customising). A few are secret.
149
160
  - **Legend of Legends** when every achievement is at legend.
150
- - **Coins:** each tier pays 10 to 50 coins (the super achievement 1000). Spend them in the **shop** on decor that appears in the office: corridor plants, a rug, a COFFEE neon, floor lamps, a disco ball, a golden statue of the agent of the month, a piano, a fountain. ☰ → **Decorate** lets you drag what you bought anywhere in the office (saved in your progress file).
161
+ - **Coins:** each tier pays 10 to 50 coins (the super achievement 1000). Spend them in the **shop** on decor that appears in the office: corridor plants, a rug, a COFFEE neon, floor lamps, a disco ball, a golden statue of the agent of the month, a piano, a fountain, a turtle terrarium, a bunny, a bigger aquarium, and clothes for your character. ☰ → **Decorate** lets you drag what you bought anywhere in the office (saved in your progress file).
151
162
  - **Agent of the month:** a framed portrait on the wall of the conversation with the most active time this month; the top 5 are in the achievements tab.
152
163
  - **The weekly paper**, *The Office Times*, tells your week in headlines, all from measured data.
164
+ - **Weekly missions:** three new challenges every week (hours, actions, days worked, commits, pushes, quick replies, pets, fast answers, long tasks), 100 coins each.
165
+ - **Your outfit:** caps, headphones, shades, a party hat, shirts and a crown from the shop; your character wears them (pick in Customise).
166
+ - **Bug Catcher:** click an arcade in the game room: catch falling bugs with your laptop, golden ones and combos score more, three misses end the game; the best score is kept.
167
+ - **Surprises:** now and then a cake, a pigeon at the window or a golden ticket shows up; click it for coins.
168
+ - **Share:** the paper tab makes a weekly card (hours, actions, trophies, a slice of the office) and a visitor photo of the office without names, branches or the office name. It also shows this week's AI scoreboard (hours, actions, tokens per AI).
153
169
  - **Progress file:** achievements, coins, purchases, avatars and settings live in `~/.config/coworking-agents/progress.json` (mode `0600`). No account needed: the app runs on your machine, so the progress is yours.
154
170
 
155
171
  ## Make it yours
156
172
 
157
173
  ![Customise](docs/customise.png)
158
174
 
159
- ☰ → **Customise** has 13 options: the office name (on a plaque by the elevator and in the top bar), your own look, floor (wood, dark wood, carpet, concrete, tiles, checker, marble, grass), walls (10 colours), room carpets (5 palettes), desks (5), the window view (city, beach, mountains, forest, space), the sky (automatic, always day, sunset, always night), lighting (natural, warm, cool, neon), the mascot (cat, dog, robot, fox, penguin, bunny), plants (normal or jungle), the radio station and the accent colour. The die next to an agent's face in its panel gives that agent a new look.
175
+ ☰ → **Customise** shows a live preview of the office on top and has 13 options: the office name (on a plaque by the elevator and in the top bar), your own look, floor (wood, dark wood, carpet, concrete, tiles, checker, marble, grass), walls (10 colours), room carpets (5 palettes), desks (5), the window view (city, beach, mountains, forest, space), the sky (automatic, always day, sunset, always night), lighting (natural, warm, cool, neon), the mascot (cat, dog, robot, fox, penguin, bunny), plants (normal or jungle), the radio station and the accent colour. The die next to an agent's face in its panel gives that agent a new look.
160
176
 
161
177
  ## A living office
162
178
 
@@ -166,7 +182,8 @@ A marble portal leads to the **trophy room**, its own screen: legends with wings
166
182
  - **Holidays:** Christmas, New Year, Halloween, Festa Junina, Carnaval and the office's birthday decorate the room and put hats on everyone (`E` turns decor off).
167
183
  - **Pets:** the mascot visits whoever has waited on you the longest and likes being petted; a dog joins at 8 achievements at gold, a parrot at 16.
168
184
  - **Certificates** for the skills and MCP servers each session used hang on its partition.
169
- - **Office photo:** ☰ → Office photo saves the scene as a PNG. And something happens if you click the `</>` neon three times.
185
+ - **Office photo:** ☰ → Office photo saves the scene as a PNG, and the last four hang as framed photos on the game room wall.
186
+ - **Weather indoors:** umbrellas by the elevator when it rains outside (real weather on), coats on the rack in winter. And something happens if you click the `</>` neon three times.
170
187
  - **Languages:** English and Brazilian Portuguese.
171
188
 
172
189
  <p align="center"><img src="docs/mobile.png" alt="The office on a phone" width="300"></p>
@@ -178,7 +195,7 @@ A marble portal leads to the **trophy room**, its own screen: legends with wings
178
195
  | `1`–`9` | open the n-th agent |
179
196
  | `N` | next agent that needs you (opens its talk modal) |
180
197
  | `/` | quick search by name, title, repository or state |
181
- | `R` / `T` / `G` / `C` | usage report / daily report (today) / history / achievements |
198
+ | `Q` / `R` / `T` / `G` / `C` | queue / usage report / daily report (today) / history / achievements |
182
199
  | WASD or arrows, Shift, Enter | walk, run, interact with what's near you |
183
200
  | `F` | focus mode (25 min, again 50, again off) |
184
201
  | `H` | replay the last hour |
Binary file
package/docs/building.png CHANGED
Binary file
package/docs/camera.png CHANGED
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
package/docs/mobile.png CHANGED
Binary file
Binary file
Binary file
package/docs/panel.png CHANGED
Binary file
package/docs/paper.png CHANGED
Binary file
Binary file
package/docs/talk.png CHANGED
Binary file
package/docs/top-bar.png CHANGED
Binary file
Binary file
package/docs/usage.png CHANGED
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coworking-agents",
3
- "version": "0.2.3",
3
+ "version": "0.3.1",
4
4
  "description": "A pixel-art coworking office for your AI coding agents (Claude Code, Codex, …): see who's working, who needs you, and who's editing the same checkout.",
5
5
  "bin": {
6
6
  "coworking-agents": "bin/coworking-agents.js"
package/public/art.js CHANGED
@@ -759,7 +759,7 @@ function aiIconEl(id, color) {
759
759
 
760
760
  window.Art = {
761
761
  aiIcon, aiIconEl, portrait, drawArcade, drawDiagramBoard, drawNeon, drawPoster, drawServerRack, drawDuck, drawSideMonitor, drawStickies, drawBeanSack, drawPizza, drawCupStack,
762
- PAL, SKIN, HAIR, SHIRT, SCREEN_GLOW, hash, shade, look, setLookSeeds, setHat, setCtx, r, sprite, blit,
762
+ PAL, SKIN, HAIR, SHIRT, SCREEN_GLOW, hash, shade, look, setLookSeeds, setHat, drawHat, setCtx, r, sprite, blit,
763
763
  drawSeatedBack, drawFront, drawStanding, drawSleeping, drawChairBack, drawChairBase, drawCat, bubble, drawScreen,
764
764
  drawPlant, drawCertificate, drawLamp, drawWhiteboard, drawCork, drawClock, skyFor, drawWindow, drawSofa, drawCoffeeMachine,
765
765
  drawCooler, drawRug, drawFloor, applyLight, LEGS_SIT,
package/public/index.html CHANGED
@@ -30,6 +30,7 @@
30
30
  <button id="btn-replay" class="icon-btn"><svg viewBox="0 0 16 16" shape-rendering="crispEdges" aria-hidden="true"><path fill="currentColor" d="M6 1h4v1h2v1h1v2h1v6h-1v2h-1v1h-2v1H6v-1H4v-1H3v-2H2V9h2v2h1v1h1v1h4v-1h1v-1h1V5h-1V4h-1V3H6v1H5v1H4v1h2v2H1V3h2v1h1V3h1V2h1zM7 5h2v3h2v2H7z"/></svg><span class="sr"></span></button>
31
31
  <button id="btn-search" class="icon-btn"><svg viewBox="0 0 16 16" shape-rendering="crispEdges" aria-hidden="true"><path fill="currentColor" d="M5 1h4v1h1v1h1v4h-1v1H9v1H5V8H4V7H3V3h1V2h1zm0 2v4h4V3zM10 9h1v1h1v1h1v1h1v1h-1v1h-1v-1h-1v-1h-1v-1H9v-1h1z"/></svg><span class="sr"></span></button>
32
32
  <button id="btn-photo" class="icon-btn"><svg viewBox="0 0 16 16" shape-rendering="crispEdges" aria-hidden="true"><path fill="currentColor" d="M5 2h6v2h3v10H2V4h3zm3 4H7v1H6v3h1v1h2v-1h1V7H9V6z"/></svg><span class="sr"></span></button>
33
+ <button id="btn-launch" class="icon-btn"><svg viewBox="0 0 16 16" shape-rendering="crispEdges" aria-hidden="true"><path fill="currentColor" d="M1 2h14v12H1zm2 2v8h10V4zm1 1h2v1h1v1H6v1H4V7h1V6H4zm4 3h3v1H8z"/></svg><span class="sr"></span></button>
33
34
  <button id="btn-deco" class="icon-btn"><svg viewBox="0 0 16 16" shape-rendering="crispEdges" aria-hidden="true"><path fill="currentColor" d="M7 1h2v3h2l-3 3-3-3h2zM1 7h3V5l3 3-3 3V9H1zm11-2 3 3-3 3V9H9V7h3zM5 12l3-3 3 3H9v3H7v-3z"/></svg><span class="sr"></span></button>
34
35
  <button id="btn-office" class="icon-btn"><svg viewBox="0 0 16 16" shape-rendering="crispEdges" aria-hidden="true"><path fill="currentColor" d="M7 1h2v2h2v1h2v2h-1v2h1v2h-2v2h-1v1H9v2H7v-2H5v-1H4v-2H3V8h1V6H3V4h2V3h2zm0 5v1H6v2h1v1h2V9h1V7H9V6z"/></svg><span class="sr"></span></button>
35
36
  <button id="btn-lang" class="icon-btn lang"></button>
@@ -75,6 +76,9 @@
75
76
  <script src="js/personal.js"></script>
76
77
  <script src="js/rooms.js"></script>
77
78
  <script src="js/player.js"></script>
79
+ <script src="js/work.js"></script>
80
+ <script src="js/views.js"></script>
81
+ <script src="js/fun2.js"></script>
78
82
  <script src="js/main.js"></script>
79
83
  </body>
80
84
  </html>
@@ -42,7 +42,7 @@ function achFacts() {
42
42
  skillsUsed: Math.max(0, ...cred.map(c => (c.topSkills || []).length)),
43
43
  commits: achCount('commits'), pushes: achCount('pushes'), repos: achCount('repos'), branches: achCount('branches'), cleanDesk: achCount('cleanDesk'), talks: achCount('talks'),
44
44
  sprints: achCount('sprints'), neon: achCount('neon'), tabPaper: achCount('tab_paper'), tabUsage: achCount('tab_usage'), tabToday: achCount('tab_today'), tabFeed: achCount('tab_feed'), tabShop: achCount('tab_shop'),
45
- spent: prog.spent, moves: achCount('moves'), themesTried: achCount('themesTried'), midnight: achCount('midnight'),
45
+ spent: prog.spent, moves: achCount('moves'), missionsDone: achCount('missionsDone'), arcadeBest: achCount('arcadeBest'), surprises: achCount('surprises'), outfits: achCount('outfits'), shares: achCount('shares'), terraceVisits: achCount('terraceVisits'), serverVisits: achCount('serverVisits'), lifts: achCount('lifts'), quickReplies: achCount('quickReplies'), goals: achCount('goals'), launches: achCount('launches'), themesTried: achCount('themesTried'), midnight: achCount('midnight'),
46
46
  dayHours: Math.max(0, ...vals.map(d => d.activeMs / 36e5)),
47
47
  dayTools: Math.max(0, ...vals.map(d => d.tools)),
48
48
  dayOut: Math.max(0, ...vals.map(d => d.output || 0)),
@@ -136,6 +136,17 @@ const ACH = [
136
136
  { id: 'historian', g: 'you', icon: '📜', fact: 'tabFeed', steps: [1, 10, 30, 100, 300] },
137
137
  { id: 'windowshop', g: 'you', icon: '🪟', fact: 'tabShop', steps: [1, 10, 30, 100, 300] },
138
138
  { id: 'spender', g: 'you', icon: '💸', fact: 'spent', steps: [200, 1000, 3000, 6000, 10000] },
139
+ { id: 'quick', g: 'you', icon: '⚡', fact: 'quickReplies', steps: [1, 10, 50, 200, 500] },
140
+ { id: 'goalgetter', g: 'you', icon: '🎯', fact: 'goals', steps: [1, 5, 20, 60, 150] },
141
+ { id: 'founder', g: 'you', icon: '🧑‍💻', fact: 'launches', steps: [1, 5, 20, 60, 150] },
142
+ { id: 'rooftop', g: 'you', icon: '🌇', fact: 'terraceVisits', steps: [1, 10, 30, 100, 300] },
143
+ { id: 'sysadmin', g: 'you', icon: '🖧', fact: 'serverVisits', steps: [1, 10, 30, 100, 300] },
144
+ { id: 'liftboy', g: 'you', icon: '🛗', fact: 'lifts', steps: [1, 10, 50, 150, 500] },
145
+ { id: 'missionary', g: 'you', icon: '🎯', fact: 'missionsDone', steps: [1, 5, 15, 40, 100] },
146
+ { id: 'gamer', g: 'you', icon: '🕹', fact: 'arcadeBest', steps: [10, 25, 50, 80, 120] },
147
+ { id: 'lucky', g: 'you', icon: '🍀', fact: 'surprises', steps: [1, 10, 30, 100, 300] },
148
+ { id: 'fashion', g: 'you', icon: '🎩', fact: 'outfits', steps: [1, 5, 20, 50, 100] },
149
+ { id: 'sharer', g: 'you', icon: '📤', fact: 'shares', steps: [1, 5, 20, 50, 100] },
139
150
  { id: 'arranger', g: 'you', icon: '✥', fact: 'moves', steps: [1, 10, 30, 100, 300] },
140
151
  { id: 'themer', g: 'you', icon: '🖌', fact: 'themesTried', steps: [2, 5, 10, 20, 40] },
141
152
  { id: 'dj', g: 'you', icon: '📻', fact: 'radio', steps: [1, 5, 20, 50, 100] },
@@ -151,7 +162,7 @@ const ACH = [
151
162
  const TIER_COINS = [0, 10, 20, 30, 40, 50];
152
163
  const superDone = st => st.every(a => a.tier >= 5);
153
164
  function coinsEarned(st = achState()) { return st.reduce((n, a) => n + TIER_COINS.slice(1, a.tier + 1).reduce((x, y) => x + y, 0), 0) + (superDone(st) ? 1000 : 0); }
154
- const coins = () => Math.max(0, coinsEarned() - prog.spent);
165
+ const coins = () => Math.max(0, coinsEarned() + (Number(prog.record.missionCoins) || 0) + (Number(prog.record.eventCoins) || 0) - prog.spent);
155
166
 
156
167
  function achState() {
157
168
  const f = achFacts();
@@ -239,7 +250,7 @@ function renderAchievements() {
239
250
  const legends = st.filter(a => a.tier >= 5).length, done = superDone(st);
240
251
  const superCard = `<div class="ach super ${done ? 'on' : ''}"><span class="ach-ico">👑</span><div><b>${esc(A.superName)}</b><small>${esc(done ? A.tiers[5] : A.locked)}</small><p>${esc(done ? A.superDone : A.superDesc)}</p><div class="ach-bar"><i style="width:${(legends / st.length * 100).toFixed(1)}%"></i></div><div class="ach-foot"><span></span><span>${legends} / ${st.length}</span></div></div></div>`;
241
252
  const wallet = `<div class="wallet"><span class="coin"></span><b>${coins().toLocaleString(lang === 'pt' ? 'pt-BR' : 'en')}</b> ${esc(A.coins)}<span class="note">${esc(A.coinsHow)}</span></div>`;
242
- body.innerHTML = tabsHtml() + `${wallet}${superCard}<h3>${esc(A.aotm)} · ${esc(monthName)}</h3><p class="sub">${esc(A.aotmSub)}</p>${podium}
253
+ body.innerHTML = tabsHtml() + `${wallet}${missionsHtml()}${superCard}<h3>${esc(A.aotm)} · ${esc(monthName)}</h3><p class="sub">${esc(A.aotmSub)}</p>${podium}
243
254
  <h3>${esc(A.title)} · ${got}/${all}</h3><p class="sub">${esc(A.sub)}</p>
244
255
  ${['history', 'live', 'you'].map(g => `<h4 class="ach-g">${esc(A.groups[g])}</h4><div class="achs">${st.filter(a => a.g === g).sort((x, y) => (x.secret && !x.tier) - (y.secret && !y.tier) || y.tier / y.max - x.tier / x.max || y.progress - x.progress).map(card).join('')}</div>`).join('')}`;
245
256
  body.querySelectorAll('[data-face]').forEach(el => el.appendChild(Art.portrait(el.dataset.face)));
@@ -32,7 +32,7 @@ function renderPaper() {
32
32
  const st = achState(), legends = st.filter(a => a.tier >= 5).length;
33
33
  const story = (h, p) => `<article><h4>${esc(h)}</h4><p>${esc(p)}</p></article>`;
34
34
  const today = new Date().toLocaleDateString(loc, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
35
- body.innerHTML = tabsHtml() + `<div class="paper">
35
+ body.innerHTML = tabsHtml() + `<div class="rhead"><span></span><span><button class="btn small" data-share-card>📤 ${esc(T.share.card)}</button> <button class="btn small" data-visit-photo>📷 ${esc(T.share.visit)}</button></span></div><div class="paper">
36
36
  <header><small>${esc(today)}</small><h2>${esc(P.name)}</h2><small>${esc(P.edition(keys.length))}</small></header>
37
37
  <div class="lead"><h3>${esc(P.headline(hrs(total)))}</h3><p>${esc(P.leadText(fmtK(tools), fmtK(out), keys.length))}</p></div>
38
38
  <div class="cols">
@@ -40,6 +40,7 @@ function renderPaper() {
40
40
  ${top ? story(P.star((live && live.name) || top.title || top.id.slice(0, 8)), P.starText(hrs(top.activeMs), fmtK(top.tools))) : ''}
41
41
  ${night ? story(P.owls, P.owlsText(night)) : story(P.sleep, P.sleepText)}
42
42
  ${both ? story(P.duo, P.duoText(both)) : ''}
43
+ ${scoreboardHtml()}
43
44
  ${story(P.trophies, P.trophiesText(st.reduce((n, a) => n + a.tier, 0), legends, coins()))}
44
45
  ${(() => { const k = new Date().toISOString().slice(0, 10), d = replies[k]; return d && d.n ? story(P.replies, P.repliesText(fmtDur(d.total / d.n), fmtDur(d.best))) : ''; })()}
45
46
  </div>
@@ -48,7 +49,7 @@ function renderPaper() {
48
49
 
49
50
  // ---- pets unlocked by achievements: a dog at 8 achievements at gold or better, a parrot at 16 ----
50
51
  const goldCount = () => achState().filter(a => a.tier >= 3).length;
51
- const dog = { x: 0, y: 0, tx: 0, ty: 0, until: 0, ready: false };
52
+ const dog = { x: 0, y: 0, tx: 0, ty: 0, until: 0, ready: false }, bunny = { x: 0, y: 0, tx: 0, ty: 0, until: 0, ready: false };
52
53
  function drawPets(t, movers) {
53
54
  const g = goldCount();
54
55
  if (g >= 8 && prog.mascot !== 'dog') {
@@ -60,6 +61,13 @@ function drawPets(t, movers) {
60
61
  movers.push({ y: dog.y, draw: () => drawDog(dog.x, dog.y, t, dog.flip, moving) });
61
62
  }
62
63
  if (g >= 16) drawParrot(RX + RW - 30, TOP - 26, t);
64
+ if (owns('bunnypet') && prog.mascot !== 'bunny') {
65
+ if (!bunny.ready) Object.assign(bunny, { x: RX + 60, y: TOP + 100, tx: RX + 60, ty: TOP + 100, ready: true });
66
+ const dx = bunny.tx - bunny.x, dy = bunny.ty - bunny.y, d = Math.hypot(dx, dy);
67
+ if (d > 1) { const hop = ((t / 200) | 0) % 2; bunny.x += dx / d * (hop ? 1.4 : 0); bunny.y += dy / d * (hop ? 1.4 : 0); bunny.flip = dx < 0; }
68
+ else if (Date.now() > bunny.until) { bunny.tx = RX + 10 + Math.random() * (RW - 30); bunny.ty = wing.ping - 20 + Math.random() * 16; bunny.until = Date.now() + 3000 + Math.random() * 5000; }
69
+ movers.push({ y: bunny.y, draw: () => drawCritter('bunny', bunny.x, bunny.y, d > 1 ? 'walk' : 'sit', t, bunny.flip) });
70
+ }
63
71
  }
64
72
  function drawDog(x, y, t, flip, moving) {
65
73
  const f = moving ? ((t / 120) | 0) % 2 : 0, c = '#c98f5a', d = '#8a5a3a';
@@ -87,6 +95,11 @@ const SHOP = [
87
95
  { id: 'statue', price: 800, icon: '🗽' },
88
96
  { id: 'piano', price: 1200, icon: '🎹' },
89
97
  { id: 'fountain', price: 2000, icon: '⛲' },
98
+ { id: 'turtle', price: 400, icon: '🐢' },
99
+ { id: 'bunnypet', price: 600, icon: '🐇' },
100
+ { id: 'bigtank', price: 900, icon: '🐠' },
101
+ { id: 'cap', price: 150, icon: '🧢' }, { id: 'headphones', price: 300, icon: '🎧' }, { id: 'shades', price: 250, icon: '🕶' }, { id: 'party', price: 200, icon: '🥳' },
102
+ { id: 'shirt_pink', price: 300, icon: '👕' }, { id: 'shirt_black', price: 300, icon: '👕' }, { id: 'shirt_gold', price: 400, icon: '👕' }, { id: 'crown', price: 1500, icon: '👑' },
90
103
  ];
91
104
  const owns = id => prog.owned.includes(id);
92
105
  function buy(id) {
@@ -103,7 +116,7 @@ function renderShop() {
103
116
  ${has ? `<small class="ok">${esc(S2.owned)}</small> <button class="btn small" data-move-item>${esc(S2.move)}</button>` : `<button class="btn small" data-buy="${it.id}" ${can ? '' : 'disabled'}><span class="coin"></span> ${it.price}</button>`}</div></div>`; }).join('')}</div>
104
117
  ${prog.owned.length ? `<p><button class="btn small" data-move-item>✥ ${esc(S2.decoTitle)}</button></p>` : ''}`;
105
118
  }
106
- reportEl.addEventListener('click', e => { const b = e.target.closest('[data-buy]'); if (b) buy(b.dataset.buy); if (e.target.closest('[data-move-item]')) setDecorating(true); });
119
+ reportEl.addEventListener('click', e => { if (e.target.closest('[data-share-card]')) return shareCard(); if (e.target.closest('[data-visit-photo]')) return visitPhoto(); const b = e.target.closest('[data-buy]'); if (b) buy(b.dataset.buy); if (e.target.closest('[data-move-item]')) setDecorating(true); });
107
120
 
108
121
  // bought items, drawn in fixed places (floor items before the people, so people walk in front)
109
122
  // ---- bought items: each one has a default spot and can be moved in decorate mode ----
@@ -118,6 +131,7 @@ const ITEMS = {
118
131
  fountain: { w: 26, h: 20, at: () => ({ x: CX - 12, y: H - 72 }) },
119
132
  neon: { w: 30, h: 8, at: () => ({ x: Math.min(W - 34, RX + 150 > W - 40 ? RX + 2 : RX + 150), y: 2 }) },
120
133
  disco: { w: 8, h: 14, at: () => ({ x: CX - 3, y: TOP - 2 }) },
134
+ turtle: { w: 24, h: 16, at: () => ({ x: RX + 110, y: wing.copa + 30 }) },
121
135
  };
122
136
  function itemPos(id) {
123
137
  const it = ITEMS[id], p = prog.placed && prog.placed[id];
@@ -125,6 +139,9 @@ function itemPos(id) {
125
139
  return { x: Math.max(0, Math.min(W - it.w, pos.x)), y: Math.max(0, Math.min(H - it.h, pos.y)), w: it.w, h: it.h };
126
140
  }
127
141
  const ITEM_DRAW = {
142
+ turtle: (x, y, t) => { r(x - 1, y - 1, 26, 18, PAL.ink); r(x, y, 24, 16, '#bfe3f0'); r(x, y + 11, 24, 5, '#c9a86b'); r(x + 3, y + 8, 1, 4, '#3a8f46'); r(x + 19, y + 7, 1, 5, '#3a8f46');
143
+ const tx = x + 6 + Math.round((Math.sin(t / 3000) + 1) * 4); r(tx, y + 8, 8, 4, '#3a6a2a'); r(tx + 1, y + 7, 6, 1, '#5a8a3a'); r(tx + 8, y + 9, 2, 2, '#7aa04a'); r(tx + 1, y + 12, 1, 1, '#7aa04a'); r(tx + 6, y + 12, 1, 1, '#7aa04a');
144
+ ctx.fillStyle = 'rgba(255,255,255,.18)'; ctx.fillRect(x + 2, y + 1, 2, 9); },
128
145
  rug: (x, y) => drawPersianRug(x, y + 3, 76, 44),
129
146
  plants: (x, y) => { Art.drawPlant(x, y + 2, true); Art.drawPlant(x + 13, y, true); Art.drawPlant(x + 26, y + 3, false); },
130
147
  lamps: (x, y, t, glows) => { for (const lx of [x, x + 16]) { r(lx + 3, y + 4, 2, 14, PAL.ink); r(lx, y, 8, 5, '#ffd84d'); glows.push({ x: lx + 4, y: y + 2, r: 22, c: '#ffd84d' }); } },
@@ -199,7 +216,7 @@ const partyBounce = (id, t) => partying() ? -Math.abs(Math.round(Math.sin(t / 11
199
216
  // ---- group photo: the office canvas as a PNG ----
200
217
  function groupPhoto() {
201
218
  const cv = document.getElementById('cv');
202
- achBump('photos');
219
+ achBump('photos'); savePhotoThumb();
203
220
  cv.toBlob(b => {
204
221
  if (!b) return;
205
222
  const a = document.createElement('a'), d = new Date();
@@ -0,0 +1,233 @@
1
+ 'use strict';
2
+ // ---------------- weekly missions, your outfit, the arcade game, surprise events, the AI scoreboard, sharing ----------------
3
+
4
+ // ---- weekly missions: three per week, picked by the week number, measured; 100 coins each ----
5
+ const weekKey = (d = new Date()) => { const j = new Date(d.getFullYear(), 0, 1); return d.getFullYear() + '-W' + Math.floor((d - j) / 6048e5 + 1); };
6
+ const weekDays = () => { const now = new Date(), out = []; const dow = (now.getDay() + 6) % 7; for (let i = 0; i <= dow; i++) { const d = new Date(now - (dow - i) * 864e5); out.push(d.toISOString().slice(0, 10)); } return out; };
7
+ // counters measured since the week began: the value at the first look this week is the baseline
8
+ function weekDelta(k) {
9
+ const wk = weekKey(), base = store.get('missionBase', null);
10
+ if (!base || base.week !== wk) { const b = { week: wk, v: {} }; for (const c of ['commits', 'pushes', 'talks', 'quickReplies', 'pets', 'fast', 'celebrations', 'trophyVisits']) b.v[c] = achCount(c); store.set('missionBase', b); return 0; }
11
+ return Math.max(0, achCount(k) - (base.v[k] || 0));
12
+ }
13
+ const MISSIONS = [
14
+ { id: 'hours', goal: 20, value: () => weekDays().reduce((n, k) => n + ((usageData && usageData.days && usageData.days[k] && usageData.days[k].activeMs) || 0), 0) / 36e5 },
15
+ { id: 'tools', goal: 3000, value: () => weekDays().reduce((n, k) => n + ((usageData && usageData.days && usageData.days[k] && usageData.days[k].tools) || 0), 0) },
16
+ { id: 'days', goal: 4, value: () => weekDays().filter(k => usageData && usageData.days && usageData.days[k] && usageData.days[k].tools >= 10).length },
17
+ { id: 'commits', goal: 5, value: () => weekDelta('commits') },
18
+ { id: 'pushes', goal: 3, value: () => weekDelta('pushes') },
19
+ { id: 'talks', goal: 10, value: () => weekDelta('talks') },
20
+ { id: 'quick', goal: 5, value: () => weekDelta('quickReplies') },
21
+ { id: 'pets', goal: 10, value: () => weekDelta('pets') },
22
+ { id: 'fast', goal: 5, value: () => weekDelta('fast') },
23
+ { id: 'deliver', goal: 5, value: () => weekDelta('celebrations') },
24
+ ];
25
+ function weekMissions() {
26
+ const wk = weekKey(), h = hash(wk), pool = MISSIONS.slice(), out = [];
27
+ for (let i = 0; i < 3; i++) out.push(pool.splice((h >>> (i * 5)) % pool.length, 1)[0]);
28
+ return out.map(m => { const v = m.value(), done = achCount('missionsDone') && (prog.record.missionsDone || []).includes(wk + ':' + m.id); return { ...m, v, done: done || v >= m.goal, claimed: done, key: wk + ':' + m.id }; });
29
+ }
30
+ function checkMissions() {
31
+ if (!usageData || !usageData.days) return;
32
+ for (const m of weekMissions()) if (!m.claimed && m.v >= m.goal) {
33
+ achAdd('missionsDone', m.key); achBump('missionCoins', 100);
34
+ toast(`<b>${esc(T.missions.done)}</b>${esc(T.missions.list[m.id](m.goal))} · +100 ${esc(T.ach.coins)}`, 'ok'); playTune('trophy');
35
+ }
36
+ }
37
+ setInterval(checkMissions, 30000);
38
+ function missionsHtml() {
39
+ const M = T.missions, list = weekMissions();
40
+ return `<h3>${esc(M.title)}</h3><p class="sub">${esc(M.sub)}</p><div class="achs">${list.map(m => `<div class="ach ${m.done ? 'on' : ''}" style="--c:${m.done ? '#63c74d' : '#ffd84d'}"><span class="ach-ico">${m.done ? '✓' : '🎯'}</span><div><b>${esc(M.list[m.id](m.goal))}</b><small>${esc(m.done ? M.claimed : '+100 ' + T.ach.coins)}</small>
41
+ <div class="ach-bar"><i style="width:${Math.min(100, m.v / m.goal * 100).toFixed(1)}%"></i></div><div class="ach-foot"><span></span><span>${Math.floor(Math.min(m.v, m.goal))} / ${m.goal}</span></div></div></div>`).join('')}</div>`;
42
+ }
43
+
44
+ // ---- your outfit: hats, shades and shirts from the shop, worn by your character ----
45
+ const OUTFIT = { cap: 'hat', crown: 'hat', headphones: 'hat', party: 'hat', shades: 'face', shirt_gold: 'shirt', shirt_pink: 'shirt', shirt_black: 'shirt' };
46
+ const SHIRTS = { shirt_gold: '#d9a441', shirt_pink: '#e8579a', shirt_black: '#2a2630' };
47
+ function wearing(slot) { const w = (prog.style || {})['wear_' + slot]; return w && owns(w) ? w : ''; }
48
+ function youLook() {
49
+ const lk = Art.look('you:' + (avatars.__you || 0)), sh = wearing('shirt');
50
+ return sh ? { ...lk, c: SHIRTS[sh], C: Art.shade(SHIRTS[sh], .72) } : lk;
51
+ }
52
+ function drawOutfit(x, y) { // over the standing sprite's head (top at y - 2)
53
+ const hat = wearing('hat');
54
+ if (hat === 'cap') { r(x + 2, y - 3, 12, 4, PAL.ink); r(x + 3, y - 2, 10, 2, '#e43b44'); r(x + 10, y, 7, 2, PAL.ink); r(x + 11, y, 5, 1, '#e43b44'); }
55
+ if (hat === 'crown') { r(x + 3, y - 4, 10, 4, '#ffd84d'); r(x + 3, y - 6, 2, 2, '#ffd84d'); r(x + 7, y - 7, 2, 3, '#ffd84d'); r(x + 11, y - 6, 2, 2, '#ffd84d'); r(x + 7, y - 3, 2, 1, '#e43b44'); }
56
+ if (hat === 'headphones') { r(x + 1, y - 3, 14, 2, PAL.ink); r(x, y + 3, 3, 5, '#3b5dc9'); r(x + 13, y + 3, 3, 5, '#3b5dc9'); }
57
+ if (hat === 'party') Art.drawHat('party', x, y - 2);
58
+ if (wearing('face') === 'shades') { r(x + 3, y + 5, 10, 2, PAL.ink); r(x + 4, y + 5, 3, 1, '#3a4566'); r(x + 9, y + 5, 3, 1, '#3a4566'); }
59
+ }
60
+ function outfitHtml() {
61
+ const O = T.outfit, have = Object.keys(OUTFIT).filter(owns);
62
+ if (!have.length) return `<p class="note">${esc(O.none)}</p>`;
63
+ const slot = s => `<div class="opts"><button class="opt ${!wearing(s) ? 'on' : ''}" data-wear="${s}" data-item="">${esc(O.nothing)}</button>${have.filter(i => OUTFIT[i] === s).map(i => `<button class="opt ${wearing(s) === i ? 'on' : ''}" data-wear="${s}" data-item="${i}">${esc(T.shop.items[i].name)}</button>`).join('')}</div>`;
64
+ return ['hat', 'face', 'shirt'].filter(s => have.some(i => OUTFIT[i] === s)).map(s => `<small class="note">${esc(O.slots[s])}</small>${slot(s)}`).join('');
65
+ }
66
+ reportEl.addEventListener('click', e => {
67
+ const w = e.target.closest('[data-wear]'); if (!w) return;
68
+ prog.style = { ...(prog.style || {}), ['wear_' + w.dataset.wear]: w.dataset.item }; saveProgress(); achBump('outfits');
69
+ reportEl.querySelectorAll(`[data-wear="${w.dataset.wear}"]`).forEach(b => b.classList.toggle('on', b === w));
70
+ const f = document.getElementById('you-face'); if (f) { f.innerHTML = ''; f.appendChild(youPortrait()); }
71
+ });
72
+ function youPortrait() { // the portrait with the outfit drawn on top
73
+ const c = Art.portrait('you:' + (avatars.__you || 0)), g = c.getContext('2d'), prev = ctx;
74
+ const sh = wearing('shirt'); if (sh) { g.fillStyle = SHIRTS[sh]; g.fillRect(1, 12, 14, 6); }
75
+ ctx = g; Art.setCtx(g); try { drawOutfit(0, 2); } finally { ctx = prev; Art.setCtx(prev); }
76
+ return c;
77
+ }
78
+
79
+ // ---- the arcade: Bug Catcher. Catch falling bugs with your laptop; 3 lives, gentle start, speeds up slowly ----
80
+ const arcadeEl = document.createElement('div');
81
+ arcadeEl.className = 'trophy-card'; arcadeEl.hidden = true;
82
+ document.body.appendChild(arcadeEl);
83
+ const AW = 240, AH = 160;
84
+ let game2 = null;
85
+ function openArcade() {
86
+ arcadeEl.innerHTML = `<div class="tc-box arcade-box"><div><b>🕹 ${esc(T.arcade.title)}</b><p>${esc(T.arcade.help)}</p><canvas width="${AW}" height="${AH}" class="arcade-cv"></canvas>
87
+ <div class="ach-foot"><span id="arcade-score"></span><span>${esc(T.arcade.best)}: <b id="arcade-best">${Number(store.get('arcadeBest', 0)) || 0}</b></span></div><button class="btn small" data-arcade-start>▶ ${esc(T.arcade.start)}</button></div></div>`;
88
+ arcadeEl.hidden = false; achBump('arcadeOpens');
89
+ drawArcadeTitle();
90
+ }
91
+ function drawArcadeTitle() {
92
+ const c = arcadeEl.querySelector('.arcade-cv'); if (!c) return;
93
+ const g = c.getContext('2d'); arcadeBg(g, performance.now());
94
+ gText(g, AW / 2, 46, 'BUG CATCHER', '#ffd84d', 3, true);
95
+ gText(g, AW / 2, 78, lang === 'pt' ? 'ESPACO OU ENTER' : 'SPACE OR ENTER', '#c0cbdc', 1, true);
96
+ drawBug(g, AW / 2 - 30, 100, false, 0); drawBug(g, AW / 2 - 4, 104, true, 1); drawBug(g, AW / 2 + 22, 100, false, 0);
97
+ }
98
+ // crisp text: the office's pixel font, scaled by whole pixels
99
+ function gText(g, x, y, str, c, k = 1, center = false) {
100
+ const txt = String(str).toUpperCase(); if (center) x -= Math.round(txt.length * 4 * k / 2);
101
+ g.fillStyle = c; let cx = Math.round(x);
102
+ for (const ch of txt) { const gl = PIXFONT[ch]; if (gl) for (let i = 0; i < 15; i++) if (gl[i] === '1') g.fillRect(cx + (i % 3) * k, Math.round(y) + ((i / 3) | 0) * k, k, k); cx += 4 * k; }
103
+ }
104
+ function arcadeBg(g, now) {
105
+ g.fillStyle = '#141826'; g.fillRect(0, 0, AW, AH);
106
+ g.fillStyle = '#1c2236'; for (let y = 0; y < AH; y += 8) { const sh = (y * 13) % 70; g.fillRect(6 + sh % 20, Math.round((y + now / 60) % AH), 30 + sh, 2); } // code scrolling behind
107
+ g.fillStyle = '#2a2f45'; g.fillRect(0, AH - 8, AW, 8);
108
+ }
109
+ function drawBug(g, x, y, gold, f) {
110
+ x = Math.round(x); y = Math.round(y);
111
+ const body = gold ? '#ffd84d' : '#63c74d', dark = gold ? '#b07d2a' : '#2f7a3b', leg = '#c0cbdc';
112
+ g.fillStyle = leg; for (const [dx, dy] of [[-3, 3], [-3, 6], [11, 3], [11, 6]]) g.fillRect(x + dx + (f ? (dx < 0 ? -1 : 1) : 0), y + dy + (f ? 1 : 0), 4, 1); // legs wiggle
113
+ g.fillStyle = '#0b0a0e'; g.fillRect(x - 1, y - 1, 13, 11);
114
+ g.fillStyle = dark; g.fillRect(x, y, 11, 9); g.fillStyle = body; g.fillRect(x + 1, y + 1, 4, 7); g.fillRect(x + 6, y + 1, 4, 7); // shell, split down the middle
115
+ g.fillStyle = '#ffffff'; g.fillRect(x + 2, y + 2, 1, 2); g.fillRect(x + 7, y + 2, 1, 2);
116
+ g.fillStyle = '#3a4058'; g.fillRect(x + 2, y - 4, 7, 4); g.fillStyle = '#ffffff'; g.fillRect(x + 3, y - 3, 1, 1); g.fillRect(x + 7, y - 3, 1, 1); // head and eyes
117
+ g.fillStyle = leg; g.fillRect(x + 2, y - 7, 1, 3); g.fillRect(x + 8, y - 7, 1, 3); // antennae
118
+ }
119
+ function startArcade() {
120
+ const cv2 = arcadeEl.querySelector('.arcade-cv'); if (!cv2) return;
121
+ game2 = { cv: cv2, g: cv2.getContext('2d'), x: AW / 2 - 14, bugs: [], sparks: [], score: 0, lives: 3, combo: 0, t0: performance.now(), last: performance.now(), keys: new Set(), spawn: 0 };
122
+ requestAnimationFrame(tickArcade);
123
+ }
124
+ function tickArcade(now) {
125
+ const G = game2; if (!G || arcadeEl.hidden) { game2 = null; return; }
126
+ const dt = Math.min(.05, (now - G.last) / 1000), age = (now - G.t0) / 1000; G.last = now;
127
+ if (G.keys.has('ArrowLeft') || G.keys.has('a')) G.x -= 190 * dt; if (G.keys.has('ArrowRight') || G.keys.has('d')) G.x += 190 * dt;
128
+ G.x = Math.max(0, Math.min(AW - 28, G.x));
129
+ // gentle start: one bug every ~1.4s, slowly more and faster
130
+ G.spawn -= dt; if (G.spawn <= 0) { G.spawn = Math.max(.45, 1.4 - age * .02); G.bugs.push({ x: 6 + Math.random() * (AW - 20), y: -8, v: 26 + Math.min(70, age * 1.6) + Math.random() * 10, gold: Math.random() < .12, f: 0 }); }
131
+ const g = G.g; arcadeBg(g, now);
132
+ for (const b of G.bugs) {
133
+ b.y += b.v * dt; b.f = ((now / 160) | 0) % 2;
134
+ drawBug(g, b.x, b.y, b.gold, b.f);
135
+ if (b.y + 8 >= AH - 22 && b.y <= AH - 14 && b.x + 10 > G.x && b.x < G.x + 28) {
136
+ b.hit = true; G.combo++; const pts = (b.gold ? 5 : 1) * (G.combo >= 5 ? 2 : 1); G.score += pts; beep(b.gold);
137
+ for (let i = 0; i < 8; i++) G.sparks.push({ x: b.x + 5, y: b.y + 4, vx: (Math.random() - .5) * 60, vy: -Math.random() * 50, life: .5, c: b.gold ? '#ffd84d' : '#63c74d', txt: i ? '' : '+' + pts });
138
+ } else if (b.y > AH - 8) { b.miss = true; G.lives--; G.combo = 0; }
139
+ }
140
+ G.bugs = G.bugs.filter(b => !b.hit && !b.miss);
141
+ for (const s of G.sparks) { s.x += s.vx * dt; s.y += s.vy * dt; s.vy += 80 * dt; s.life -= dt; if (s.txt) gText(g, s.x, s.y - 6, s.txt, s.c); else { g.fillStyle = s.c; g.fillRect(Math.round(s.x), Math.round(s.y), 2, 2); } }
142
+ G.sparks = G.sparks.filter(s => s.life > 0);
143
+ // the laptop paddle
144
+ const px = Math.round(G.x), py = AH - 18;
145
+ g.fillStyle = '#1b1622'; g.fillRect(px - 1, py - 9, 30, 19); g.fillStyle = '#c0cbdc'; g.fillRect(px + 2, py - 8, 24, 10); g.fillStyle = '#2ce8f5'; g.fillRect(px + 4, py - 6, 20, 6);
146
+ g.fillStyle = '#8b9bb4'; g.fillRect(px, py + 2, 28, 4); g.fillStyle = '#d97757'; g.fillRect(px + 11, py - 4, 6, 2);
147
+ // HUD: hearts, score, combo
148
+ for (let i = 0; i < 3; i++) { g.fillStyle = i < G.lives ? '#e43b44' : '#3a3448'; g.fillRect(6 + i * 10, 6, 3, 2); g.fillRect(10 + i * 10, 6, 3, 2); g.fillRect(6 + i * 10, 8, 7, 2); g.fillRect(7 + i * 10, 10, 5, 1); g.fillRect(8 + i * 10, 11, 3, 1); }
149
+ gText(g, AW - 6 - String(G.score).length * 8, 5, G.score, '#ffffff', 2);
150
+ if (G.combo >= 5) gText(g, AW / 2, 6, 'COMBO X2', '#ff6ec7', 1, true);
151
+ const sc = arcadeEl.querySelector('#arcade-score'); if (sc) sc.textContent = `${T.arcade.score}: ${G.score}`;
152
+ if (G.lives <= 0) {
153
+ const best = Number(store.get('arcadeBest', 0)) || 0;
154
+ if (G.score > best) { store.set('arcadeBest', G.score); toast(`<b>${esc(T.arcade.record)}</b>${G.score}`, 'ok'); const be = arcadeEl.querySelector('#arcade-best'); if (be) be.textContent = G.score; }
155
+ achRecord.arcadeBest = Math.max(achRecord.arcadeBest || 0, G.score); achBump('arcadeGames');
156
+ g.fillStyle = 'rgba(0,0,0,.7)'; g.fillRect(0, 56, AW, 46); gText(g, AW / 2, 64, 'GAME OVER', '#ffd84d', 2, true); gText(g, AW / 2, 84, `${G.score}`, '#ffffff', 2, true);
157
+ game2 = null; return;
158
+ }
159
+ requestAnimationFrame(tickArcade);
160
+ }
161
+ arcadeEl.addEventListener('click', e => { if (e.target === arcadeEl) { arcadeEl.hidden = true; game2 = null; } if (e.target.closest('[data-arcade-start]')) startArcade(); });
162
+ document.addEventListener('keydown', e => {
163
+ if (arcadeEl.hidden) return;
164
+ if (!game2 && (e.key === ' ' || e.key === 'Enter')) { e.preventDefault(); e.stopImmediatePropagation(); return startArcade(); }
165
+ if (!game2) return;
166
+ const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
167
+ if (['ArrowLeft', 'ArrowRight', 'a', 'd'].includes(k)) { game2.keys.add(k); e.preventDefault(); e.stopImmediatePropagation(); }
168
+ if (e.key === 'Escape') { arcadeEl.hidden = true; game2 = null; }
169
+ }, true);
170
+ document.addEventListener('keyup', e => { if (game2) { game2.keys.delete(e.key); game2.keys.delete(e.key.toLowerCase()); } });
171
+ arcadeEl.addEventListener('pointermove', e => { if (!game2) return; const rc = game2.cv.getBoundingClientRect(); game2.x = (e.clientX - rc.left) / rc.width * AW - 14; });
172
+
173
+ // ---- surprise events: now and then something appears; click it for coins ----
174
+ let surprise = null; // { kind, x, y, until, coins }
175
+ function maybeSurprise() {
176
+ if (surprise || !data || trophyView || document.hidden || Math.random() > .25) return;
177
+ const kinds = [
178
+ { kind: 'cake', x: RX + 90, y: TOP - 12, coins: 10 },
179
+ { kind: 'pigeon', x: windowBoxes.length ? windowBoxes[(Math.random() * windowBoxes.length) | 0].x + 20 : CX - 40, y: 30, coins: 5 },
180
+ { kind: 'ticket', x: CX - 4, y: TOP + 40 + Math.random() * (H - TOP - 120), coins: 25 },
181
+ ];
182
+ surprise = { ...kinds[(Math.random() * kinds.length) | 0], until: Date.now() + 90000 };
183
+ }
184
+ setInterval(maybeSurprise, 5 * 60000);
185
+ function drawSurprise(t) {
186
+ if (!surprise) return; if (Date.now() > surprise.until) { surprise = null; return; }
187
+ const { x, y, kind } = surprise, b = ((t / 300) | 0) % 2;
188
+ if (kind === 'cake') { r(x, y + 4, 12, 6, '#f4dfc6'); r(x, y + 4, 12, 2, '#ff6ec7'); r(x + 5, y, 1, 4, '#2ce8f5'); if (b) r(x + 5, y - 2, 1, 2, '#ffd84d'); }
189
+ if (kind === 'pigeon') { r(x, y + 2, 8, 5, '#9aa6b8'); r(x + 6, y, 4, 4, '#9aa6b8'); r(x + 9, y + 1, 2, 1, '#feae34'); r(x + 7, y + 1, 1, 1, PAL.ink); r(x + 2, y + 7, 1, 2 - b, '#feae34'); r(x + 5, y + 7, 1, 1 + b, '#feae34'); }
190
+ if (kind === 'ticket') { r(x - 1, y - 1, 12, 8, PAL.ink); r(x, y, 10, 6, '#ffd84d'); r(x + 2, y + 2, 6, 1, '#b07d2a'); if (b) r(x + 9, y - 2, 1, 1, '#ffffff'); }
191
+ surpriseBox = { x: x - 4, y: y - 4, w: 20, h: 16 };
192
+ }
193
+ let surpriseBox = null;
194
+ function claimSurprise() {
195
+ if (!surprise) return;
196
+ achBump('eventCoins', surprise.coins); achBump('surprises');
197
+ toast(`<b>${esc(T.surprise[surprise.kind])}</b>+${surprise.coins} ${esc(T.ach.coins)}`, 'ok'); playTune('done');
198
+ surprise = null; surpriseBox = null; lastHits = ''; renderOverlay();
199
+ }
200
+
201
+ // ---- the AI scoreboard: this week's hours, actions and tokens per AI ----
202
+ function scoreboardHtml() {
203
+ const S2 = T.scoreboard, days = weekDays(), sum = {};
204
+ for (const k of days) { const d = usageData && usageData.days && usageData.days[k]; if (!d || !d.byAgent) continue; for (const [a, v] of Object.entries(d.byAgent)) { const s = sum[a] || (sum[a] = { activeMs: 0, tools: 0, output: 0 }); s.activeMs += v.activeMs; s.tools += v.tools; s.output += v.output || 0; } }
205
+ const rows = Object.entries(sum).sort((a, b) => b[1].activeMs - a[1].activeMs);
206
+ if (!rows.length) return '';
207
+ return `<article><h4>${esc(S2.title)}</h4><table class="score"><tr><th></th><th>${esc(S2.hours)}</th><th>${esc(S2.actions)}</th><th>${esc(S2.tokens)}</th></tr>${rows.map(([a, s], i) => `<tr><td>${i ? '' : '🥇 '}${esc((AGENT[a] || {}).label || a)}</td><td>${(s.activeMs / 36e5).toFixed(1)}h</td><td>${fmtK(s.tools)}</td><td>${fmtK(s.output)}</td></tr>`).join('')}</table></article>`;
208
+ }
209
+
210
+ // ---- share card: a picture of your week, ready to post ----
211
+ function shareCard() {
212
+ const c = document.createElement('canvas'); c.width = 600; c.height = 315;
213
+ const g = c.getContext('2d'), days = weekDays(), d = k => (usageData && usageData.days && usageData.days[k]) || {};
214
+ const hours = days.reduce((n, k) => n + (d(k).activeMs || 0), 0) / 36e5, tools = days.reduce((n, k) => n + (d(k).tools || 0), 0);
215
+ const st = achState(), tiers = st.reduce((n, a) => n + a.tier, 0), legends = st.filter(a => a.tier >= 5).length;
216
+ g.fillStyle = '#1b1622'; g.fillRect(0, 0, 600, 315);
217
+ g.drawImage(document.getElementById('cv'), 0, 0, Math.min(W, 380), Math.min(H, 220), 300, 40, 280, 170 * Math.min(1, Math.min(H, 220) / 220));
218
+ g.fillStyle = '#ffd84d'; g.font = 'bold 26px system-ui, sans-serif'; g.fillText(prog.officeName || 'coworking-agents', 24, 52);
219
+ g.fillStyle = '#c0cbdc'; g.font = '14px system-ui, sans-serif'; g.fillText(T.share.week, 24, 76);
220
+ const stat = (y, big, label) => { g.fillStyle = '#ffffff'; g.font = 'bold 30px system-ui, sans-serif'; g.fillText(big, 24, y); g.fillStyle = '#9a9187'; g.font = '13px system-ui, sans-serif'; g.fillText(label, 24, y + 18); };
221
+ stat(126, hours.toFixed(1) + 'h', T.share.hours); stat(186, fmtK(tools), T.share.actions); stat(246, `${tiers} · ${legends}★`, T.share.trophies);
222
+ g.fillStyle = '#d97757'; g.fillRect(0, 290, 600, 25); g.fillStyle = '#ffffff'; g.font = 'bold 13px system-ui, sans-serif'; g.fillText('npx coworking-agents', 24, 307);
223
+ c.toBlob(b => { if (!b) return; const a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = `coworking-week-${weekKey()}.png`; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 2000); toast(`<b>${esc(T.share.saved)}</b>${esc(a.download)}`, 'ok'); achBump('shares'); }, 'image/png');
224
+ }
225
+
226
+ // ---- a photo for visitors: the office without names, branches or the office name ----
227
+ let snapshotMode = false;
228
+ function visitPhoto() {
229
+ snapshotMode = true;
230
+ try { drawScene(performance.now(), 0); const cv = document.getElementById('cv');
231
+ cv.toBlob(b => { if (!b) return; const a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = `coworking-visit-${new Date().toISOString().slice(0, 10)}.png`; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 2000); toast(`<b>${esc(T.share.visitSaved)}</b>${esc(T.share.visitNote)}`, 'ok'); achBump('shares'); }, 'image/png');
232
+ } finally { snapshotMode = false; }
233
+ }