infinicode 2.8.99 → 2.8.102

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.
@@ -790,7 +790,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
790
790
  <h3><span>Live activity</span><span style="color:var(--muted)" id="pk-src">/fed/stream</span></h3>
791
791
  <div class="rows" id="pk-rows"></div>
792
792
  </aside>
793
- <div class="pk-hint">drag to pan · scroll to zoom · <b>click a robot</b> for cam / mic / speaker</div>
793
+ <div class="pk-hint">drag to pan · scroll to zoom · <b>click a robot</b> for cam / mic / speaker · <b>ctrl+drag a robot</b> to reposition (persists)</div>
794
794
  </div>
795
795
  <div class="pk-drawer" id="pk-drawer">
796
796
  <div class="pk-dh">
@@ -4078,21 +4078,27 @@ button.act:disabled{opacity:0.5;cursor:default;}
4078
4078
  // Pond / curved water feature at the lower-left of the plaza.
4079
4079
  var POND={cx:0.18*15, cy:0.84*8, rx:2.4, ry:1.1};
4080
4080
  // Street traffic along the top road. Each car has x, y (world), color,
4081
- // a per-car speed (world units / second), a direction (+1 = right, -1 =
4082
- // left). y is one of two lane bands (≈ -0.65 for outbound, ≈ -1.18 for
4083
- // inbound) so cars don't overlap. Speeds are deliberately varied and
4084
- // fast enough to read as movement at 60fps — the road is short, so
4085
- // a 4-7s pass across the screen feels natural.
4081
+ // a per-car base speed (world units / second), a direction (+1 = right,
4082
+ // -1 = left), and a per-frame jitter range so cars never settle into
4083
+ // a stable cluster. y is one of two lane bands (≈ -0.65 for outbound,
4084
+ // -1.18 for inbound) so cars don't overlap.
4085
+ //
4086
+ // Stability fix: the previous version always re-spawned a wrapped car
4087
+ // at x = -0.5 (or PARK_W + 0.5) — every car in the same lane teleported
4088
+ // to the same spot, so the slowest car instantly caught the fastest
4089
+ // one and the whole lane collapsed into a clump. We now (a) spawn
4090
+ // each car at a random x within the visible road, and (b) add a
4091
+ // ±5% per-frame speed jitter so cars gradually drift apart. Speeds
4092
+ // are also more spread (2.2 → 4.6) so the natural ordering stays
4093
+ // stable long enough to look like real traffic.
4086
4094
  var ROAD_Y0=-1.5, ROAD_Y1=-0.35;
4087
4095
  var CARS=[
4088
- {x:1.5, y:-0.65, color:'#f2c14e', speed:3.2, dir:+1},
4089
- {x:4.0, y:-1.18, color:'#7fb4ff', speed:3.6, dir:-1},
4090
- {x:6.5, y:-0.65, color:'#e0a23c', speed:2.8, dir:+1},
4091
- {x:9.0, y:-1.18, color:'#ff7e6e', speed:4.0, dir:-1},
4092
- {x:11.5, y:-0.65, color:'#d8d8e0', speed:3.0, dir:+1},
4093
- {x:0.5, y:-1.18, color:'#5b8bd6', speed:3.4, dir:-1},
4094
- {x:13.0, y:-0.65, color:'#c87ed4', speed:2.6, dir:+1},
4095
- {x:7.0, y:-1.18, color:'#f2c14e', speed:3.8, dir:-1},
4096
+ {x:1.5, y:-0.65, color:'#f2c14e', speed:2.4, dir:+1},
4097
+ {x:6.5, y:-0.65, color:'#e0a23c', speed:3.6, dir:+1},
4098
+ {x:11.5, y:-0.65, color:'#d8d8e0', speed:4.4, dir:+1},
4099
+ {x:4.0, y:-1.18, color:'#7fb4ff', speed:2.6, dir:-1},
4100
+ {x:9.0, y:-1.18, color:'#ff7e6e', speed:3.4, dir:-1},
4101
+ {x:0.5, y:-1.18, color:'#5b8bd6', speed:4.2, dir:-1},
4096
4102
  ];
4097
4103
  // Per-car phase offsets so headlights / taillights / wheels don't all
4098
4104
  // blink in lockstep — gives a natural "different cars, different
@@ -4369,12 +4375,16 @@ button.act:disabled{opacity:0.5;cursor:default;}
4369
4375
  connected: match?!!match.connected:false,
4370
4376
  load: (match&&match.load!=null)?match.load:0,
4371
4377
  platform: match?(match.platform||''):'', version: match?(match.version||'?'):'—',
4372
- site: match?(match.site||''):'', caps: match?(match.capabilities||[]):[],
4373
- schedulerRobotId: match?(match.schedulerRobotId||match.schedulerDeviceId||''):'',
4374
- schedulerDeviceId: match?(match.schedulerDeviceId||match.schedulerRobotId||''):'',
4378
+ site: match?(match.site||''):'', caps: match?(match.capabilities||[]):[],
4379
+ schedulerRobotId: match?(match.schedulerRobotId||match.schedulerDeviceId||''):'',
4380
+ schedulerDeviceId: match?(match.schedulerDeviceId||match.schedulerRobotId||''):'',
4375
4381
  telemKeys: [slot.label.toLowerCase()].concat(slot.keys||[]).concat(match?[(match.displayName||'').toLowerCase()]:[]),
4382
+ // slotId is the stable handle we use to persist operator-
4383
+ // chosen positions. slotNx/slotNy are the preset's default
4384
+ // coordinates so the user can always reset back.
4385
+ slotId: slot.id, slotNx: slot.nx, slotNy: slot.ny,
4376
4386
  wx:wx, wy:wy, _hit:prevHit(id)
4377
- };
4387
+ };
4378
4388
  // Attach a status derived from the latest scheduler telemetry
4379
4389
  // (push-poll, so it can lag up to 3s behind /fed/status).
4380
4390
  if(kind === 'robot'){
@@ -4406,6 +4416,10 @@ button.act:disabled{opacity:0.5;cursor:default;}
4406
4416
  if(!s) return;
4407
4417
  nodes = layout(s.nodes||[]);
4408
4418
  byId={}; nodes.forEach(function(n){ byId[n.id]=n; });
4419
+ // re-apply any persisted Ctrl+drag overrides on top of the
4420
+ // fresh layout (the server may have also returned updated
4421
+ // slot nx/ny — those go through the override map too)
4422
+ _applyPosOverrides();
4409
4423
  // if not streaming, animate from the polled activity tail
4410
4424
  if(!es || es.readyState!==1){ (s.activity||[]).forEach(function(e){ ingest(e.f||e, true); }); }
4411
4425
  }
@@ -4450,16 +4464,187 @@ button.act:disabled{opacity:0.5;cursor:default;}
4450
4464
  }
4451
4465
 
4452
4466
  // ── canvas + projection ──
4467
+ // State for Ctrl+drag robot repositioning on the park map. When
4468
+ // the user holds Ctrl and mousedown's a robot, the canvas stops
4469
+ // panning and instead drags that node around. On mouseup, the
4470
+ // new (nx, ny) is written to _parkPosOverrides and pushed to
4471
+ // the scheduler via a debounced PUT. Survives reloads because
4472
+ // the server stores nx/ny in the character_presets table.
4473
+ var _parkPosOverrides = {}; // presetId -> {nx, ny} (normalized 0..1)
4474
+ var _dragNode = null; // the robot node currently being dragged
4475
+ var _dragOffset = {x:0, y:0}; // screen-space offset from node center
4476
+ var _posSaveTimer = null; // debounce timer for the server PUT
4477
+ var _posSaveQueue = {}; // presetId -> {nx, ny} pending save
4478
+ function _isoToWorld(sx, sy){
4479
+ // Inverse of iso() in the same coordinate system. Given a
4480
+ // screen point, return the (wx, wy) world point. Used by the
4481
+ // Ctrl+drag handler to translate a mouse position back into
4482
+ // the iso coordinate space so the robot follows the cursor.
4483
+ var dx = (sx - (W-300)/2 - cam.x) / (TW*cam.z);
4484
+ var dy = (sy - H*0.42 - cam.y) / (TH*cam.z);
4485
+ // from iso(): dx = (wx-PARK_W/2) - (wy-PARK_D/2); dy = (wx-PARK_W/2) + (wy-PARK_D/2)
4486
+ // so wx = (dx+dy)/2 + PARK_W/2; wy = (dy-dx)/2 + PARK_D/2
4487
+ return { x: (dx+dy)*0.5 + PARK_W*0.5, y: (dy-dx)*0.5 + PARK_D*0.5 };
4488
+ }
4489
+ function _applyPosOverrides(){
4490
+ // Walk nodes and override their wx/wy from _parkPosOverrides if
4491
+ // we have a stored position. The slot's default nx/ny stays in
4492
+ // node.slotNx/slotNy so the user can "reset" back to defaults
4493
+ // if we ever expose that control.
4494
+ for(var i=0; i<nodes.length; i++){
4495
+ var n = nodes[i];
4496
+ if(!n.slotId) continue;
4497
+ var ov = _parkPosOverrides[n.slotId];
4498
+ if(ov){
4499
+ n.wx = Math.max(0, Math.min(PARK_W, ov.nx * PARK_W));
4500
+ n.wy = Math.max(0, Math.min(PARK_D, ov.ny * PARK_D));
4501
+ n._posOverridden = true;
4502
+ } else if(n._posOverridden){
4503
+ // The server just gave us a fresh layout and we don't have
4504
+ // a local override — fall back to the slot's default position.
4505
+ n.wx = n.slotNx * PARK_W;
4506
+ n.wy = n.slotNy * PARK_D;
4507
+ n._posOverridden = false;
4508
+ }
4509
+ }
4510
+ }
4511
+ function _schedulePosSave(){
4512
+ // Debounce: collapse a flurry of drag-end events into one
4513
+ // PUT after 350ms of quiet. Saves queue is merged so the final
4514
+ // PUT always reflects the latest position for each preset.
4515
+ if(_posSaveTimer) clearTimeout(_posSaveTimer);
4516
+ _posSaveTimer = setTimeout(_flushPosSaves, 350);
4517
+ }
4518
+ function _flushPosSaves(){
4519
+ var entries = [];
4520
+ for(var id in _posSaveQueue){
4521
+ if(Object.prototype.hasOwnProperty.call(_posSaveQueue, id)){
4522
+ entries.push({ id: id, nx: _posSaveQueue[id].nx, ny: _posSaveQueue[id].ny });
4523
+ }
4524
+ }
4525
+ _posSaveQueue = {};
4526
+ _posSaveTimer = null;
4527
+ if(!entries.length) return;
4528
+ // Persist to the scheduler. We POST to the dedicated
4529
+ // /api/character-presets/positions batch endpoint which clamps
4530
+ // nx/ny to [0.04, 0.96] so a stray drag can't park a robot
4531
+ // off the island.
4532
+ var body = JSON.stringify({ positions: entries });
4533
+ try{
4534
+ fetch('/api/character-presets/positions'+QS, {
4535
+ method: 'PUT',
4536
+ headers: { 'Content-Type': 'application/json' },
4537
+ body: body,
4538
+ keepalive: true
4539
+ }).then(function(r){
4540
+ if(r.ok) return r.json();
4541
+ throw new Error('HTTP '+r.status);
4542
+ }).then(function(j){
4543
+ if(typeof showOut === 'function'){
4544
+ showOut('park position saved · '+(j.updated||entries.length)+' preset'+(entries.length===1?'':'s'));
4545
+ }
4546
+ }).catch(function(err){
4547
+ if(typeof showOut === 'function') showOut('park position save failed: '+err.message);
4548
+ });
4549
+ }catch(e){ /* offline — keep override in memory */ }
4550
+ }
4551
+ function _hitTestNode(mx, my){
4552
+ // Walk all visible nodes and return the first whose hit-box
4553
+ // contains (mx, my). Mirrors the existing click hit-test
4554
+ // but returns the node directly (and skips early if a drag
4555
+ // is already in progress).
4556
+ for(var i=0; i<nodes.length; i++){
4557
+ var n = nodes[i];
4558
+ var hb = n._hit;
4559
+ if(hb && mx>=hb.x && mx<=hb.x+hb.w && my>=hb.y && my<=hb.y+hb.h){
4560
+ return n;
4561
+ }
4562
+ }
4563
+ return null;
4564
+ }
4453
4565
  function ensure(){
4454
4566
  if(C) return;
4455
4567
  C=$('pk-canvas'); X=C.getContext('2d'); t0=performance.now();
4456
4568
  DPR=Math.max(1,Math.min(2,window.devicePixelRatio||1));
4457
- var d=false,px,py;
4458
- C.addEventListener('mousedown',function(e){d=true;px=e.clientX;py=e.clientY;});
4459
- window.addEventListener('mouseup',function(){d=false;});
4460
- window.addEventListener('mousemove',function(e){ if(!d)return; cam.x+=e.clientX-px; cam.y+=e.clientY-py; px=e.clientX; py=e.clientY; });
4569
+ // Panning: drag on the canvas (no Ctrl) pans the iso camera.
4570
+ // Ctrl+drag on a robot reposition the robot on the park map.
4571
+ // Each gesture is mutually exclusive — we keep one _isPanning
4572
+ // flag and one _dragNode at a time.
4573
+ var _isPanning = false;
4574
+ var _px = 0, _py = 0;
4575
+ C.addEventListener('mousedown',function(e){
4576
+ var r = C.getBoundingClientRect();
4577
+ var mx = e.clientX - r.left;
4578
+ var my = e.clientY - r.top;
4579
+ // Ctrl+drag: if a robot is under the cursor, pick it up.
4580
+ // Only ROBOT nodes are draggable — the hub, parked cars in the
4581
+ // lot, and ghosts for offline nodes stay locked to their slot.
4582
+ if(e.ctrlKey){
4583
+ var hit = _hitTestNode(mx, my);
4584
+ if(hit && hit.slotId && hit.kind === 'robot'){
4585
+ _dragNode = hit;
4586
+ var p = iso(hit.wx, hit.wy);
4587
+ _dragOffset.x = mx - p.x;
4588
+ _dragOffset.y = my - p.y;
4589
+ // prevent text selection while dragging
4590
+ e.preventDefault();
4591
+ return;
4592
+ }
4593
+ }
4594
+ // otherwise: start a pan
4595
+ _isPanning = true;
4596
+ _px = e.clientX; _py = e.clientY;
4597
+ });
4598
+ window.addEventListener('mouseup',function(){
4599
+ if(_dragNode){
4600
+ // commit the drag: record override, schedule server save
4601
+ var nx = _dragNode.wx / PARK_W;
4602
+ var ny = _dragNode.wy / PARK_D;
4603
+ var id = _dragNode.slotId;
4604
+ if(id){
4605
+ _parkPosOverrides[id] = { nx: nx, ny: ny };
4606
+ _posSaveQueue[id] = { nx: nx, ny: ny };
4607
+ _schedulePosSave();
4608
+ }
4609
+ _dragNode = null;
4610
+ }
4611
+ _isPanning = false;
4612
+ });
4613
+ window.addEventListener('mousemove',function(e){
4614
+ if(_dragNode){
4615
+ var r = C.getBoundingClientRect();
4616
+ var mx = e.clientX - r.left;
4617
+ var my = e.clientY - r.top;
4618
+ // screen point = mouse minus the original grab offset, so
4619
+ // the robot doesn't snap its center to the cursor
4620
+ var worldPt = _isoToWorld(mx - _dragOffset.x, my - _dragOffset.y);
4621
+ // clamp to the visible grass rectangle (with a small
4622
+ // inner margin so the avatar doesn't get clipped by the
4623
+ // gold wall or the hedge)
4624
+ var MARGIN = 0.4;
4625
+ _dragNode.wx = Math.max(MARGIN, Math.min(PARK_W - MARGIN, worldPt.x));
4626
+ _dragNode.wy = Math.max(MARGIN, Math.min(PARK_D - MARGIN, worldPt.y));
4627
+ return;
4628
+ }
4629
+ if(!_isPanning) return;
4630
+ cam.x += e.clientX - _px;
4631
+ cam.y += e.clientY - _py;
4632
+ _px = e.clientX; _py = e.clientY;
4633
+ });
4634
+ // Escape cancels an in-progress drag without saving
4635
+ window.addEventListener('keydown',function(e){
4636
+ if((e.key === 'Escape' || e.key === 'Esc') && _dragNode){
4637
+ // restore to the original override (or slot default)
4638
+ _dragNode.wx = _dragNode.slotNx * PARK_W;
4639
+ _dragNode.wy = _dragNode.slotNy * PARK_D;
4640
+ _dragNode = null;
4641
+ }
4642
+ });
4461
4643
  C.addEventListener('wheel',function(e){ e.preventDefault(); var f=e.deltaY<0?1.08:0.925; cam.z=Math.max(.5,Math.min(2.4,cam.z*f)); },{passive:false});
4462
4644
  C.addEventListener('click',function(e){
4645
+ // Suppress the click that fires at the end of a drag — we
4646
+ // don't want a single drag-release to also open the drawer.
4647
+ if(_dragNode) return;
4463
4648
  var r=C.getBoundingClientRect(), mx=e.clientX-r.left, my=e.clientY-r.top, hit=null;
4464
4649
  for(var i=0;i<nodes.length;i++){ var hb=nodes[i]._hit; if(hb&&mx>=hb.x&&mx<=hb.x+hb.w&&my>=hb.y&&my<=hb.y+hb.h) hit=nodes[i]; }
4465
4650
  if(hit){ openDrawer(hit.id); if(String(hit.id).indexOf('pad:')!==0) selectNode(hit.id, hit.name); }
@@ -4605,9 +4790,11 @@ button.act:disabled{opacity:0.5;cursor:default;}
4605
4790
  // the warm sun")
4606
4791
  // The underside and drop shadow give the park real depth so it
4607
4792
  // doesn't look like a flat sticker on the sky.
4608
- // Build the grass perimeter polygon once (offset slightly outward
4609
- // so the gold rim sits just outside the turf edge).
4610
- var GRASS_RIM = 0.18; // world units of gold rim thickness
4793
+ // Build the grass perimeter polygon once (the same corners the
4794
+ // grass field uses, so the gold wall's top edge sits flush
4795
+ // against the grass). We use grassPerim(0) no offset — for
4796
+ // the walls, and the perimeter itself is used again later for
4797
+ // the bottom face of the floating island (see UNDERSIDE_LIFT).
4611
4798
  function grassPerim(offset){
4612
4799
  // Use the same world corners as the grass field, offset outward.
4613
4800
  var o = offset;
@@ -4724,39 +4911,80 @@ button.act:disabled{opacity:0.5;cursor:default;}
4724
4911
  X.fill();
4725
4912
  }
4726
4913
  X.restore();
4727
- // 3. GOLD RIMthick gold band tracing the grass perimeter.
4728
- // Drawn AFTER the underside so the top edge of the gold
4729
- // sits flush with the top of the grass. Two strokes: a wide
4730
- // gold core + a thin lighter inner highlight on the top edge.
4731
- var rimOuter = grassPerim(GRASS_RIM);
4914
+ // 3. RAISED GOLD WALLSproper 3D parapet around the park.
4915
+ // The wall follows the grass perimeter (4-corner rectangle).
4916
+ // Each side has a visible front face that extends downward in
4917
+ // screen space, plus a thin top-edge highlight that catches
4918
+ // the warm sun light. Drawn AFTER the underside so the top
4919
+ // edge of the wall sits flush with the top of the grass.
4920
+ //
4921
+ // Geometry: the wall's TOP edge is the grass perimeter line.
4922
+ // Its BOTTOM edge is the same line shifted down by WALL_H
4923
+ // pixels. We draw each of the 4 sides as a quad (TL, TR,
4924
+ // BR, BL), then trace a thin gold line along the top of
4925
+ // each side for the highlight.
4732
4926
  var rimInner = grassPerim(0);
4733
- var rimOuterScreen = rimOuter.map(function(p){ return iso(p[0], p[1]); });
4734
4927
  var rimInnerScreen = rimInner.map(function(p){ return iso(p[0], p[1]); });
4928
+ var WALL_H = 26 * z; // wall height in screen pixels
4929
+ // Build bottom-edge screen points by shifting each top point
4930
+ // down by WALL_H.
4931
+ var rimBotScreen = rimInnerScreen.map(function(s){
4932
+ return { x: s.x, y: s.y + WALL_H };
4933
+ });
4934
+ // Front face: a single closed polygon (the grass perimeter top
4935
+ // + the grass perimeter bottom, traversed in reverse). Drawn
4936
+ // in two passes — first a darker base, then a lighter overlay
4937
+ // stripe near the top to give the wall some "warm-light from
4938
+ // above" shading.
4735
4939
  X.save();
4736
- // outer glow
4737
- var glowGrad = X.createLinearGradient(0, rimOuterScreen[0].y - 30, 0, rimOuterScreen[3].y + 30);
4738
- X.fillStyle = 'rgba(255, 200, 110, 0.0)';
4739
- // fill the rim band as a quad
4940
+ // base wall (slightly darker gold)
4941
+ X.fillStyle = 'rgba(176, 124, 44, 0.96)';
4740
4942
  X.beginPath();
4741
- X.moveTo(rimOuterScreen[0].x, rimOuterScreen[0].y);
4742
- X.lineTo(rimOuterScreen[1].x, rimOuterScreen[1].y);
4743
- X.lineTo(rimOuterScreen[2].x, rimOuterScreen[2].y);
4744
- X.lineTo(rimOuterScreen[3].x, rimOuterScreen[3].y);
4943
+ X.moveTo(rimInnerScreen[0].x, rimInnerScreen[0].y);
4944
+ X.lineTo(rimInnerScreen[1].x, rimInnerScreen[1].y);
4945
+ X.lineTo(rimInnerScreen[2].x, rimInnerScreen[2].y);
4946
+ X.lineTo(rimInnerScreen[3].x, rimInnerScreen[3].y);
4947
+ X.lineTo(rimBotScreen[3].x, rimBotScreen[3].y);
4948
+ X.lineTo(rimBotScreen[2].x, rimBotScreen[2].y);
4949
+ X.lineTo(rimBotScreen[1].x, rimBotScreen[1].y);
4950
+ X.lineTo(rimBotScreen[0].x, rimBotScreen[0].y);
4745
4951
  X.closePath();
4746
4952
  X.fill();
4747
- // gold band via two strokes: thick base + thin highlight
4748
- X.strokeStyle = 'rgba(255, 196, 92, 0.95)'; // base gold
4749
- X.lineWidth = 5.0*z;
4953
+ // warm-light gradient overlay fades from bright gold at the
4954
+ // top of the wall to transparent at the bottom, so the wall
4955
+ // reads as if the top is catching the sun and the bottom is
4956
+ // in shadow.
4957
+ var wallGrad = X.createLinearGradient(0, rimInnerScreen[0].y, 0, rimInnerScreen[0].y + WALL_H);
4958
+ wallGrad.addColorStop(0, 'rgba(255, 226, 132, 0.85)');
4959
+ wallGrad.addColorStop(0.35, 'rgba(255, 196, 92, 0.55)');
4960
+ wallGrad.addColorStop(1, 'rgba(176, 124, 44, 0)');
4961
+ X.fillStyle = wallGrad;
4750
4962
  X.beginPath();
4751
4963
  X.moveTo(rimInnerScreen[0].x, rimInnerScreen[0].y);
4752
4964
  X.lineTo(rimInnerScreen[1].x, rimInnerScreen[1].y);
4753
4965
  X.lineTo(rimInnerScreen[2].x, rimInnerScreen[2].y);
4754
4966
  X.lineTo(rimInnerScreen[3].x, rimInnerScreen[3].y);
4967
+ X.lineTo(rimBotScreen[3].x, rimBotScreen[3].y);
4968
+ X.lineTo(rimBotScreen[2].x, rimBotScreen[2].y);
4969
+ X.lineTo(rimBotScreen[1].x, rimBotScreen[1].y);
4970
+ X.lineTo(rimBotScreen[0].x, rimBotScreen[0].y);
4971
+ X.closePath();
4972
+ X.fill();
4973
+ // outer dark base line at the bottom of the wall (the bottom
4974
+ // edge against the sky) — sells the "raised" effect
4975
+ X.strokeStyle = 'rgba(124, 84, 28, 0.95)';
4976
+ X.lineWidth = 1.4 * z;
4977
+ X.beginPath();
4978
+ X.moveTo(rimBotScreen[0].x, rimBotScreen[0].y);
4979
+ X.lineTo(rimBotScreen[1].x, rimBotScreen[1].y);
4980
+ X.lineTo(rimBotScreen[2].x, rimBotScreen[2].y);
4981
+ X.lineTo(rimBotScreen[3].x, rimBotScreen[3].y);
4755
4982
  X.closePath();
4756
4983
  X.stroke();
4757
- // inner highlight (thinner, brighter)
4758
- X.strokeStyle = 'rgba(255, 232, 158, 0.95)';
4759
- X.lineWidth = 1.4*z;
4984
+ // top edge — gold band right at the grass line, with a thin
4985
+ // lighter highlight on top to catch the sun.
4986
+ X.strokeStyle = 'rgba(196, 138, 48, 0.95)';
4987
+ X.lineWidth = 4.0 * z;
4760
4988
  X.beginPath();
4761
4989
  X.moveTo(rimInnerScreen[0].x, rimInnerScreen[0].y);
4762
4990
  X.lineTo(rimInnerScreen[1].x, rimInnerScreen[1].y);
@@ -4764,16 +4992,26 @@ button.act:disabled{opacity:0.5;cursor:default;}
4764
4992
  X.lineTo(rimInnerScreen[3].x, rimInnerScreen[3].y);
4765
4993
  X.closePath();
4766
4994
  X.stroke();
4767
- // outer deep-gold edge (slightly darker outer stroke for definition)
4768
- X.strokeStyle = 'rgba(190, 134, 48, 0.85)';
4769
- X.lineWidth = 1.2*z;
4995
+ // bright top highlight (thin, hottest color)
4996
+ X.strokeStyle = 'rgba(255, 232, 158, 0.95)';
4997
+ X.lineWidth = 1.2 * z;
4770
4998
  X.beginPath();
4771
- X.moveTo(rimOuterScreen[0].x, rimOuterScreen[0].y);
4772
- X.lineTo(rimOuterScreen[1].x, rimOuterScreen[1].y);
4773
- X.lineTo(rimOuterScreen[2].x, rimOuterScreen[2].y);
4774
- X.lineTo(rimOuterScreen[3].x, rimOuterScreen[3].y);
4999
+ X.moveTo(rimInnerScreen[0].x, rimInnerScreen[0].y);
5000
+ X.lineTo(rimInnerScreen[1].x, rimInnerScreen[1].y);
5001
+ X.lineTo(rimInnerScreen[2].x, rimInnerScreen[2].y);
5002
+ X.lineTo(rimInnerScreen[3].x, rimInnerScreen[3].y);
4775
5003
  X.closePath();
4776
5004
  X.stroke();
5005
+ // corner studs — small gold dots at each of the 4 perimeter
5006
+ // vertices so the wall reads as constructed panels, not a
5007
+ // single continuous block.
5008
+ for(var sci=0; sci<rimInnerScreen.length; sci++){
5009
+ var sPt = rimInnerScreen[sci];
5010
+ X.fillStyle = 'rgba(255, 220, 140, 0.95)';
5011
+ X.beginPath(); X.arc(sPt.x, sPt.y, 2.2 * z, 0, Math.PI*2); X.fill();
5012
+ X.fillStyle = 'rgba(196, 138, 48, 0.85)';
5013
+ X.beginPath(); X.arc(sPt.x, sPt.y, 3.4 * z, 0, Math.PI*2); X.stroke();
5014
+ }
4777
5015
  X.restore();
4778
5016
  // ── flower spots — deterministic small dots in 3 colors, scattered in the lawn only ──
4779
5017
  X.save();
@@ -6263,19 +6501,27 @@ button.act:disabled{opacity:0.5;cursor:default;}
6263
6501
  // Cars share the same elapsed-dt clock so a tab-switch doesn't
6264
6502
  // catapult every car off the road at once. speed is world units
6265
6503
  // per second, dt is milliseconds. cdt is the already-clamped dt.
6504
+ // Per-frame speed jitter (±5%) so the same lane never settles into
6505
+ // a stable cluster — the fastest car gradually pulls away from the
6506
+ // slowest one and the spread stays alive even after many wraps.
6266
6507
  var cdt = Math.min(64, dt);
6267
6508
  for(var ci=0; ci<CARS.length; ci++){
6268
6509
  var car = CARS[ci];
6269
- car.x += car.dir * car.speed * (cdt/1000);
6510
+ // Deterministic per-car jitter based on wall-clock + index so it
6511
+ // changes every frame but isn't pure noise (smooth-ish accel).
6512
+ var jitter = 1 + (Math.sin(now/380 + ci*1.7) * 0.05);
6513
+ car.x += car.dir * car.speed * jitter * (cdt/1000);
6270
6514
  // Wrap the car around the road. When it leaves one end, it
6271
- // re-enters the opposite end on the other lane so oncoming
6272
- // traffic feels continuous.
6515
+ // re-enters the opposite end on the other lane and the
6516
+ // re-entry x is randomized across the visible road so two
6517
+ // cars that arrived at the boundary together don't land on
6518
+ // top of each other.
6273
6519
  if(car.dir > 0 && car.x > PARK_W + 0.5){
6274
- car.x = -0.5;
6520
+ car.x = -0.5 - Math.random() * (PARK_W * 0.5);
6275
6521
  car.y = (car.y < -0.9) ? -0.65 : -1.18;
6276
6522
  car.dir = (car.y < -0.9) ? +1 : -1;
6277
6523
  } else if(car.dir < 0 && car.x < -0.5){
6278
- car.x = PARK_W + 0.5;
6524
+ car.x = PARK_W + 0.5 + Math.random() * (PARK_W * 0.5);
6279
6525
  car.y = (car.y < -0.9) ? -0.65 : -1.18;
6280
6526
  car.dir = (car.y < -0.9) ? +1 : -1;
6281
6527
  }
@@ -6489,6 +6735,25 @@ button.act:disabled{opacity:0.5;cursor:default;}
6489
6735
  ensure();
6490
6736
  loadSDK();
6491
6737
  loadImages();
6738
+ // Pull the latest operator-saved positions from the scheduler
6739
+ // and seed the in-memory override map. Without this step the
6740
+ // dashboard would only know about local (this-session) drags
6741
+ // and would reset to slot defaults on every reload.
6742
+ try{
6743
+ getJson('/api/character-presets'+QS).then(function(list){
6744
+ if(Array.isArray(list)){
6745
+ for(var pi=0; pi<list.length; pi++){
6746
+ var p = list[pi];
6747
+ if(p && p.id && typeof p.nx === 'number' && typeof p.ny === 'number'){
6748
+ _parkPosOverrides[p.id] = { nx: p.nx, ny: p.ny };
6749
+ }
6750
+ }
6751
+ }
6752
+ // re-apply on the live node list (if it's already populated
6753
+ // by a prior /fed/status frame)
6754
+ if(typeof _applyPosOverrides === 'function') _applyPosOverrides();
6755
+ }).catch(function(){ /* offline — use slot defaults */ });
6756
+ }catch(e){}
6492
6757
  if(lastStatus) onStatus(lastStatus);
6493
6758
  resize();
6494
6759
  connectStream();
@@ -115,35 +115,51 @@ function ensureRobovoiceSingleAudioTrack(path) {
115
115
  return { patched: false, warning: 'voice_agent.py was not found; skipping RoboPark greeting compatibility patch' };
116
116
  }
117
117
  const source = readFileSync(file, 'utf8');
118
- if (source.includes('Initial greeting completed through AgentSession audio track')) {
119
- return { patched: false, file };
120
- }
118
+ let updated = source;
119
+ let patched = false;
120
+ const newline = source.includes('\r\n') ? '\r\n' : '\n';
121
+ const singleTrackMarker = 'Initial greeting completed through AgentSession audio track';
121
122
  const startMarker = ' # Synthesize the greeting first';
122
123
  const endMarker = ' except Exception as e:';
123
- const start = source.indexOf(startMarker);
124
- const end = start >= 0 ? source.indexOf(endMarker, start) : -1;
125
- if (start < 0 || end < 0) {
126
- return {
127
- patched: false,
128
- file,
129
- warning: 'voice_agent.py does not contain the known duplicate greeting-track block; no automatic patch applied',
130
- };
124
+ if (!updated.includes(singleTrackMarker)) {
125
+ const start = updated.indexOf(startMarker);
126
+ const end = start >= 0 ? updated.indexOf(endMarker, start) : -1;
127
+ if (start < 0 || end < 0) {
128
+ return {
129
+ patched: false,
130
+ file,
131
+ warning: 'voice_agent.py does not contain the known duplicate greeting-track block; no automatic patch applied',
132
+ };
133
+ }
134
+ const replacement = [
135
+ ' # Use the existing conversation track. A dedicated greeting',
136
+ ' # track makes Linux robots compete for the same ALSA device.',
137
+ ' import inspect as _inspect',
138
+ ' speech = session.say(greeting, allow_interruptions=False)',
139
+ ' if _inspect.isawaitable(speech):',
140
+ ' await speech',
141
+ " elif hasattr(speech, 'wait_for_playout'):",
142
+ ' await speech.wait_for_playout()',
143
+ " logger.info('Initial greeting completed through AgentSession audio track')",
144
+ '',
145
+ ].join(newline);
146
+ updated = updated.slice(0, start) + replacement + updated.slice(end);
147
+ patched = true;
131
148
  }
132
- const newline = source.includes('\r\n') ? '\r\n' : '\n';
133
- const replacement = [
134
- ' # Use the existing conversation track. A dedicated greeting',
135
- ' # track makes Linux robots compete for the same ALSA device.',
136
- ' import inspect as _inspect',
137
- ' speech = session.say(greeting, allow_interruptions=False)',
138
- ' if _inspect.isawaitable(speech):',
139
- ' await speech',
140
- " elif hasattr(speech, 'wait_for_playout'):",
141
- ' await speech.wait_for_playout()',
142
- " logger.info('Initial greeting completed through AgentSession audio track')",
143
- '',
144
- ].join(newline);
145
- writeFileSync(file, source.slice(0, start) + replacement + source.slice(end), 'utf8');
146
- return { patched: true, file };
149
+ // The job is dispatched after the robot participant joins. Waiting for its
150
+ // camera adds camera startup to voice latency even though audio is ready.
151
+ const cameraGate = / await asyncio\.wait_for\(robot_camera_ready\.wait\(\), timeout\s*=\s*[0-9.]+\)/;
152
+ if (cameraGate.test(updated)) {
153
+ updated = updated.replace(cameraGate, [
154
+ ' # The robot is already in the room when this job is dispatched.',
155
+ ' # Yield once for subscription signalling; do not gate voice on camera.',
156
+ ' await asyncio.sleep(0)',
157
+ ].join(newline));
158
+ patched = true;
159
+ }
160
+ if (patched)
161
+ writeFileSync(file, updated, 'utf8');
162
+ return { patched, file };
147
163
  }
148
164
  export async function roboparkAgentUp(opts) {
149
165
  console.log(chalk.bold('\n robopark agent up'));
@@ -135,10 +135,25 @@ export async function roboparkRobotRuntime(opts) {
135
135
  // Motion remains enabled, but it reads RoboVision's shared stream. Only
136
136
  // RoboVision may open the physical V4L2 device.
137
137
  LOCAL_CAMERA_MOTION: 'true',
138
+ // The first production sound is the greeting. A beep would occupy the
139
+ // same exclusive ALSA device and delay or clip TTS.
140
+ ROBOPARK_MOTION_CUE: 'false',
138
141
  // Fleet hardware profile: resolve volatile ALSA hw:X,Y coordinates
139
142
  // from these USB product labels on every inventory refresh.
140
143
  ROBOPARK_AUDIO_INPUT_MATCH: 'Usb Audio Device: USB Audio',
141
144
  ROBOPARK_AUDIO_OUTPUT_MATCH: 'USB Audio Device: -',
145
+ // Launch hardware has no acoustic echo cancellation. Suppress mic PCM
146
+ // only while audible robot speech is playing, then hold briefly for
147
+ // room echo decay so VAD cannot interrupt the robot with its own voice.
148
+ ROBOPARK_HALF_DUPLEX: 'true',
149
+ ROBOPARK_ECHO_TAIL_MS: '700',
150
+ ROBOPARK_ECHO_GATE_PEAK: '96',
151
+ // Keep ordinary speaker echo muted but reopen the microphone when a
152
+ // nearby voice rises decisively above the learned acoustic echo floor.
153
+ ROBOPARK_ADAPTIVE_BARGE_IN: 'true',
154
+ ROBOPARK_BARGE_IN_MIN_PEAK: '2200',
155
+ ROBOPARK_BARGE_IN_ECHO_RATIO: '2.4',
156
+ ROBOPARK_BARGE_IN_HOLD_MS: '1400',
142
157
  // Inventory changes should reach the Control Center promptly.
143
158
  HEARTBEAT_INTERVAL: '5',
144
159
  },
@@ -199,3 +199,42 @@ Required result: `playback_started ok`.
199
199
  rejects duplicate assignments, limits pulses to 10 seconds, serializes all
200
200
  actuation, and de-energizes every registered relay on errors and shutdown.
201
201
  - A missing or busy GPIO driver is a failed test, never a simulated success.
202
+
203
+ ## Robot Speaker Echo Contract (2.8.100+)
204
+
205
+ - The launch USB speaker/microphone stack has no hardware acoustic echo
206
+ cancellation, so production uses robot-side half-duplex echo suppression.
207
+ - While outbound TTS contains audible PCM, the Pi publishes timing-correct
208
+ silence instead of amplified microphone PCM. The microphone track remains
209
+ connected and resumes after a 700 ms acoustic decay tail.
210
+ - Silent frames on the persistent TTS track do not hold the gate open. Output
211
+ peak detection releases the visitor microphone between responses.
212
+ - `ROBOPARK_HALF_DUPLEX`, `ROBOPARK_ECHO_TAIL_MS`, and
213
+ `ROBOPARK_ECHO_GATE_PEAK` are explicit runtime controls. Disable half-duplex
214
+ only after deploying real acoustic echo cancellation.
215
+
216
+ ## Instant Greeting Contract (2.8.101+)
217
+
218
+ - Motion reporting is asynchronous and never delays session allocation.
219
+ - RoboVision's shared camera stream is not stopped or reopened on a trigger.
220
+ - The old fixed 500 ms trigger delay is removed.
221
+ - The local motion beep is disabled by default because it occupies the same
222
+ exclusive ALSA speaker as TTS. `ROBOPARK_MOTION_CUE=true` is diagnostics only.
223
+ - The voice worker uses the persistent AgentSession audio track and starts the
224
+ greeting after robot join; it does not wait for camera subscription.
225
+ - Network, scheduler, LiveKit, and TTS synthesis still impose real latency, but
226
+ RoboPark adds no intentional serial delay before greeting generation.
227
+
228
+ ## Adaptive Interruption Contract (2.8.102+)
229
+
230
+ - The preset greeting remains non-interruptible so ambient startup noise cannot
231
+ cancel it before the first audible frame.
232
+ - Normal responses retain LiveKit AgentSession interruption handling.
233
+ - Robot half-duplex suppression learns the microphone's active speaker-echo
234
+ floor. Three consecutive near-field peaks above both the absolute threshold
235
+ and the adaptive echo ratio reopen microphone PCM for the VAD.
236
+ - The barge-in window remains open for 1.4 seconds, allowing LiveKit to cancel
237
+ TTS; ordinary speaker echo remains suppressed with the existing decay tail.
238
+ - Vision responses inherit the selected voice-stack output language. With an
239
+ automatic profile, the original user transcript language is authoritative,
240
+ including direct terminal vision-tool responses.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinicode",
3
- "version": "2.8.99",
3
+ "version": "2.8.102",
4
4
  "description": "OpenKernel — provider-agnostic AI execution kernel. Native coding agent + mission-driven execution runtime.",
5
5
  "type": "module",
6
6
  "main": "./dist/kernel/index.js",