@homie-rocks/studio 0.38.0 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +57 -1
  2. package/bin/homie-studio.mjs +13 -1
  3. package/lib/build.mjs +4 -0
  4. package/lib/cloudflare.mjs +2 -2
  5. package/lib/dev.mjs +12 -1
  6. package/lib/mcp-tools.mjs +10 -0
  7. package/lib/port-check.mjs +33 -7
  8. package/lib/rules-build.mjs +8 -1
  9. package/lib/rules-check-worker.mjs +3 -3
  10. package/lib/rules-check.mjs +3 -1
  11. package/lib/scaffold.mjs +19 -6
  12. package/lib/studio.mjs +1 -1
  13. package/lib/template-history.json +82 -0
  14. package/lib/tools-build.mjs +55 -0
  15. package/lib/tools-cli.mjs +55 -0
  16. package/lib/worker-build.mjs +35 -0
  17. package/netplay/NETPLAY.md +74 -2
  18. package/package.json +17 -6
  19. package/rules/collision.ts +115 -0
  20. package/rules/core.ts +65 -25
  21. package/rules/host.ts +5 -1
  22. package/rules/math.ts +15 -5
  23. package/rules/rules.ts +33 -10
  24. package/rules/types.ts +2 -2
  25. package/rules/view.ts +126 -32
  26. package/starters/ember-vale/game.json +16 -4
  27. package/starters/ember-vale/map/main.json +66 -0
  28. package/starters/ember-vale/src/move.ts +31 -0
  29. package/starters/ember-vale/src/rules.ts +244 -0
  30. package/starters/ember-vale/src/{main.ts → view.ts} +89 -574
  31. package/starters/ember-vale/tunables.json +100 -11
  32. package/starters/gem-rush/src/move.ts +9 -1
  33. package/starters/gem-rush/src/view.ts +2 -1
  34. package/starters/gem-rush-3d/game.json +23 -5
  35. package/starters/gem-rush-3d/map/main.json +153 -0
  36. package/starters/gem-rush-3d/src/move.ts +37 -0
  37. package/starters/gem-rush-3d/src/rules.ts +133 -0
  38. package/starters/gem-rush-3d/src/{main.ts → view.ts} +63 -671
  39. package/starters/gem-rush-3d/tunables.json +98 -11
  40. package/starters/hero-rush-3d/game.json +23 -5
  41. package/starters/hero-rush-3d/map/main.json +153 -0
  42. package/starters/hero-rush-3d/src/move.ts +49 -0
  43. package/starters/hero-rush-3d/src/rules.ts +200 -0
  44. package/starters/hero-rush-3d/src/{main.ts → view.ts} +83 -976
  45. package/starters/hero-rush-3d/tunables.json +260 -30
  46. package/tools/TOOLS.md +113 -0
  47. package/tools/tools.ts +28 -0
  48. package/worker/app-records.mjs +8 -0
  49. package/worker/index.mjs +26 -0
  50. package/worker/mcp-room.mjs +61 -0
  51. package/worker/mcp-store.mjs +45 -0
  52. package/worker/mcp-tools.mjs +105 -0
  53. package/worker/mcp.mjs +159 -0
  54. package/worker/office-page.mjs +1 -1
  55. package/worker/office.mjs +2 -1
  56. package/worker/purchase-mcp.mjs +3 -3
  57. package/worker/tool-events.mjs +14 -0
  58. package/worker/tool-identity.mjs +4 -0
  59. package/worker/tool-webhook.mjs +28 -0
  60. package/worker/version.mjs +1 -1
@@ -0,0 +1,66 @@
1
+ {
2
+ "bounds": {
3
+ "min": [
4
+ 0,
5
+ 0
6
+ ],
7
+ "max": [
8
+ 32,
9
+ 20
10
+ ]
11
+ },
12
+ "spots": {
13
+ "camp": [
14
+ [
15
+ 16,
16
+ 10
17
+ ]
18
+ ],
19
+ "king": [
20
+ [
21
+ 16,
22
+ 2.4
23
+ ]
24
+ ],
25
+ "east-woods": [
26
+ [
27
+ 28,
28
+ 10.6
29
+ ]
30
+ ],
31
+ "start": [
32
+ [
33
+ 19.2,
34
+ 10.0
35
+ ],
36
+ [
37
+ 12.8,
38
+ 10.0
39
+ ],
40
+ [
41
+ 16.0,
42
+ 12.4
43
+ ],
44
+ [
45
+ 16.0,
46
+ 7.6
47
+ ],
48
+ [
49
+ 18.26274169979695,
50
+ 11.697056274847714
51
+ ],
52
+ [
53
+ 13.737258300203047,
54
+ 8.302943725152286
55
+ ],
56
+ [
57
+ 13.737258300203049,
58
+ 11.697056274847714
59
+ ],
60
+ [
61
+ 18.26274169979695,
62
+ 8.302943725152286
63
+ ]
64
+ ]
65
+ }
66
+ }
@@ -0,0 +1,31 @@
1
+ import { defineMove } from '@homie-rocks/studio/rules';
2
+
3
+ export const move = defineMove({
4
+ hero(body, input, ctx) {
5
+ const M = ctx.math, m = body.motion;
6
+ if (ctx.tick < m.downUntil) { body.vel = { x: 0, y: 0, z: 0 }; return; }
7
+ const v = M.scale(M.clampLen({ x: input.ax / 127, y: input.ay / 127, z: 0 }, 1), m.speed);
8
+ if (M.len(v) > 0.3) body.heading = M.norm(v);
9
+ if (input.strike && ctx.tick >= m.ready && m.live) {
10
+ m.attackTick = ctx.tick; m.ready = ctx.tick + ctx.ticks(ctx.tune.strikeMs / 1000);
11
+ m.lungeDir = body.heading; m.lungeDone = m.lunges ? 0 : 1;
12
+ }
13
+ let delta = M.scale(v, ctx.dt);
14
+ if (m.lungeDone < 1) {
15
+ const u = M.clamp((ctx.tick - m.attackTick + 1) * ctx.dt / 0.09, 0, 1), ease = 1 - (1 - u) * (1 - u);
16
+ delta = M.add(delta, M.scale(m.lungeDir, (ease - m.lungeDone) * ctx.tune.lunge)); m.lungeDone = ease;
17
+ }
18
+ ctx.map.sweep(body, delta); body.vel = v;
19
+ },
20
+ slime(body, input, ctx) {
21
+ const M = ctx.math, m = body.motion;
22
+ if (m.pushAt > 0) {
23
+ const u = M.clamp((ctx.tick - m.pushAt) * ctx.dt / (ctx.tune.pushMs / 1000), 0, 1);
24
+ const target = M.add(m.pushFrom, M.scale(m.pushDir, ctx.tune.slimePush * (1 - M.pow(1 - u, 3))));
25
+ ctx.map.sweep(body, M.sub(target, body.pos));
26
+ if (u >= 1) m.pushAt = 0;
27
+ body.vel = { x: 0, y: 0, z: 0 }; return;
28
+ }
29
+ body.vel = m.velocity; ctx.map.sweep(body, M.scale(body.vel, ctx.dt));
30
+ },
31
+ });
@@ -0,0 +1,244 @@
1
+ import { defineRules, f, type GameWorld, type GameSelf } from '@homie-rocks/studio/rules';
2
+ import { move } from './move';
3
+
4
+ type Hero = GameSelf<'hero'>;
5
+ type ReadWorld = Pick<GameWorld, 'near' | 'math' | 'map' | 'tick' | 'dt' | 'shared'>;
6
+ function slimes(world: ReadWorld) { return world.near({ x: 16, y: 10, z: 0 }, 64, 'slime'); }
7
+ function people(world: ReadWorld) { return world.near({ x: 16, y: 10, z: 0 }, 64, 'hero').filter(p => p.driver === 'person' && p.hp > 0); }
8
+ function nearest(world: ReadWorld, at: { x: number; y: number; z: number }, list = slimes(world)) {
9
+ let best = list[0], distance = 10000;
10
+ for (const s of list) { const d = world.math.dist(at, s.pos); if (d < distance) { distance = d; best = s; } }
11
+ return best;
12
+ }
13
+ function quests(world: ReadWorld) {
14
+ const list = slimes(world);
15
+ return ['slime-hunt', ...(list.some(s => s.size === 3) || (world.shared.kingAt - world.tick) * world.dt < 30 ? ['king-slime'] : []), ...(list.some(s => s.size === 2) ? ['big-slime'] : [])];
16
+ }
17
+ function skill(world: GameWorld, guide = false) {
18
+ const n = (world.kids ? Math.min(3, guide ? world.guideLevel : world.level) : guide ? world.guideLevel : world.level) - 1;
19
+ return { reaction: [650, 420, 250, 170, 110][n], noise: [0.55, 0.3, 0.15, 0.07, 0.02][n], positioning: [0.1, 0.35, 0.5, 0.75, 0.95][n], aggression: Math.min(world.kids ? 0.3 : 1, [0.1, 0.3, 0.5, 0.7, 0.9][n]) };
20
+ }
21
+ function reset(world: GameWorld, self: Hero) {
22
+ self.hp = self.maxHp; self.motion.downUntil = 0; self.motion.ready = 0; self.motion.attackTick = 0; self.motion.lungeDone = 1;
23
+ const spots = world.map.spots('start');
24
+ if (world.stage === 'dummy') world.place(self, self.driver === 'person' ? { x: 14.6, y: 10, z: 0 } : { x: self.seat % 2 ? 4 : 28, y: 3.2 + Math.floor(self.seat / 2) * 4, z: 0 }, { heading: { x: 1, y: 0, z: 0 } });
25
+ else world.place(self, spots[self.seat % spots.length]);
26
+ self.target = self.pos;
27
+ }
28
+
29
+ export default defineRules({
30
+ contract: 2, space: { dims: 2 }, move, map: './map',
31
+ shapes: {
32
+ events: { beat: {}, director: {}, kingSlain: {}, hit: { by: f.ref(), from: f.vec3(), damage: f.u16() }, reward: { xp: f.u16(), gold: f.u16() }, bite: { damage: f.u16(), size: f.u8() } },
33
+ effects: { loot: { xp: f.u16(), gold: f.u16() }, down: { cause: f.text(24) }, king: {}, strike: {}, director: { tactic: f.text(12), pressure: f.u8(), by: f.text(8) } },
34
+ commands: { hero: { level: f.u8(), name: f.text(20) } },
35
+ view: {
36
+ me: f.struct({ hp: f.u8(), down: f.bit() }), zone: f.text(20), danger: f.text(20),
37
+ party: f.list(f.struct({ seat: f.u8(), dist: f.u16(), hp: f.u8(), down: f.bit() }), 6),
38
+ quests: f.list(f.text(16), 3), slimes: f.struct({ near: f.u8(), king: f.bit() }), round: f.struct({ phase: f.text(8), left: f.u16() }),
39
+ },
40
+ },
41
+ shared: { serial: f.u32(), spawnAt: f.tick(), kingAt: f.tick(), kingSlainAt: f.tick(), tactic: f.text(12, { init: 'chase' }), pressure: f.u8({ init: 2 }), waveLeft: f.u8(), directorBy: f.text(8, { init: 'floor' }), quests: f.list(f.text(16), 3) },
42
+ asks: {
43
+ director: {
44
+ state: {
45
+ heroes: f.list(f.struct({ hp: f.u8(), down: f.bit(), level: f.u8(), nearKing: f.u16() }), 32),
46
+ slimes: f.struct({ count: f.u8(), big: f.u8(), king: f.u8() }),
47
+ night: f.struct({ secondsLeft: f.u16() }), now: f.struct({ tactic: f.text(12), pressure: f.u8() }), kids: f.bit(),
48
+ },
49
+ questions: {
50
+ tactic: { type: 'choice', instructions: 'How should the slimes hunt? Hurt heroes deserve a breather.', criteria: { chase: 'Rush the nearest hero', surround: 'Spread out and close in', weakest: 'Hunt the most hurt hero', regroup: 'Gather at the King' } },
51
+ wave: { type: 'noul', instructions: 'Should three extra slimes come?', criteria: { true: 'Healthy heroes and quiet woods', false: 'Hurt, down or few heroes' } },
52
+ pressure: { type: 'score', instructions: 'How hard should the vale push?', criteria: ['A breather', 'Easy', 'Steady', 'Hard', 'Fierce'] },
53
+ },
54
+ floor() { return { tactic: 'chase', wave: false, pressure: 2 }; },
55
+ },
56
+ },
57
+ entities: {
58
+ hero: {
59
+ player: { away: 'think', leave: 'bot' }, body: { shape: 'circle', radius: 0.44, maxSpeed: 6 },
60
+ fields: { striking: f.bit(), hp: f.u16({ init: 72 }), maxHp: f.u16({ init: 72 }), level: f.u8({ init: 1 }), name: f.text(20), score: f.u32({ score: true }), target: f.vec3(), aim: f.vec3(), hunting: f.bit(), noticed: f.tick(), greeted: f.list(f.u8(), 32), warned: f.tick(), huntFrom: f.u32(), huntGoal: f.tick(), arrivedAt: f.tick(), leadGoal: f.tick() },
61
+ motion: { lunges: f.bit({ init: true }), speed: f.fix({ init: 6 }), downUntil: f.tick(), ready: f.tick(), attackTick: f.tick(), lungeDir: f.dir(), lungeDone: f.fix({ init: 1 }), live: f.bit({ init: true }) },
62
+ input: { ax: f.i8(), ay: f.i8(), strike: f.press() },
63
+ commands: {
64
+ hero(world, self, e) { if (self.driver !== 'person') return; self.level = world.math.clamp(e.level, 1, 99); self.maxHp = 60 + self.level * 12; self.hp = Math.min(self.hp, self.maxHp); self.name = world.kids ? '' : e.name; },
65
+ },
66
+ tick(world, self) {
67
+ self.motion.live = world.round.phase === 'live';
68
+ self.striking = self.motion.attackTick > 0 && world.tick - self.motion.attackTick < world.tune.flashMs / 1000 / world.dt;
69
+ if (world.kids && self.name) self.name = '';
70
+ if (self.driver === 'person' && !self.away) { self.motion.speed = 6; self.motion.lunges = true; }
71
+ if (self.hp === 0) {
72
+ if (world.tick >= self.motion.downUntil) { self.hp = self.maxHp; world.place(self, { x: 16 + (world.random() - 0.5) * 6, y: 10 + (world.random() - 0.5) * 4, z: 0 }); }
73
+ return;
74
+ }
75
+ if (self.motion.attackTick === world.tick && world.round.phase === 'live') {
76
+ world.emit('strike', self.id, {});
77
+ for (const s of world.near(self.pos, world.tune.strikeRange + 0.6, 'slime'))
78
+ if (world.math.dist(self.pos, s.pos) <= world.tune.strikeRange + s.size * 0.2) world.send(s.id, 'hit', { by: self.id, from: self.pos, damage: 10 + self.level * 3 });
79
+ }
80
+ },
81
+ think(world, self) {
82
+ self.motion.lunges = false;
83
+ const M = world.math, s = skill(world, world.guideSeats.includes(self.seat)), goal = self.goal, folk = people(world);
84
+ if (self.hp === 0 || world.stage === 'dummy') { self.motion.speed = 0; return { ax: 0, ay: 0 }; }
85
+ let target = self.target, hunt = undefined as ReturnType<typeof nearest> | undefined;
86
+ if (goal) {
87
+ const nearTo = (p: { x: number; y: number; z: number }, r: number) => { const n = nearest(world, p); return n && M.dist(n.pos, p) < r ? n : undefined; };
88
+ if (goal.goal === 'follow') {
89
+ const person = folk.find(p => p.seat === goal.args.seat);
90
+ if (!person) world.goalDone(false);
91
+ else {
92
+ const back = (70 + (150 - 70) * (1 - s.positioning)) / 50;
93
+ const side = (self.seat % 2 ? 1 : -1) * (0.5 + self.seat % 3 * 0.25), a = M.atan2(person.heading.y, person.heading.x) + side;
94
+ target = { x: person.pos.x - M.cos(a) * back, y: person.pos.y - M.sin(a) * back, z: 0 }; hunt = nearTo(person.pos, 4);
95
+ }
96
+ } else if (goal.goal === 'quest') {
97
+ if (goal.args.quest === 'king-slime') {
98
+ if (world.shared.kingSlainAt > goal.at) world.goalDone();
99
+ hunt = slimes(world).find(s => s.size === 3); target = { x: 16, y: 3.8, z: 0 };
100
+ } else if (goal.args.quest === 'big-slime') { hunt = nearest(world, self.pos, slimes(world).filter(s => s.size === 2)); if (!hunt) world.goalDone(); }
101
+ else {
102
+ if (self.huntGoal !== goal.at || self.huntFrom > self.score) { self.huntGoal = goal.at; self.huntFrom = self.score; }
103
+ if (self.score - self.huntFrom >= 18) world.goalDone();
104
+ const centre = folk.length ? M.scale(folk.reduce((p, h) => M.add(p, h.pos), { x: 0, y: 0, z: 0 }), 1 / folk.length) : self.pos;
105
+ hunt = nearTo(centre, 10.4) ?? nearest(world, self.pos);
106
+ }
107
+ } else if (goal.goal === 'lead') {
108
+ target = world.map.spot(goal.args.place) ?? world.map.spot('camp')!;
109
+ if (self.leadGoal !== goal.at) { self.leadGoal = goal.at; self.arrivedAt = 0; }
110
+ if (M.dist(target, self.pos) < 1.2) {
111
+ if (!self.arrivedAt) self.arrivedAt = world.tick;
112
+ if (folk.some(p => M.dist(p.pos, target) < 5.6)) world.goalDone();
113
+ else if (world.tick - self.arrivedAt > world.ticks(20)) world.goalDone(false);
114
+ } else self.arrivedAt = 0;
115
+ hunt = nearTo(self.pos, 2.2);
116
+ } else if (goal.goal === 'guard') {
117
+ const near = folk.filter(p => M.dist(p.pos, self.pos) < 14);
118
+ const centre = near.length ? M.scale(near.reduce((p, h) => M.add(p, h.pos), { x: 0, y: 0, z: 0 }), 1 / near.length) : self.pos;
119
+ target = M.add(centre, { x: 1, y: 0.8, z: 0 }); hunt = nearTo(centre, (260 + (160 - 260) * (1 - s.positioning)) / 50);
120
+ } else if (goal.goal === 'back') { target = world.map.spot('camp')!; if (M.dist(target, self.pos) < 1) world.goalDone(); }
121
+ if (!hunt && goal.goal !== 'follow') target = M.add(target, M.scale(M.dir(self.seat * 2.4), 0.92));
122
+ if (hunt) {
123
+ if (!self.noticed || world.tick - self.noticed >= s.reaction / 1000 / world.dt) { self.noticed = world.tick; self.aim = { x: hunt.pos.x + (world.random() - 0.5) * 1.6 * s.noise, y: hunt.pos.y + (world.random() - 0.5) * 1.6 * s.noise, z: 0 }; }
124
+ target = self.aim;
125
+ }
126
+ } else {
127
+ hunt = nearest(world, self.pos);
128
+ if (!self.noticed || world.tick - self.noticed >= s.reaction / 1000 / world.dt) {
129
+ self.noticed = world.tick; self.hunting = Boolean(hunt && M.dist(hunt.pos, self.pos) < (460 + (220 - 460) * (1 - s.positioning)) / 50);
130
+ if (self.hunting && hunt) self.aim = { x: hunt.pos.x + (world.random() - 0.5) * 1.6 * s.noise, y: hunt.pos.y + (world.random() - 0.5) * 1.6 * s.noise, z: 0 };
131
+ }
132
+ target = self.hunting ? self.aim : self.target;
133
+ if (!self.hunting && M.dist(self.target, self.pos) < 0.6) self.target = { x: 4 + world.random() * 24, y: 3 + world.random() * 14, z: 0 };
134
+ if (!self.hunting) hunt = undefined;
135
+ }
136
+ const delta = M.sub(target, self.pos), distance = M.len(delta), stop = goal ? hunt ? 1 : 0.48 : 1.2;
137
+ self.motion.speed = distance > stop ? Math.min(goal ? 5.1 : 4.5, distance / world.dt) : 0;
138
+ const v = distance > stop ? M.norm(delta) : { x: 0, y: 0, z: 0 };
139
+ const strike = Boolean(hunt && M.dist(goal ? hunt.pos : self.aim, self.pos) < world.tune.strikeRange + (goal ? hunt.size * 0.2 : 0) && world.tick - self.motion.attackTick > (1400 - 900 * s.aggression) / 1000 / world.dt);
140
+ return { ax: Math.round(v.x * 127), ay: Math.round(v.y * 127), strike };
141
+ },
142
+ on: {
143
+ arrive(world, self) { if (self.driver !== 'person') { self.level = 1 + self.seat % 4; self.maxHp = 60 + self.level * 12; } reset(world, self); },
144
+ takeover(world, self) { self.score = 0; self.name = ''; self.level = 1; self.maxHp = 72; self.hp = 72; self.motion.downUntil = 0; self.motion.speed = 6; },
145
+ reward(world, self, e) { self.score += e.xp; if (self.driver === 'person') world.emit('loot', self.id, { xp: e.xp, gold: e.gold }); },
146
+ bite(world, self, e) {
147
+ if (self.hp === 0) return; self.hp = Math.max(0, self.hp - e.damage);
148
+ if (self.hp === 0) { self.motion.downUntil = world.tick + world.ticks(4); self.motion.lungeDone = 1; world.emit('down', self.id, { cause: e.size === 3 ? 'the King Slime' : e.size === 2 ? 'a big slime' : 'a slime' }); }
149
+ },
150
+ },
151
+ onRoom: { roundStart(world, self) { self.score = 0; reset(world, self); } },
152
+ guide: {
153
+ view(world, self) {
154
+ const list = slimes(world), king = list.find(s => s.size === 3), near = list.filter(s => world.math.dist(self.pos, s.pos) < 7).length;
155
+ const party = world.near(self.pos, 18, 'hero').filter(p => p.driver === 'person').map(p => ({ seat: p.seat, dist: Math.round(world.math.dist(self.pos, p.pos) * 50), hp: Math.round(100 * p.hp / p.maxHp), down: p.hp === 0 })).sort((a, b) => a.dist - b.dist).slice(0, 6);
156
+ let zone = 'vale', distance = 5.2;
157
+ for (const name of ['camp', 'king', 'east-woods']) { const at = world.map.spot(name)!; const d = world.math.dist(self.pos, at); if (d < distance) { zone = name; distance = d; } }
158
+ return { me: { hp: Math.round(100 * self.hp / self.maxHp), down: self.hp === 0 }, zone, danger: king && world.math.dist(king.pos, self.pos) < 9 ? 'the King Slime' : near >= 5 ? 'slimes' : '', party, quests: quests(world), slimes: { near, king: Boolean(king) }, round: { phase: world.round.phase, left: Math.max(0, Math.round((world.round.endsAt - world.tick) * world.dt)) } };
159
+ },
160
+ floor(world, self, v) {
161
+ const live = world.near(self.pos, 64, 'hero').filter(p => p.driver === 'person');
162
+ const party = v.party.filter(p => live.some(h => h.seat === p.seat));
163
+ const ask = v.asks[0];
164
+ if (ask?.k === 'ask_help') return { goal: 'quest', args: { quest: ask.args.quest }, say: 'quest_help', sayArgs: { quest: ask.args.quest } };
165
+ if (ask?.k === 'lead_me') return { goal: 'lead', args: { place: ask.args.place }, say: 'follow_me', sayArgs: { place: ask.args.place } };
166
+ if (ask?.k === 'no_thanks') return { goal: 'back', say: 'bye' };
167
+ if (v.goal?.state === 'active' && ['quest', 'lead', 'back'].includes(v.goal.goal)) return {};
168
+ const fresh = party.find(p => !self.greeted.includes(p.seat) && p.dist < 420);
169
+ if (fresh) { self.greeted = [...self.greeted, fresh.seat].slice(-32); return { goal: 'follow', args: { seat: fresh.seat }, say: 'hello', sayArgs: { player: fresh.seat } }; }
170
+ if (v.danger === 'the King Slime') {
171
+ const warn = world.tick - self.warned > world.ticks(30); if (warn) self.warned = world.tick;
172
+ return warn ? { goal: 'guard', say: 'careful', sayArgs: { thing: 'the King Slime' } } : { goal: 'guard' };
173
+ }
174
+ const hurt = party.find(p => !p.down && p.hp < 40), person = hurt ?? party[0];
175
+ return person ? { goal: 'follow', args: { seat: person.seat } } : { goal: 'lead', args: { place: 'camp' } };
176
+ },
177
+ },
178
+ },
179
+ slime: {
180
+ body: { shape: 'circle', radius: 0.4, maxSpeed: 1.7 },
181
+ fields: { serial: f.u32(), hp: f.u16({ init: 18 }), maxHp: f.u16({ init: 18 }), size: f.u8({ init: 1 }), biteAt: f.tick() },
182
+ motion: { velocity: f.vec3(), pushAt: f.fix(), pushFrom: f.vec3(), pushDir: f.dir() },
183
+ tick(world, self) {
184
+ if (world.stage === 'dummy' || world.round.phase !== 'live' || self.motion.pushAt > 0) { self.motion.velocity = { x: 0, y: 0, z: 0 }; return; }
185
+ const M = world.math, all = world.near(self.pos, 64, 'hero').filter(p => p.hp > 0), folk = all.filter(p => p.driver === 'person'), prey = folk.length ? folk : all;
186
+ let target = prey[0], distance = 10000;
187
+ for (const p of prey) { const d = M.dist(p.pos, self.pos); if (d < distance) { distance = d; target = p; } }
188
+ if (world.shared.tactic === 'weakest' && !world.kids) for (const p of prey) if (target && p.hp / p.maxHp < target.hp / target.maxHp) target = p;
189
+ if (target) {
190
+ let to = target.pos;
191
+ const king = slimes(world).find(s => s.size === 3), speed = self.size === 3 ? 1.4 : (95 - self.size * 10) / 50;
192
+ if (world.shared.tactic === 'surround' && distance > 1.8) to = M.add(to, M.scale(M.dir(self.serial * 2.39996), 1.6));
193
+ if (world.shared.tactic === 'regroup' && king && king.id !== self.id && distance > 2.8) to = M.add(king.pos, M.scale(M.dir(self.serial), 1.4));
194
+ const delta = M.sub(to, self.pos); self.motion.velocity = M.len(delta) > .08 ? M.scale(M.norm(delta), speed) : { x: 0, y: 0, z: 0 };
195
+ if (M.dist(target.pos, self.pos) < .64 + self.size * .2 && world.tick - self.biteAt > world.ticks(.8)) { self.biteAt = world.tick; world.send(target.id, 'bite', { damage: 5 * self.size + (self.size === 3 ? 10 : 0), size: self.size }); }
196
+ } else self.motion.velocity = { x: M.sin(world.tick * world.dt / .9 + self.serial) * .4, y: M.cos(world.tick * world.dt / 1.1 + self.serial) * .4, z: 0 };
197
+ },
198
+ on: {
199
+ hit(world, self, e) {
200
+ if (self.hp === 0 || world.math.dist(e.from, self.pos) > world.tune.strikeRange + self.size * .2) return;
201
+ self.hp = Math.max(0, self.hp - e.damage);
202
+ self.motion.pushFrom = self.pos; self.motion.pushDir = world.math.norm(world.math.sub(self.pos, e.from)); self.motion.pushAt = world.tick + world.tune.hitStopMs / 1000 / world.dt;
203
+ if (self.hp === 0) {
204
+ world.send(e.by, 'reward', { xp: self.size === 3 ? 60 : 6 * self.size, gold: self.size === 3 ? 50 : 2 + Math.floor(world.random() * 4) * self.size });
205
+ if (self.size === 3) world.sendRoom('kingSlain', {}); world.despawn(self);
206
+ }
207
+ },
208
+ },
209
+ onRoom: { roundOver(world, self) { world.despawn(self); } },
210
+ },
211
+ },
212
+ room: {
213
+ rounds: { seconds: 90, breakSeconds: 8 }, bots: { keep: 3 },
214
+ join(ctx, player) { const spots = ctx.map.spots('start'); return { kind: 'hero', at: spots[player.seat % spots.length] }; },
215
+ start(world) { world.after(1, 'beat', {}); world.after(1, 'director', {}); },
216
+ on: {
217
+ roundStart(world) { world.shared.spawnAt = 0; world.shared.kingAt = world.tick + world.ticks(45); if (world.stage === 'dummy') { world.shared.serial += 1; world.spawn('slime', { x: 16.6, y: 10, z: 0 }, { hp: 36, maxHp: 36, size: 2, serial: world.shared.serial }); } },
218
+ kingSlain(world) { world.shared.kingSlainAt = world.tick; },
219
+ beat(world) {
220
+ world.after(1, 'beat', {}); world.shared.quests = quests(world);
221
+ if (world.stage === 'dummy' || world.round.phase !== 'live' || world.shared.kingAt === 0) return;
222
+ const list = slimes(world), cap = Math.max(4, 6 + people(world).length * 2 + (world.shared.pressure - 2) * 2) + (world.shared.waveLeft > 0 ? 3 : 0);
223
+ if ((world.tick >= world.shared.spawnAt || world.shared.waveLeft > 0) && list.length < cap) {
224
+ world.shared.spawnAt = world.tick + world.ticks([1.9, 1.45, 1.1, .85, .7][world.shared.pressure]); if (world.shared.waveLeft > 0) world.shared.waveLeft -= 1;
225
+ const edge = Math.floor(world.random() * 4), x = edge === 0 ? .6 : edge === 1 ? 31.4 : world.random() * 32, y = edge === 2 ? .6 : edge === 3 ? 19.4 : world.random() * 20, size = world.random() < .25 ? 2 : 1;
226
+ world.shared.serial += 1; world.spawn('slime', { x, y, z: 0 }, { hp: 18 * size, maxHp: 18 * size, size, serial: world.shared.serial });
227
+ }
228
+ if (world.tick >= world.shared.kingAt && !list.some(s => s.size === 3)) { world.shared.kingAt = world.tick + world.ticks(60); world.shared.serial += 1; world.spawn('slime', { x: 16, y: .8, z: 0 }, { hp: 160, maxHp: 160, size: 3, serial: world.shared.serial }); world.emit('king', { x: 16, y: .8, z: 0 }, {}); }
229
+ },
230
+ director(world) {
231
+ world.after(world.ticks(6), 'director', {}); if (world.round.phase !== 'live') return;
232
+ const list = slimes(world), king = list.find(s => s.size === 3);
233
+ world.ask('director', { heroes: world.near({ x: 16, y: 10, z: 0 }, 64, 'hero').filter(p => p.driver === 'person').map(p => ({ hp: Math.round(100 * p.hp / p.maxHp), down: p.hp === 0, level: p.level, nearKing: king ? Math.round(world.math.dist(p.pos, king.pos) * 50) : 0 })), slimes: { count: list.length, big: list.filter(s => s.size === 2).length, king: king ? Math.round(100 * king.hp / king.maxHp) : 0 }, night: { secondsLeft: Math.max(0, Math.round((world.round.endsAt - world.tick) * world.dt)) }, now: { tactic: world.shared.tactic, pressure: world.shared.pressure }, kids: world.kids });
234
+ },
235
+ answer(world, e) {
236
+ if (e.ask !== 'director') return;
237
+ world.shared.tactic = world.kids && e.picks.tactic === 'weakest' ? 'chase' : e.picks.tactic;
238
+ world.shared.pressure = Math.min(world.kids ? 2 : 4, e.picks.pressure);
239
+ if (e.picks.wave && world.shared.waveLeft === 0) world.shared.waveLeft = 3;
240
+ world.shared.directorBy = e.by; world.emit('director', { x: 16, y: 10, z: 0 }, { tactic: world.shared.tactic, pressure: world.shared.pressure, by: e.by });
241
+ },
242
+ },
243
+ },
244
+ });