@bitefoundry/retrieviewer 0.0.8 → 0.0.10

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.
Binary file
@@ -49,7 +49,7 @@ export function createPaperProjection({viewportWidth,viewportHeight,rect,width,h
49
49
  const cx=rect.width/2,cy=rect.height/2;
50
50
  return{
51
51
  project(x,y,z){const p=paperCameraPoint(x,y,z);if(p.depth<=.02)throw Error('Paper point behind camera');return{x:(cx+f*p.x/p.depth)/rect.width,y:(cy-f*p.y/p.depth)/rect.height,depth:p.depth};},
52
- placement(x,z){const p=this.project(x,0,z),sourceSize=f*PAPER_CAMERA.canvasMeters/p.depth;return{x:p.x,y:p.y,size:sourceSize/rect.height,pixelAspect:(rect.width/width)/(rect.height/height),depth:p.depth,
52
+ placement(x,z,lift=0){const p=this.project(x,lift,z),sourceSize=f*PAPER_CAMERA.canvasMeters/p.depth;return{x:p.x,y:p.y,size:sourceSize/rect.height,pixelAspect:(rect.width/width)/(rect.height/height),depth:p.depth,
53
53
  groundFromPixel:(px,py)=>this.ground(px/width,py/height),pixelFromGround:point=>{const q=this.project(point.x,0,point.z);return{x:q.x*width,y:q.y*height};}};},
54
54
  ground(u,v){
55
55
  if(![u,v].every(Number.isFinite))return null;
@@ -104,12 +104,58 @@ export function planPaperApproachTravel(distance){
104
104
  return{distance,duration,peak:Math.max(...segments.map(p=>p.peak)),brakingDistance:brakeDistance,at(t){if(!Number.isFinite(t))throw Error('Invalid approach time');if(t>=duration)return{distance,speed:0,stage:'arrived',done:true};let offset=0;for(let i=0;i<segments.length;i++){const p=segments[i];if(t<p.duration||i===segments.length-1){const s=p.at(t);return{...s,distance:offset+s.distance};}t-=p.duration;offset+=p.distance;}}};
105
105
  }
106
106
 
107
+ // Ball-carry return: a true diagonal trot, optionally preceded by whole gallop
108
+ // cycles. The last small gathering steps MOVE through the final centimeters;
109
+ // the standing endpoint is reached at zero velocity, never a skidding still.
110
+ export function planPaperCarry(distance,{mixed=false,settleSeconds=.22}={}){
111
+ if(!Number.isFinite(distance)||distance<=0||typeof mixed!=='boolean'||!Number.isFinite(settleSeconds)||settleSeconds<=0||settleSeconds>1)throw Error('Invalid carry travel');
112
+ const gait=PAPER_GAITS.trot,runDistance=mixed?Math.max(0,Math.floor((distance-1.5)/run.stride))*run.stride:0;
113
+ const remaining=distance-runDistance,gatherDistance=Math.min(.088,remaining*.2),trotDistance=remaining-gatherDistance;
114
+ const cycles=Math.max(1,Math.ceil(trotDistance/gait.stride)),stride=trotDistance/cycles;
115
+ const speed=Math.min(gait.speed,stride*gait.frequency),entrySpeed=Math.min(speed,2*gatherDistance/settleSeconds,Math.sqrt(2*2.5*trotDistance));
116
+ const gatherSeconds=2*gatherDistance/entrySpeed;
117
+ const segments=[];
118
+ if(runDistance)segments.push(planPaperTravel(runDistance,{speed:run.speed,...PAPER_DYNAMICS,endSpeed:speed}));
119
+ segments.push(planPaperTravel(trotDistance,{speed,acceleration:2.5,deceleration:4,startSpeed:runDistance?speed:0,endSpeed:entrySpeed}));
120
+ segments.push(planPaperTravel(gatherDistance,{speed:entrySpeed,startSpeed:entrySpeed,deceleration:entrySpeed/gatherSeconds}));
121
+ const duration=segments.reduce((a,p)=>a+p.duration,0),gatherStart=runDistance+trotDistance;
122
+ return{distance,duration,peak:Math.max(...segments.map(p=>p.peak)),brakingDistance:gatherDistance,stride,runDistance,trotDistance,gatherStart,gatherSeconds,
123
+ poseAt(d){const phase=(d-runDistance)/stride;return d<runDistance?{clip:'run',progress:d/run.stride}:d<gatherStart?{clip:'carry-trot',progress:phase%1,cycle:Math.floor(phase)}:{clip:'carry-settle',progress:1-Math.sqrt(Math.max(0,(distance-d)/gatherDistance))};},
124
+ at(t){if(!Number.isFinite(t))throw Error('Invalid carry time');if(t>=duration)return{distance,speed:0,stage:'arrived',done:true};let offset=0;for(let i=0;i<segments.length;i++){const p=segments[i];if(t<p.duration||i===segments.length-1){const s=p.at(t);return{...s,distance:offset+s.distance};}t-=p.duration;offset+=p.distance;}}
125
+ };
126
+ }
127
+
107
128
  // Finish at a shared cycle endpoint without animating extra strides in place.
108
129
  // Short paths use shorter strides and a correspondingly lower speed ceiling.
109
130
  export function planPaperRun(distance){
110
131
  const cycles=Math.max(1,Math.ceil(distance/run.stride)),stride=distance/cycles||run.stride;
111
132
  return{stride,cycles,plan:planPaperTravel(distance,{speed:Math.min(run.speed,stride*run.frequency),...PAPER_DYNAMICS})};
112
133
  }
134
+
135
+ // A single forward chase ending in a braced stumble/catch at the ball. Solve
136
+ // entry distance and stride TOGETHER: positive entry velocity, exact contact
137
+ // phase, no pre-impact braking, no overshoot/second go and no extra run cycle.
138
+ export function planPaperStumble(distance,{entryPhase=.25,brakeSeconds=.27,catchSeconds=.36,catchSpeedRatio=.18,duration=.82}={}){
139
+ if(![distance,entryPhase,brakeSeconds,catchSeconds,catchSpeedRatio,duration].every(Number.isFinite)||distance<=0||entryPhase<=0||entryPhase>=1||brakeSeconds<=0||catchSeconds<=0||catchSpeedRatio<=0||catchSpeedRatio>=1||duration<brakeSeconds+catchSeconds||duration>3)throw Error('Invalid stumble travel');
140
+ const cycles=Math.max(0,Math.ceil(distance/run.stride-entryPhase)),contacts=cycles+entryPhase,a=PAPER_DYNAMICS.acceleration;
141
+ const coefficient=((1+catchSpeedRatio)*brakeSeconds+catchSpeedRatio*catchSeconds)/2;
142
+ const speedAt=d=>Math.min(run.speed,d/contacts*run.frequency,Math.sqrt(2*a*d));
143
+ let lo=0,hi=distance;
144
+ for(let i=0;i<60;i++){const d=(lo+hi)/2;if(d+coefficient*speedAt(d)<distance)lo=d;else hi=d;}
145
+ const entryDistance=(lo+hi)/2,entrySpeed=speedAt(entryDistance),stride=entryDistance/contacts,catchSpeed=entrySpeed*catchSpeedRatio;
146
+ const entry=planPaperTravel(entryDistance,{speed:entrySpeed,acceleration:a,deceleration:PAPER_DYNAMICS.deceleration,endSpeed:entrySpeed});
147
+ const plan=planPaperTravel(distance,{speed:entrySpeed,acceleration:a,deceleration:PAPER_DYNAMICS.deceleration,endSpeed:entrySpeed});
148
+ const brakeDistance=(entrySpeed+catchSpeed)*brakeSeconds/2,impactDistance=distance-entryDistance;
149
+ return{plan,stride,entryDistance,entrySpeed,entryTime:entry.duration,entryPhase,duration,impactDistance,
150
+ impactAt(age){
151
+ if(!Number.isFinite(age))throw Error('Invalid stumble age');const t=Math.max(0,age);
152
+ if(t>=brakeSeconds+catchSeconds)return{distance:impactDistance,speed:0,stage:'rest'};
153
+ if(t<brakeSeconds){const deceleration=(entrySpeed-catchSpeed)/brakeSeconds;return{distance:entrySpeed*t-deceleration*t*t/2,speed:entrySpeed-deceleration*t,stage:'bracing'};}
154
+ const u=t-brakeSeconds,deceleration=catchSpeed/catchSeconds;
155
+ return{distance:brakeDistance+catchSpeed*u-deceleration*u*u/2,speed:catchSpeed-deceleration*u,stage:'catching'};
156
+ }
157
+ };
158
+ }
113
159
  export function planPaperWalk(distance){
114
160
  const gait=PAPER_GAITS.walk,cycles=Math.max(1,Math.ceil(distance/gait.stride)),stride=distance/cycles||gait.stride;
115
161
  return{stride,cycles,plan:planPaperTravel(distance,{speed:Math.min(gait.speed,stride*gait.frequency),acceleration:PAPER_DYNAMICS.walkAcceleration,deceleration:PAPER_DYNAMICS.walkDeceleration})};
package/puppy-paper.mjs CHANGED
@@ -1,14 +1,14 @@
1
1
  import {createCachedTween,retimeRun,cleanGroundOffset} from './puppy-clean.mjs';
2
2
  import {createPaperFootLock,footSourcePoint,createRearSampler,createPaperSupportEpisodes} from './puppy-paper-contact.mjs';
3
3
  import {createPaperSeatSampler} from './puppy-paper-seat.mjs';
4
- import {planPaperRun,planPaperWalk,paperWalkTarget,planPaperApproach,planPaperApproachTravel,PAPER_GAITS,PAPER_GROUND,paperGroundTarget} from './puppy-paper-world.mjs';
4
+ import {planPaperRun,planPaperWalk,planPaperCarry,planPaperStumble,paperWalkTarget,planPaperApproach,planPaperApproachTravel,PAPER_GAITS,PAPER_GROUND,paperGroundTarget} from './puppy-paper-world.mjs';
5
5
  import {createPaperDirector} from './puppy-paper-director.mjs';
6
6
  import {createPaperBehaviorSampler,paperBehaviorPose} from './puppy-paper-behaviors.mjs';
7
7
  import {createPaperAreaSampler} from './puppy-paper-area.mjs';
8
8
  import {PAPER_ACTION_DURATIONS,createPaperActionSampler,paperActionPose} from './puppy-paper-actions.mjs';
9
9
  import {createPawTransport} from './puppy-paper-paw-transport.mjs';
10
10
  import {createCompanionSpriteSampler} from './puppy-companion-sprites.mjs';
11
- import {createComfortSpriteSampler} from './puppy-comfort-sprites.mjs';
11
+ import {createComfortSpriteSampler,comfortMotion,comfortMotionKey} from './puppy-comfort-sprites.mjs';
12
12
  import {mouthSourceFrame,mouthAnchor,projectMouth} from './puppy-mouth-anchor.mjs';
13
13
  import {trickClipEdges} from './puppy-paper-trick-timing.mjs';
14
14
 
@@ -37,12 +37,21 @@ export function paperWatchProgress(age){
37
37
  }
38
38
 
39
39
  // Original artwork only. Physics supplies ground travel; no procedural dog is drawn.
40
- export function createPaperActor(animal,info,{manualTricks=false,trickTimings=null,walkTransitions=false,comfortTimings=PAPER_COMFORT_TIMINGS}={}){
40
+ export function createPaperActor(animal,info,{manualTricks=false,trickTimings=null,walkTransitions=false,comfortTimings=PAPER_COMFORT_TIMINGS,scrambleSeconds=0,fetchMotions=null}={}){
41
+ if(!Number.isFinite(scrambleSeconds)||scrambleSeconds<0||scrambleSeconds>3)throw Error('Invalid scramble duration');
42
+ if(fetchMotions&&(!Number.isFinite(fetchMotions.scramble?.entryPhase)||fetchMotions.scramble.entryPhase<=0||fetchMotions.scramble.entryPhase>=1||!Number.isFinite(fetchMotions.scramble.brakeSeconds)||fetchMotions.scramble.brakeSeconds<=0||fetchMotions.scramble.brakeSeconds>scrambleSeconds||!['front','rear'].every(v=>Number.isFinite(fetchMotions.pickups?.[v]?.duration)&&fetchMotions.pickups[v].duration>0&&fetchMotions.pickups[v].duration<=3)))throw Error('Invalid fetch actor metadata');
43
+ if(fetchMotions?.scramble.trajectory==='single-pass-target'){
44
+ if(fetchMotions.scramble.entryView!=='rear'||fetchMotions.scramble.duration!==scrambleSeconds)throw Error('Invalid single-pass view/duration');
45
+ planPaperStumble(1,fetchMotions.scramble); // Eagerly validate the complete owned motion profile.
46
+ }
47
+ const recovery=fetchMotions?.scramble?.recovery;
48
+ if(recovery&&(![recovery.start,recovery.end,recovery.distance,recovery.height].every(Number.isFinite)||recovery.start<fetchMotions.scramble.brakeSeconds||recovery.end<=recovery.start||recovery.end>scrambleSeconds||recovery.distance<0||recovery.distance>.2||recovery.height<0||recovery.height>.1))throw Error('Invalid fetch recovery metadata');
41
49
  const bellyTiming=comfortTimings?.belly;
42
50
  if(!bellyTiming||![bellyTiming.hold,bellyTiming.enter,bellyTiming.exit,comfortTimings.tugExit].every(Number.isFinite)||bellyTiming.hold<=0||bellyTiming.hold>=1||bellyTiming.enter<=0||bellyTiming.exit<=0||comfortTimings.tugExit<=0)throw Error('Invalid comfort timing');
43
51
  let state='idle',age=0,phase=0,flip=false,nextFlip=false,target=null,route=false,routeIndex=0,view='front',nextView='front',turnView='front',turnFlip=false;
44
52
  let held=null,waypoints=[];
45
53
  let preparationRate=1;
54
+ let scrambleArmed=false,movingScramble=null,carryMode=null;
46
55
  let arrived=false;
47
56
  let straight=false,seatProgress=0,seatVariant='default',travel=null,approachPlan=null,travelAge=0,travelDistance=0,motionSpeed=0,motionStage='rest',completed=false;
48
57
  let watchSide='left',restProgress=0,idleHold=2.5;
@@ -50,7 +59,7 @@ export function createPaperActor(animal,info,{manualTricks=false,trickTimings=nu
50
59
  let comfort=null,comfortSeconds=0,sleepLook=null,nextSleepLook={variant:'side',right:false};
51
60
  const director=createPaperDirector();
52
61
  const points=[[.12,PAPER_GROUND.away]];
53
- const enter=s=>{state=s;age=0;};
62
+ const enter=s=>{state=s;age=0;if(s==='idle')preparationRate=1;};
54
63
  const nextRoute=()=>{const choice=director.choose({x:animal.position.x,z:animal.position.z,seated:view==='approach'&&seatProgress===1});idleHold=choice.holdSeconds;if(choice.kind==='watch'){watchSide=choice.side;enter('watch');}else if(choice.kind==='rest')enter('recline');else if(choice.kind==='come')go(animal.position.x,.2,{direct:true});else go(...choice.target);};
55
64
  const settle=()=>{if(view==='approach'&&completed)seatVariant='arrival';enter(view==='approach'?'sit-down':'settle');};
56
65
  const stop=()=>{animal.follow({x:animal.position.x,y:0,z:animal.position.z},{x:0,y:0,z:0},{heading:0});motionSpeed=0;};
@@ -58,8 +67,9 @@ export function createPaperActor(animal,info,{manualTricks=false,trickTimings=nu
58
67
  const viewTurnSeconds=()=>snappyPreparation()?.24:walkTransitions&&movementMode==='walk'?.4:1;
59
68
  const turnSeconds=()=>snappyPreparation()?.22:.85;
60
69
  const launchSeconds=()=>snappyPreparation()?.10:.25;
61
- const dispatch=(fn,{keepDown=false}={})=>{
62
- if(activeAction||state==='tug'||(movementMode==='walk'&&state==='run')){route=false;deferred=()=>dispatch(fn,{keepDown});hurryComfort();return true;}
70
+ const dispatch=(fn,{keepDown=false,urgent=false}={})=>{
71
+ if(urgent)preparationRate=3;
72
+ if(activeAction||state==='tug'||state==='scramble'||state==='pickup'||(movementMode==='walk'&&state==='run')){route=false;deferred=()=>dispatch(fn,{keepDown,urgent});hurryComfort();return true;}
63
73
  // The calibrated trick down pose has its own byte-shared recovery; never
64
74
  // jump back into the older/larger rest illustration on another command.
65
75
  if(manualDown&&!keepDown){route=false;deferred=fn;playAction('lie-up',()=>{manualDown=false;view='approach';seatProgress=1;seatVariant='default';enter('idle');drain();},.55);return true;}
@@ -88,9 +98,24 @@ export function createPaperActor(animal,info,{manualTricks=false,trickTimings=nu
88
98
  const finishTrick=name=>{if(name!=='spin'){view='approach';flip=false;seatProgress=1;seatVariant='default';enter('idle');}else{view='front';flip=false;seatProgress=0;enter('settle');}drain();};
89
99
  const issue=(skipEntry=false)=>{
90
100
  if(movementMode==='walk'&&!skipEntry){playAction('walk-enter',()=>issue(true));return;}
91
- preparationRate=1;arrived=false;completed=false;travelAge=0;travelDistance=0;const x=animal.position.x,z=animal.position.z;if(straight&&view==='approach')target[0]=x;
101
+ arrived=false;completed=false;travelAge=0;travelDistance=0;const x=animal.position.x,z=animal.position.z;if(straight&&view==='approach')target[0]=x;
92
102
  const dx=target[0]-x,dz=target[1]-z,distance=Math.hypot(dx,dz),run=movementMode==='walk'?planPaperWalk(distance):planPaperRun(distance);phase=Math.ceil(phase);
93
103
  travel={x,z,dx,dz,heading:Math.atan2(dx,dz),stride:view==='approach'?PAPER_GAITS.approach.stride:run.stride,plan:view==='approach'?planPaperApproachTravel(distance):run.plan};approachPlan=view==='approach'?planPaperApproach(distance):null;stop();enter('run');
104
+ if(['trot','run-trot'].includes(carryMode)&&fetchMotions?.carryTrot&&view==='approach'&&distance>0){
105
+ const carry=planPaperCarry(distance,{mixed:carryMode==='run-trot',settleSeconds:fetchMotions.carryTrot.settle.duration});
106
+ Object.assign(travel,{plan:carry,carry,stride:carry.stride});approachPlan=null;
107
+ }
108
+ if(scrambleArmed&&fetchMotions?.scramble.trajectory==='single-pass-target'&&view===fetchMotions.scramble.entryView&&!waypoints.length){
109
+ const stumble=planPaperStumble(distance,fetchMotions.scramble);
110
+ Object.assign(travel,{plan:stumble.plan,stride:stumble.stride,stumble,scrambleAt:stumble.entryTime});
111
+ }else if(scrambleArmed&&fetchMotions&&view==='front'&&!waypoints.length&&fetchMotions.scramble.trajectory!=='single-pass-target'){
112
+ // Last matching forepaw contact before the endpoint. Invert the existing
113
+ // distance profile once, not on every frame. Source frame pair5/blend0 is
114
+ // shared EXACTLY with the new motion's first key.
115
+ const contact=fetchMotions.scramble.entryPhase,cycle=Math.max(0,Math.floor(distance/travel.stride-contact-.15)),d=(cycle+contact)*travel.stride;
116
+ let lo=0,hi=travel.plan.duration;for(let i=0;i<40;i++){const mid=(lo+hi)/2;if(travel.plan.at(mid).distance<d)lo=mid;else hi=mid;}
117
+ travel.scrambleAt=(lo+hi)/2;
118
+ }
94
119
  };
95
120
  const prepareAction=()=>{
96
121
  const name=pendingAction,desired=name==='spin'?'front':'approach';
@@ -116,8 +141,8 @@ export function createPaperActor(animal,info,{manualTricks=false,trickTimings=nu
116
141
  playAction(name,()=>finishTrick(name));
117
142
  };
118
143
  const prepareDirection=()=>{if(pendingAction){prepareAction();return;}if(!target){settle();return;}if(nextView!==view){turnView=view==='front'?nextView:'front';enter('view-turn');}else if(view==='front'&&nextFlip!==flip){turnFlip=nextFlip;enter('turn');}else issue();};
119
- const requestAction=name=>{
120
- preparationRate=1;
144
+ const requestAction=(name,{urgent=false}={})=>{
145
+ preparationRate=urgent?3:1;
121
146
  if(name==='roll'&&manualDown&&state==='down'){route=false;target=null;waypoints=[];pendingAction=null;stayDown=false;manualDown=false;restProgress=0;playAction('roll-down',()=>finishTrick('roll'));return true;}
122
147
  if(name==='roll'&&state==='down'&&restProgress===.25){route=false;target=null;waypoints=[];pendingAction=null;stayDown=false;restProgress=0;playAction('roll-down',()=>finishTrick('roll'));return true;}
123
148
  if(name==='belly'&&state==='down'&&(manualDown||restProgress===.25)){route=false;target=null;waypoints=[];pendingAction=null;startBelly(comfortSeconds);return true;}
@@ -132,9 +157,11 @@ export function createPaperActor(animal,info,{manualTricks=false,trickTimings=nu
132
157
  return true;
133
158
  };
134
159
  const faceTarget=direct=>{nextFlip=target[0]<animal.position.x;const dz=target[1]-animal.position.z,dx=target[0]-animal.position.x;straight=direct;nextView=direct?'approach':Math.abs(dz)>.16&&Math.abs(dz)>Math.abs(dx)*.7?(dz<0?'rear':'approach'):'front';};
135
- function go(x,z,{direct=false,walking=false,urgent=false}={}){
160
+ function go(x,z,{direct=false,walking=false,urgent=false,scramble=false,returnMode=null}={}){
136
161
  if(![x,z].every(Number.isFinite))throw Error('Invalid paper target');
137
162
  preparationRate=urgent?3:1;
163
+ carryMode=returnMode;
164
+ scrambleArmed=!!(scramble&&fetchMotions);movingScramble=null;
138
165
  target=paperGroundTarget(x,z);
139
166
  pendingAction=null;stayDown=false;comfort=null;movementMode=walking?'walk':'run';
140
167
  const dz=target[1]-animal.position.z,dx=target[0]-animal.position.x;
@@ -156,12 +183,12 @@ export function createPaperActor(animal,info,{manualTricks=false,trickTimings=nu
156
183
  }
157
184
  return{
158
185
  go(x,z){if(![x,z].every(Number.isFinite))throw Error('Invalid paper target');return dispatch(()=>{route=false;go(x,z);});},
159
- // Eager attention changes preparation cadence only. Existing dispatch keeps
160
- // actions/walks at safe endpoints; run physics and distance gait are unchanged.
161
- chase(x,z){if(![x,z].every(Number.isFinite))throw Error('Invalid paper target');return dispatch(()=>{route=false;go(x,z,{urgent:true});});},
186
+ // Eager attention changes preparation/recovery cadence only. Existing dispatch
187
+ // keeps actions/walks at safe endpoints; root physics and gait are unchanged.
188
+ chase(x,z,{scramble=false,returnMode=null}={}){if(![x,z].every(Number.isFinite)||![null,'brake','trot','run-trot'].includes(returnMode))throw Error('Invalid paper target/return mode');return dispatch(()=>{route=false;go(x,z,{urgent:true,scramble,returnMode});},{urgent:true});},
162
189
  come(){return dispatch(()=>{route=false;if(state==='idle'&&seatProgress===1&&Math.abs(animal.position.z-.2)<.002)return;go(animal.position.x,.2,{direct:true});});},
163
190
  roam(){return dispatch(()=>{route=true;routeIndex=0;go(...points[routeIndex]);});},
164
- sit(){return dispatch(()=>requestAction('sit'));},spin(){return dispatch(()=>requestAction('spin'));},roll(){return dispatch(()=>requestAction('roll'),{keepDown:true});},
191
+ sit({urgent=false}={}){return dispatch(()=>requestAction('sit',{urgent}),{urgent});},spin(){return dispatch(()=>requestAction('spin'));},roll(){return dispatch(()=>requestAction('roll'),{keepDown:true});},
165
192
  walk(side,distance=1.05){if(!['left','right'].includes(side)||!Number.isFinite(distance)||distance<=0)throw Error('Invalid walk command');return dispatch(()=>{const [x,z]=paperWalkTarget(animal.position.x,animal.position.z,side==='left'?-1:1,distance);if(Math.abs(x-animal.position.x)<1e-6)return false;route=false;go(x,z,{walking:true});return true;});},
166
193
  look(side='left'){if(state!=='idle'||seatProgress!==1)return false;route=false;watchSide=side==='right'?'right':'left';enter('watch');return true;},
167
194
  lie(){const name=manualDown||activeAction?.name==='lie-down'||restProgress>0?'sit':'lie';return dispatch(()=>requestAction(name));},
@@ -169,24 +196,50 @@ export function createPaperActor(animal,info,{manualTricks=false,trickTimings=nu
169
196
  belly(seconds=6){comfortDuration('belly',seconds);return dispatch(()=>{comfortSeconds=seconds;return requestAction('belly');},{keepDown:true});},
170
197
  sleep(seconds=25,variant='side',right=false){comfortDuration('sleep',seconds);if(!['side','belly','curl','chin'].includes(variant)||typeof right!=='boolean')throw Error('Invalid sleep pose');return dispatch(()=>{comfortSeconds=seconds;nextSleepLook={variant,right};return requestAction('sleep');});},
171
198
  tug(seconds=6){comfortDuration('tug',seconds);return dispatch(()=>{comfortSeconds=seconds;return requestAction('tug');});},
199
+ get canScramble(){return scrambleSeconds>0;},
200
+ get canMovingScramble(){return !!fetchMotions;},
201
+ get canSinglePassScramble(){return fetchMotions?.scramble.trajectory==='single-pass-target';},
202
+ get canPickup(){return !!fetchMotions;},
203
+ get canCarryTrot(){return !!fetchMotions?.carryTrot;},
204
+ pickup(){if(!fetchMotions||state!=='idle'||seatProgress>0||!['rear','front'].includes(view))return false;route=false;target=null;waypoints=[];pendingAction=null;stop();comfort={kind:'pickup',seconds:fetchMotions.pickups[view].duration};enter('pickup');return true;},
205
+ scramble(){if(!scrambleSeconds||state!=='idle'||view!=='front'||seatProgress>0)return false;route=false;target=null;waypoints=[];pendingAction=null;stop();comfort={kind:'scramble',seconds:scrambleSeconds};enter('scramble');return true;},
172
206
  rest(){return dispatch(()=>{route=false;target=null;waypoints=[];pendingAction=null;stayDown=false;comfort=null;stop();if(restProgress>0){enter('wake');}else if(state==='watch'||state==='arrival-exit'){/* finish shared return */}else if(seatProgress>0||state==='rise'||state==='sit-down')enter('sit-down');else if(!['idle','view-turn','turn'].includes(state))enter('finish');});},
173
207
  update(dt){
174
208
  if(!Number.isFinite(dt)||dt<0||dt>.1)throw Error('Invalid paper step');
175
209
  const beforeX=animal.position.x,beforeZ=animal.position.z;
210
+ let enteredScramble=false;
176
211
  if(state==='run'&&travel&&target){
177
212
  travelAge+=dt;const p=travel.plan.at(travelAge),ratio=travel.plan.distance?p.distance/travel.plan.distance:1;
178
213
  travelDistance=p.distance;motionSpeed=p.speed;motionStage=p.stage;
179
214
  const gain=travel.plan.distance?p.speed/travel.plan.distance:0;
180
215
  animal.follow({x:travel.x+travel.dx*ratio,y:0,z:travel.z+travel.dz*ratio},{x:travel.dx*gain,y:0,z:travel.dz*gain},{heading:travel.heading});
181
216
  arrived=p.done;
217
+ if(scrambleArmed&&travel.scrambleAt!==undefined&&travelAge>=travel.scrambleAt){
218
+ const entry=travel.plan.at(travel.scrambleAt),ratio=entry.distance/travel.plan.distance,n=travel.plan.distance;
219
+ const x=travel.x+travel.dx*ratio,z=travel.z+travel.dz*ratio,ux=travel.dx/n,uz=travel.dz/n;
220
+ const end=paperGroundTarget(x+ux*entry.speed*fetchMotions.scramble.brakeSeconds/2,z+uz*entry.speed*fetchMotions.scramble.brakeSeconds/2);
221
+ const distance=Math.hypot(end[0]-x,end[1]-z),brake=entry.speed?2*distance/entry.speed:0;
222
+ const fullEnd=paperGroundTarget(end[0]+ux*(recovery?.distance??0),end[1]+uz*(recovery?.distance??0));
223
+ movingScramble={x,z,ux,uz,speed:entry.speed,brake,distance,travelBase:entry.distance,hop:Math.hypot(fullEnd[0]-end[0],fullEnd[1]-end[1]),profile:travel.stumble??null};
224
+ scrambleArmed=false;target=null;waypoints=[];comfort={kind:'scramble',seconds:scrambleSeconds};enter('scramble');age=travelAge-travel.scrambleAt;enteredScramble=true;
225
+ }
226
+ }
227
+ if(state==='scramble'&&movingScramble){
228
+ const m=movingScramble,time=age+(enteredScramble?0:dt),t=Math.min(m.brake,time),p=recovery?Math.max(0,Math.min(1,(time-recovery.start)/(recovery.end-recovery.start))):0;
229
+ const hop=m.hop*(1-Math.cos(Math.PI*p))/2,hopSpeed=recovery&&p>0&&p<1?m.hop*Math.PI/(2*(recovery.end-recovery.start))*Math.sin(Math.PI*p):0;
230
+ const kinematic=m.profile?.impactAt(time),speed=kinematic?.speed??((m.brake?m.speed*(1-t/m.brake):0)+hopSpeed),d=kinematic?.distance??((m.brake?m.speed*t-.5*m.speed/m.brake*t*t:0)+hop);
231
+ animal.follow({x:m.x+m.ux*d,y:0,z:m.z+m.uz*d},{x:m.ux*speed,y:0,z:m.uz*speed},{heading:travel.heading});travelDistance=m.travelBase+d;motionSpeed=speed;motionStage=kinematic?.stage??(hopSpeed>1e-8?'recovery':speed?'braking':'rest');
182
232
  }
183
233
  animal.update(dt);
184
234
  const prepareDt=dt*preparationRate;
185
- age+=['view-turn','turn','rise','watch','arrival-exit','launch','wake'].includes(state)?prepareDt:dt;
235
+ // Urgent fetches accelerate only authored preparation/recovery, never root
236
+ // travel or its distance-driven stride. Deferred tricks reach their shared
237
+ // endpoint promptly instead of ignoring a thrown ball for several seconds.
238
+ if(!enteredScramble)age+=['view-turn','turn','rise','watch','arrival-exit','launch','wake','finish','settle'].includes(state)||(state==='action'&&deferred)?prepareDt:dt;
186
239
  const distance=Math.hypot(animal.position.x-beforeX,animal.position.z-beforeZ);
187
240
  director.update(dt,state==='run',distance);
188
241
  if(state==='run'){
189
- phase+=distance/travel.stride;
242
+ phase+=distance/(travel.carry&&travelDistance<travel.carry.runDistance?PAPER_GAITS.approach.stride:travel.stride);
190
243
  if(target&&arrived){completed=true;stop();target=null;if(movementMode==='walk')playAction('walk-exit',()=>{movementMode='run';enter(walkTransitions?'idle':'settle');drain();});else enter('finish');}
191
244
  }else if(state==='action'&&age>=activeAction.duration){
192
245
  const done=actionDone;activeAction=null;actionDone=null;done();
@@ -200,13 +253,14 @@ export function createPaperActor(animal,info,{manualTricks=false,trickTimings=nu
200
253
  }else if(pendingAction)prepareDirection();else settle();
201
254
  }else if(state==='view-turn'&&age>=viewTurnSeconds()){view=turnView;prepareDirection();}
202
255
  else if(state==='turn'&&age>=turnSeconds()){flip=turnFlip;prepareDirection();}
203
- else if(state==='sit-down'){seatProgress=Math.min(1,seatProgress+dt/(snappyPreparation()?.35:.95));if(seatProgress===1){seatVariant='default';enter('idle');if(pendingAction)prepareDirection();}}
256
+ else if(state==='sit-down'){seatProgress=Math.min(1,seatProgress+prepareDt/(snappyPreparation()?.35:.95));if(seatProgress===1){seatVariant='default';enter('idle');if(pendingAction)prepareDirection();}}
204
257
  else if(state==='rise'){seatProgress=Math.max(0,seatProgress-prepareDt/(snappyPreparation()?.28:.65));if(seatProgress===0){if(seatVariant==='arrival')enter('arrival-exit');else prepareDirection();}}
205
258
  else if(state==='arrival-exit'&&age>=.22){seatVariant='default';prepareDirection();}
206
259
  else if(state==='watch'&&age>=PAPER_WATCH_TIMING.total){if(pendingAction)prepareDirection();else if(target)enter('rise');else enter('idle');}
207
260
  else if(state==='recline'){const end=stayDown?.25:1;restProgress=Math.min(end,restProgress+dt/1.8);if(restProgress===end)enter(stayDown?'down':comfort?.kind==='sleep'?'sleep':'nap');}
208
261
  else if(state==='sleep'&&age>=comfort.seconds){comfort=null;enter('wake');}
209
262
  else if(state==='tug'&&tugProgress()>=1){comfort=null;enter('idle');drain();}
263
+ else if((state==='scramble'||state==='pickup')&&age>=comfort.seconds){comfort=null;movingScramble=null;stop();enter('idle');drain();}
210
264
  else if(state==='nap'&&age>=5&&!stayDown){enter('wake');}
211
265
  else if(state==='wake'){restProgress=Math.max(0,restProgress-prepareDt/1.2);if(restProgress===0){if(pendingAction)prepareDirection();else if(target)enter('rise');else enter('idle');}}
212
266
  else if(state==='idle'&&route&&age>=idleHold)nextRoute();
@@ -217,7 +271,7 @@ export function createPaperActor(animal,info,{manualTricks=false,trickTimings=nu
217
271
  let pair=20,blend=0,kind=state,rearPair=null,approachPair=null;
218
272
  let seat=state==='sit-down'||state==='rise'||seatProgress>0?Math.round(seatProgress*48)/48:null;
219
273
  if(restProgress===0&&state!=='recline')sleepLook=null;
220
- let behavior=null,behaviorProgress=0,comfortPose=state==='tug'?{clip:'tug',progress:tugProgress()}:sleepLook&&restProgress>.72?{clip:'sleep',...sleepLook,blend:comfortEase((restProgress-.72)/.28)}:null;
274
+ let behavior=null,behaviorProgress=0,comfortPose=state==='scramble'?{clip:'scramble',view,progress:Math.min(1,age/comfort.seconds)}:state==='pickup'?{clip:'pickup',view,progress:Math.min(1,age/comfort.seconds)}:state==='tug'?{clip:'tug',progress:tugProgress()}:sleepLook&&restProgress>.72?{clip:'sleep',...sleepLook,blend:comfortEase((restProgress-.72)/.28)}:null;
221
275
  if(activeAction?.comfort)kind=activeAction.comfort;
222
276
  let actionName=activeAction?.name??(state==='run'&&movementMode==='walk'?'walk':null),actionProgress=activeAction?(activeAction.timing?activeAction.timing.progressAt(age):Math.min(1,age/activeAction.duration)):phase;
223
277
  if(manualDown&&state==='down'){actionName='lie-down';actionProgress=1;}
@@ -228,6 +282,10 @@ export function createPaperActor(animal,info,{manualTricks=false,trickTimings=nu
228
282
  // The old turn's intro/outro added an unrelated squat to every reversal.
229
283
  if(walkTransitions&&state==='turn'){actionName='walk-turn';actionProgress=Math.min(1,age/turnSeconds());}
230
284
  if(view==='approach'&&approachPlan&&(state==='run'||(state==='finish'&&completed))){const p=approachPlan.at(travelDistance);if(state==='finish'){behavior='brake';behaviorProgress=1;}else if(p.clip!=='run'){behavior=p.clip;behaviorProgress=p.progress;}}
285
+ if(view==='approach'&&travel?.carry&&(state==='run'||state==='finish')){
286
+ const p=state==='finish'?{clip:'carry-settle',progress:1}:travel.carry.poseAt(travelDistance);
287
+ if(p.clip!=='run'){comfortPose=p;behavior=null;actionName=null;}
288
+ }
231
289
  if(state==='arrival-exit'){behavior='finish';behaviorProgress=Math.min(1,age/.22);}
232
290
  if(state==='watch'){behavior=`watch-${watchSide}`;behaviorProgress=paperWatchProgress(age);}
233
291
  if(restProgress>0||state==='recline'||state==='wake'||state==='nap'){behavior='rest';behaviorProgress=restProgress;}
@@ -250,7 +308,8 @@ export function createPaperActor(animal,info,{manualTricks=false,trickTimings=nu
250
308
  blend=Math.round(blend*16)/16;
251
309
  const key=`${state}/${actionName}/${view}/${turnView}/${poseFps}/${Math.floor(clock*poseFps)}`;
252
310
  if(held?.key===key){pair=held.pair;blend=held.blend;rearPair=held.rearPair;approachPair=held.approachPair;seat=held.seat;behavior=held.behavior;behaviorProgress=held.behaviorProgress;actionName=held.actionName;actionProgress=held.actionProgress;comfortPose=held.comfortPose;}else held={key,pair,blend,rearPair,approachPair,seat,behavior,behaviorProgress,actionName,actionProgress,comfortPose,posePhase:phase};
253
- return{state:state==='run'||state==='finish'?'run':kind,pair,blend,rearPair,approachPair,seatProgress:seat,seatVariant,behavior,behaviorProgress,actionName,actionProgress,queued:!!deferred,view,straight,flip:state==='turn'&&!walkTransitions?false:flip,phase,posePhase:held.posePhase,stride:travel?.stride??PAPER_GAITS.front.stride,heading:travel?.heading??0,speed:motionSpeed,motionStage,travelDistance,energy:director.report.energy,x:animal.position.x,z:animal.position.z,...(comfortPose?{comfortPose:{...comfortPose}}:{})};
311
+ const hopProgress=recovery&&movingScramble?Math.max(0,Math.min(1,(age-recovery.start)/(recovery.end-recovery.start))):0;
312
+ return{state:state==='run'||state==='finish'?'run':kind,pair,blend,rearPair,approachPair,seatProgress:seat,seatVariant,behavior,behaviorProgress,actionName,actionProgress,queued:!!deferred,view,straight,flip:state==='turn'&&!walkTransitions?false:flip,phase,posePhase:held.posePhase,stride:travel?.carry&&travelDistance<travel.carry.runDistance?PAPER_GAITS.approach.stride:travel?.stride??PAPER_GAITS.front.stride,heading:travel?.heading??0,speed:motionSpeed,motionStage,travelDistance,energy:director.report.energy,x:animal.position.x,z:animal.position.z,...(movingScramble?{bodyLift:recovery&&hopProgress>0&&hopProgress<1?recovery.height*Math.sin(Math.PI*hopProgress):0}:{}),...(comfortPose?{comfortPose:{...comfortPose}}:{})};
254
313
  },get state(){return state;}
255
314
  };
256
315
  }
@@ -293,18 +352,23 @@ export function createPaperRenderer(cache,info,meta,width,height,{rear,approach,
293
352
  const image=isComfort?comfortSample({...state.comfortPose,...state.comfortPose.clip==='sleep'&&state.comfortPose.blend<1&&behaviorSample?{base:behaviorSample({behavior:'rest',behaviorProgress:state.behaviorProgress})}:{}}):isInteraction?interactionSample(state.interactionPose):isAction?actionSample(state):isBehavior?behaviorSample(state):isSeat?(state.seatVariant==='arrival'&&arrivalSeatSample?arrivalSeatSample:seatSample)(state.seatProgress):isApproach?approachSample(state.approachPair,state.blend):isRear?rearSample(state.rearPair,state.blend):tween(state),side=height*size,sideX=side/pixelAspect;
294
353
  // Quantize source lookup only, never the projected rectangle. Rounding side,
295
354
  // left and top first introduced whole-body scale/position jumps at thresholds.
296
- const left=width*x-sideX/2,floor=isInteraction||isAction||isBehavior||isSeat||isRear||isApproach?0:cleanGroundOffset(state,renderMeta),top=height*y-(300-floor)/320*side;
355
+ const motion=isComfort?comfortMotion(state.comfortPose,comfort.info):null;
356
+ const left=width*x-sideX/2,floor=motion?(motion.floorOffsets?.[comfortMotionKey(state.comfortPose.progress,motion)]??motion.floorOffset??0):isInteraction||isAction||isBehavior||isSeat||isRear||isApproach?0:cleanGroundOffset(state,renderMeta),top=height*y-(300-floor)/320*side;
297
357
  let feet=[];
298
- if(contactEnabled&&isAction&&state.actionName==='walk'){
358
+ if(contactEnabled&&isComfort&&state.comfortPose.clip==='carry-trot'){
359
+ const k=comfortMotionKey(state.comfortPose.progress,motion);
360
+ feet=motion.feet[k].map(f=>({...f,episode:`carry/${state.comfortPose.cycle}/${f.id}`}));
361
+ if(state.flip)feet=feet.map(p=>({...p,x:319-p.x}));
362
+ }else if(contactEnabled&&isAction&&state.actionName==='walk'){
299
363
  const p=paperActionPose('walk',state.actionProgress,actions.info);feet=walkEpisodes(actions.info.feet[p.pair][Math.round(p.blend*8)],p.pair,p.blend,state.actionProgress);
300
364
  if(state.flip)feet=feet.map(p=>({...p,x:319-p.x}));
301
- }else if(contactEnabled&&rear&&state.state==='run'&&!isAction){
365
+ }else if(contactEnabled&&rear&&state.state==='run'&&!isAction&&!isComfort){
302
366
  const b=isBehavior?paperBehaviorPose(state,behaviors.info):null;
303
367
  feet=isBehavior?behaviors.info.feet[b.pair][Math.round(b.blend*8)]:isApproach?approach.info.feet[state.approachPair][Math.round(state.blend*8)]:isRear?rear.info.rearFeet[state.rearPair][Math.round(state.blend*8)]:rear.info.frontFeet[state.pair]?.[Math.round(state.blend*16)]??[];
304
368
  feet=isBehavior?behaviorEpisodes[state.behavior](feet,b.pair,b.blend,state.behaviorProgress):isApproach?sourceEpisodes.approach(feet,state.approachPair,state.blend,state.posePhase??state.phase??0):isRear?sourceEpisodes.rear(feet,state.rearPair,state.blend,state.posePhase??state.phase??0):sourceEpisodes.front(feet,state.pair,state.blend,state.posePhase??state.phase??0);
305
369
  if(state.flip)feet=feet.map(p=>({...p,x:319-p.x}));
306
370
  }
307
- const pins=lock.update(feet,{left,top,side,sideX,groundFromPixel,pixelFromGround,root:{x:state.x,z:state.z,heading:state.heading??0}},`${state.view??'front'}/${state.flip}/${state.state==='run'}/${state.actionName??state.behavior??'gallop'}`);render.lastPins=pins;
371
+ const pins=lock.update(feet,{left,top,side,sideX,groundFromPixel,pixelFromGround,root:{x:state.x,z:state.z,heading:state.heading??0}},`${state.view??'front'}/${state.flip}/${state.state==='run'}/${state.comfortPose?.clip??state.actionName??state.behavior??'gallop'}`);render.lastPins=pins;
308
372
  const transport=pawTransport?createPawTransport(feet,pins,{opaque:(x,y)=>{
309
373
  const rawX=x+originX,rawY=y+originY;if(rawX<0||rawX>=sourceN||rawY<0||rawY>=sourceN)return false;
310
374
  let sx=Math.floor(rawX);if(state.flip)sx=sourceN-1-sx;
@@ -319,6 +383,10 @@ export function createPaperRenderer(cache,info,meta,width,height,{rear,approach,
319
383
  const mouthInfo={behaviors:behaviors?.info,seat:seat?.info,arrivalSeat:arrivalSeat?.info,approach:approach?.info}[mouthFamily];
320
384
  const mouthFrame=mouthFamily?mouthSourceFrame(mouthFamily,state,mouthInfo):null;
321
385
  render.lastMouth=projectMouth(mouthFamily?mouthAnchor(mouthFamily,mouthFrame,mouthInfo?.sha256):null,{left,top,side,sideX},state.flip,{family:mouthFamily,frame:mouthFrame});
386
+ if(motion?.mouth){
387
+ const k=comfortMotionKey(state.comfortPose.progress,motion),p=motion.mouth[k];
388
+ render.lastMouth=projectMouth({x:p[0],y:p[1],scale:1,score:null},{left,top,side,sideX},state.flip,{family:'comfort',frame:motion.frames[k]});
389
+ }
322
390
  const drawTop=top-originY/320*side,bottom=top+Math.max(sourceN-originY,...pins.map(p=>p.target.y+20))/320*side;
323
391
  const drawLeft=left+Math.min(-originX,...pins.map(p=>p.target.x-20))/320*sideX,drawRight=left+Math.max(sourceN-originX,...pins.map(p=>p.target.x+20))/320*sideX;
324
392
  for(let dy=Math.max(0,minify?Math.floor(drawTop):Math.ceil(drawTop-.5));dy<Math.min(height,minify?Math.ceil(bottom):Math.ceil(bottom-.5));dy++)for(let dx=Math.max(0,minify?Math.floor(drawLeft):Math.ceil(drawLeft-.5));dx<Math.min(width,minify?Math.ceil(drawRight):Math.ceil(drawRight-.5));dx++){
@@ -13,7 +13,7 @@ Usage:
13
13
  retrieviewer doctor [--agent ID] [--json]
14
14
  retrieviewer agents [--json]
15
15
  retrieviewer preview [--original]
16
- retrieviewer desktop [--ink cream|gold|dark]
16
+ retrieviewer desktop [--ink cream|gold|dark] [--return-style auto|brake|trot|run-trot]
17
17
  retrieviewer desktop-status [--json]
18
18
  retrieviewer desktop-stop
19
19
  retrieviewer desktop-clean --yes
@@ -27,12 +27,13 @@ Desktop is a separate native overlay, NOT a Codex plugin. Choose a window once;
27
27
  use Codex normally. Exit from its tray icon or desktop-stop. No auto-start is added.
28
28
  Windows is verified; macOS and Linux X11 adapters are experimental. Native Wayland
29
29
  app tracking is not supported. Linux targets AlmaLinux8/GLIBC2.28, system Python3.6+.
30
+ Return style defaults to auto (mix all three). Restart the companion to change it.
30
31
  `;
31
32
  export function parseArguments(args){
32
33
  const [command='help',...rest]=args,options={};
33
34
  for(let i=0;i<rest.length;i++){
34
35
  const token=rest[i];
35
- if(token==='--agent'||token==='--ink'){
36
+ if(token==='--agent'||token==='--ink'||token==='--return-style'){
36
37
  const key=token.slice(2);
37
38
  if(options[key]||!rest[i+1]||rest[i+1].startsWith('-'))throw Error(token+' requires one value');
38
39
  options[key]=rest[++i];
@@ -41,8 +42,9 @@ export function parseArguments(args){
41
42
  }
42
43
  if(options.agent&&!AGENTS.some(a=>a.id===options.agent))throw Error('Unknown agent: '+options.agent);
43
44
  if(options.ink&&!['cream','gold','dark'].includes(options.ink))throw Error('--ink must be cream, gold or dark');
45
+ if(options['return-style']&&!['auto','brake','trot','run-trot'].includes(options['return-style']))throw Error('--return-style must be auto, brake, trot or run-trot');
44
46
  if(!['help','--help','-h','enable','disable','disconnect','status','doctor','agents','preview','desktop','desktop-status','desktop-stop','desktop-clean','desktop-remove'].includes(command))throw Error('Unknown command: '+command);
45
- const allowed={desktop:['ink'],enable:['agent','yes','json','dry-run'],disconnect:['agent','yes','json','dry-run'],disable:['agent','json'],status:['agent','json'],doctor:['agent','json'],agents:['json'],preview:['original'],'desktop-status':['json'],'desktop-clean':['yes'],'desktop-remove':['yes']};
47
+ const allowed={desktop:['ink','return-style'],enable:['agent','yes','json','dry-run'],disconnect:['agent','yes','json','dry-run'],disable:['agent','json'],status:['agent','json'],doctor:['agent','json'],agents:['json'],preview:['original'],'desktop-status':['json'],'desktop-clean':['yes'],'desktop-remove':['yes']};
46
48
  for(const key of Object.keys(options))if(!(allowed[command]??[]).includes(key))throw Error(`--${key} is not supported by ${command}`);
47
49
  return {command,options};
48
50
  }
@@ -53,7 +55,7 @@ export async function runCli(args,{input=process.stdin,output=process.stdout,err
53
55
  if(['desktop-clean','desktop-remove'].includes(command)&&!options.yes)throw Error('Use '+command+' --yes to remove only the companion cache.');
54
56
  const api=desktop??await import('./integrations/windows-overlay/desktop.mjs');
55
57
  const action={'desktop':'startDesktop','desktop-status':'desktopStatus','desktop-stop':'stopDesktop','desktop-clean':'cleanDesktop','desktop-remove':'removeDesktop'}[command];
56
- emit(await api[action](options.ink?{ink:options.ink}:{}));return 0;
58
+ emit(await api[action]({...options.ink?{ink:options.ink}:{},...options['return-style']?{returnStyle:options['return-style']}:{} }));return 0;
57
59
  }
58
60
  if(command==='preview'){await import('./examples/live.mjs');return 0;}
59
61
  if(['status','doctor','agents'].includes(command)){
@@ -1,9 +1,12 @@
1
1
  /** A headless pet: the host owns input, time and terminal rendering. */
2
+ export type FetchReturnStyle = 'auto' | 'brake' | 'trot' | 'run-trot';
2
3
  export interface PetOptions {
3
4
  /** Asset names are relative; return original file bytes (keep .gz files compressed). */
4
5
  loadAsset?: (name: string) => Promise<ArrayBuffer | ArrayBufferView>;
5
6
  seed?: number;
6
7
  energy?: number;
8
+ /** Default auto mixes all three existing return styles without adjacent repeats. */
9
+ returnStyle?: FetchReturnStyle;
7
10
  /** Keep true for the current paw correction; false selects the legacy comparison. */
8
11
  pawTransport?: boolean;
9
12
  /** Default q5 (32 levels). Original requires separately supplied original assets. */
@@ -32,6 +35,8 @@ export interface PetState {
32
35
  seatProgress: number | null;
33
36
  behavior: string | null;
34
37
  actionName: string | null;
38
+ /** Physical vertical lift during an optional authored recovery bound. */
39
+ bodyLift?: number;
35
40
  [key: string]: unknown;
36
41
  }
37
42
  export interface LifeReport {
@@ -47,7 +52,12 @@ export interface LifeReport {
47
52
  decisions: number;
48
53
  cooldowns: {ball: number; treat: number; tug: number};
49
54
  gesture: {clip: string; progress: number} | null;
50
- ball: {x: number; z: number; height: number; carried: boolean} | null;
55
+ ball: {x: number; z: number; height: number; carried: boolean; spin?: number; speed?: number; phase?: 'flight' | 'roll' | 'rest'; settled?: boolean} | null;
56
+ /** Local deterministic fetch variation; null outside a ball interaction. */
57
+ fetchPattern: 'clean' | 'rolling' | 'miss' | 'tumble' | null;
58
+ /** Ball-carry gait, selected independently of the fetch/roaming random stream. */
59
+ returnStyle: FetchReturnStyle;
60
+ returnMode: Exclude<FetchReturnStyle, 'auto'> | null;
51
61
  /** Rare autonomous visitor; absent during direct user interactions. */
52
62
  butterfly: {x: number; z: number; height: number; phase: number; opacity: number} | null;
53
63
  treat: {x: number; z: number; height: number} | null;
@@ -81,6 +91,8 @@ export interface Pet {
81
91
  frame(options: FrameOptions): PetFrame;
82
92
  /** False when busy or cooling down. */
83
93
  interact(kind: 'ball' | 'treat' | 'tug'): boolean;
94
+ /** Sets the next throw's return style. Does not interrupt a fetch in progress. */
95
+ setReturnStyle(style: FetchReturnStyle): FetchReturnStyle;
84
96
  readonly disposed: boolean;
85
97
  dispose(): void;
86
98
  }
package/retrieviewer.mjs CHANGED
@@ -2,7 +2,7 @@ import {createPaperActor,createPaperRenderer,createPaperComfortTimings} from './
2
2
  import {createPaperProjection,createPaperBody} from './puppy-paper-world.mjs';
3
3
  import {rgbaToBraille} from './braille.mjs';
4
4
  import {createSnappyTrickTimings} from './puppy-paper-trick-timing.mjs';
5
- import {createCompanionLife} from './puppy-companion-life.mjs';
5
+ import {createCompanionLife,FETCH_RETURN_STYLES} from './puppy-companion-life.mjs';
6
6
  import {paintCompanionObjects} from './puppy-companion-objects.mjs';
7
7
  import {readPackedFrames} from './puppy-frame-store.mjs';
8
8
 
@@ -65,7 +65,9 @@ async function loadPetAssets(loadAsset,artProfile){
65
65
  let codec;
66
66
  if(typeof process!=='undefined'&&process.versions?.node){
67
67
  const {deflateRawSync,inflateRawSync}=await import('node:zlib');
68
- codec={compress:bytes=>deflateRawSync(bytes,{level:1}),expand:(bytes,size)=>inflateRawSync(bytes,{maxOutputLength:size})};
68
+ // Level 3 trims retained crop payload without the multi-second startup cost
69
+ // of levels 6..9. Pixels, synchronous frame access and the LRU stay identical.
70
+ codec={compress:bytes=>deflateRawSync(bytes,{level:3}),expand:(bytes,size)=>inflateRawSync(bytes,{maxOutputLength:size})};
69
71
  }
70
72
  const load=async name=>bytesOf(name,await loadAsset(name));
71
73
  const json=async name=>JSON.parse(new TextDecoder().decode(await load(name)));
@@ -94,13 +96,14 @@ async function acquireAssets(loadAsset,artProfile){
94
96
  try{return{data:await entry.promise,release};}catch(error){release();throw error;}
95
97
  }
96
98
 
97
- export async function createPet({loadAsset=defaultLoadAsset,seed,energy,pawTransport=true,artProfile=DEFAULT_ART_PROFILE}={}){
99
+ export async function createPet({loadAsset=defaultLoadAsset,seed,energy,pawTransport=true,artProfile=DEFAULT_ART_PROFILE,returnStyle='auto'}={}){
98
100
  if(typeof loadAsset!=='function')throw TypeError('loadAsset must be a function');
99
101
  if(seed===undefined)seed=globalThis.crypto?.getRandomValues?globalThis.crypto.getRandomValues(new Uint32Array(1))[0]:731;
100
102
  if(!Number.isInteger(seed)||seed<0||seed>0xffffffff)throw RangeError('seed must be a uint32');
101
103
  if(energy!==undefined&&(!Number.isFinite(energy)||energy<0||energy>1))throw RangeError('energy must be within 0..1');
102
104
  if(typeof pawTransport!=='boolean')throw TypeError('pawTransport must be boolean');
103
105
  if(!ART_PROFILES.includes(artProfile))throw RangeError('artProfile must be original or q5');
106
+ if(!FETCH_RETURN_STYLES.includes(returnStyle))throw RangeError('returnStyle must be auto, brake, trot or run-trot');
104
107
  let lease=await acquireAssets(loadAsset,artProfile),data=lease.data;
105
108
  const release=lease.release;lease=null;
106
109
  let {cache,info,meta}=data;const assets={...data.assets};data=null;
@@ -108,8 +111,8 @@ export async function createPet({loadAsset=defaultLoadAsset,seed,energy,pawTrans
108
111
  try{
109
112
  if(!assets.actions.info.clips?.['lie-down']||!assets.actions.info.clips?.['lie-up'])throw Error('v9 action cache lacks lie-down/lie-up clips');
110
113
  animal=createPaperBody();
111
- actor=createPaperActor(animal,info,{manualTricks:true,trickTimings:createSnappyTrickTimings(assets.actions.info),comfortTimings:createPaperComfortTimings(assets.actions.info),walkTransitions:true});
112
- life=createCompanionLife(actor,{seed,...energy===undefined?{}:{energy}});
114
+ actor=createPaperActor(animal,info,{manualTricks:true,trickTimings:createSnappyTrickTimings(assets.actions.info),comfortTimings:createPaperComfortTimings(assets.actions.info),walkTransitions:true,scrambleSeconds:assets.comfort.info.scramble?.duration??0,fetchMotions:assets.comfort.info.version>=4?assets.comfort.info:null});
115
+ life=createCompanionLife(actor,{seed,returnStyle,...energy===undefined?{}:{energy}});
113
116
  }catch(e){animal?.dispose?.();release();throw e;}
114
117
  let paint=null,paintKey='',time=0,acc=0,disposed=false;
115
118
  const alive=()=>{if(disposed)throw Error('Pet is disposed');};
@@ -135,13 +138,15 @@ export async function createPet({loadAsset=defaultLoadAsset,seed,energy,pawTrans
135
138
  const projection=createPaperProjection({viewportWidth,viewportHeight,rect:camera?.rect??{left:0,top:0,width:viewportWidth,height:viewportHeight},width,height});
136
139
  const state=actor.sample(time,{poseFps}),report=life.report;
137
140
  if(report.gesture)state.interactionPose=report.gesture;
138
- const placement=projection.placement(state.x,state.z),pixels=paint(state,{...placement,contactEnabled:!!contactEnabled});
141
+ const placement=projection.placement(state.x,state.z,state.bodyLift??0),pixels=paint(state,{...placement,contactEnabled:!!contactEnabled});
139
142
  paintCompanionObjects(pixels,width,height,projection,report,state,paint.lastMouth);
140
143
  const {text}=rgbaToBraille(pixels,width,height,{method:'stable',brightness:1.18,contrast:1.1});
141
144
  return {text,columns,rows,pixels,state,report,placement,projection,artProfile,mouth:paint.lastMouth,contacts:paint.report()};
142
145
  },
143
146
  // 'ball' | 'treat' | 'tug'. False when busy or cooling down; unknown kinds throw.
144
147
  interact(kind){alive();if(!INTERACTIONS.includes(kind))throw TypeError(`Unknown interaction: ${kind}`);return life.interact(kind);},
148
+ // Applies to the next ball throw; never rewrites a fetch already in motion.
149
+ setReturnStyle(style){alive();return life.setReturnStyle(style);},
145
150
  get disposed(){return disposed;},
146
151
  dispose(){
147
152
  if(disposed)return;disposed=true;