@bitefoundry/retrieviewer 0.0.9 → 0.0.11

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.
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,planPaperTravel,paperWalkTarget,planPaperApproach,planPaperApproachTravel,PAPER_DYNAMICS,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;}
@@ -87,10 +97,28 @@ export function createPaperActor(animal,info,{manualTricks=false,trickTimings=nu
87
97
  const playAction=(name,done,duration=PAPER_ACTION_DURATIONS[name])=>{const timing=trickTimings?.[name];duration=timing?.duration??duration;if(!Number.isFinite(duration)||duration<=0)throw Error('Invalid paper action duration');activeAction={name,duration,timing};actionDone=done;stop();enter('action');};
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)=>{
100
+ // The walk-enter drawing finishes at a shared standing endpoint. A direct
101
+ // interaction queued during it must not start the obsolete walk afterwards.
102
+ if(skipEntry&&deferred){drain();return;}
90
103
  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;
104
+ 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
105
  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
106
  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');
107
+ if(['trot','run-trot'].includes(carryMode)&&fetchMotions?.carryTrot&&view==='approach'&&distance>0){
108
+ const carry=planPaperCarry(distance,{mixed:carryMode==='run-trot',settleSeconds:fetchMotions.carryTrot.settle.duration});
109
+ Object.assign(travel,{plan:carry,carry,stride:carry.stride});approachPlan=null;
110
+ }
111
+ if(scrambleArmed&&fetchMotions?.scramble.trajectory==='single-pass-target'&&view===fetchMotions.scramble.entryView&&!waypoints.length){
112
+ const stumble=planPaperStumble(distance,fetchMotions.scramble);
113
+ Object.assign(travel,{plan:stumble.plan,stride:stumble.stride,stumble,scrambleAt:stumble.entryTime});
114
+ }else if(scrambleArmed&&fetchMotions&&view==='front'&&!waypoints.length&&fetchMotions.scramble.trajectory!=='single-pass-target'){
115
+ // Last matching forepaw contact before the endpoint. Invert the existing
116
+ // distance profile once, not on every frame. Source frame pair5/blend0 is
117
+ // shared EXACTLY with the new motion's first key.
118
+ const contact=fetchMotions.scramble.entryPhase,cycle=Math.max(0,Math.floor(distance/travel.stride-contact-.15)),d=(cycle+contact)*travel.stride;
119
+ 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;}
120
+ travel.scrambleAt=(lo+hi)/2;
121
+ }
94
122
  };
95
123
  const prepareAction=()=>{
96
124
  const name=pendingAction,desired=name==='spin'?'front':'approach';
@@ -116,8 +144,8 @@ export function createPaperActor(animal,info,{manualTricks=false,trickTimings=nu
116
144
  playAction(name,()=>finishTrick(name));
117
145
  };
118
146
  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;
147
+ const requestAction=(name,{urgent=false}={})=>{
148
+ preparationRate=urgent?3:1;
121
149
  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
150
  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
151
  if(name==='belly'&&state==='down'&&(manualDown||restProgress===.25)){route=false;target=null;waypoints=[];pendingAction=null;startBelly(comfortSeconds);return true;}
@@ -132,9 +160,11 @@ export function createPaperActor(animal,info,{manualTricks=false,trickTimings=nu
132
160
  return true;
133
161
  };
134
162
  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}={}){
163
+ function go(x,z,{direct=false,walking=false,urgent=false,scramble=false,returnMode=null}={}){
136
164
  if(![x,z].every(Number.isFinite))throw Error('Invalid paper target');
137
165
  preparationRate=urgent?3:1;
166
+ carryMode=returnMode;
167
+ scrambleArmed=!!(scramble&&fetchMotions);movingScramble=null;
138
168
  target=paperGroundTarget(x,z);
139
169
  pendingAction=null;stayDown=false;comfort=null;movementMode=walking?'walk':'run';
140
170
  const dz=target[1]-animal.position.z,dx=target[0]-animal.position.x;
@@ -155,13 +185,29 @@ export function createPaperActor(animal,info,{manualTricks=false,trickTimings=nu
155
185
  }
156
186
  }
157
187
  return{
188
+ // Attention interrupts a journey, not its momentum. Continue the current
189
+ // heading/gait while braking from the actual velocity, then recover to sit.
190
+ // Rolls/tugs still finish their connected authored recovery via dispatch.
191
+ attend(){
192
+ if(state!=='run'||!travel||!target||motionSpeed<1e-8)return dispatch(()=>requestAction('sit',{urgent:true}),{urgent:true});
193
+ const n=Math.hypot(travel.dx,travel.dz),remaining=Math.max(0,travel.plan.distance-(travelDistance-(travel.distanceBase??0)));
194
+ const a=movementMode==='walk'?PAPER_DYNAMICS.walkDeceleration:PAPER_DYNAMICS.deceleration;
195
+ const distance=Math.min(remaining,motionSpeed**2/(2*a));
196
+ if(!n||distance<1e-10)return dispatch(()=>requestAction('sit',{urgent:true}),{urgent:true});
197
+ const x=animal.position.x,z=animal.position.z,dx=travel.dx/n*distance,dz=travel.dz/n*distance;
198
+ const plan=planPaperTravel(distance,{speed:motionSpeed,startSpeed:motionSpeed,deceleration:motionSpeed**2/(2*distance)});
199
+ travel={...travel,x,z,dx,dz,plan,distanceBase:travelDistance,attending:true};
200
+ travelAge=0;target=[x+dx,z+dz];waypoints=[];route=false;deferred=null;
201
+ pendingAction='sit';preparationRate=3;scrambleArmed=false;completed=false;
202
+ return true;
203
+ },
158
204
  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});});},
205
+ // Eager attention changes preparation/recovery cadence only. Existing dispatch
206
+ // keeps actions/walks at safe endpoints; root physics and gait are unchanged.
207
+ 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
208
  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
209
  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});},
210
+ sit({urgent=false}={}){return dispatch(()=>requestAction('sit',{urgent}),{urgent});},spin(){return dispatch(()=>requestAction('spin'));},roll(){return dispatch(()=>requestAction('roll'),{keepDown:true});},
165
211
  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
212
  look(side='left'){if(state!=='idle'||seatProgress!==1)return false;route=false;watchSide=side==='right'?'right':'left';enter('watch');return true;},
167
213
  lie(){const name=manualDown||activeAction?.name==='lie-down'||restProgress>0?'sit':'lie';return dispatch(()=>requestAction(name));},
@@ -169,25 +215,51 @@ export function createPaperActor(animal,info,{manualTricks=false,trickTimings=nu
169
215
  belly(seconds=6){comfortDuration('belly',seconds);return dispatch(()=>{comfortSeconds=seconds;return requestAction('belly');},{keepDown:true});},
170
216
  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
217
  tug(seconds=6){comfortDuration('tug',seconds);return dispatch(()=>{comfortSeconds=seconds;return requestAction('tug');});},
218
+ get canScramble(){return scrambleSeconds>0;},
219
+ get canMovingScramble(){return !!fetchMotions;},
220
+ get canSinglePassScramble(){return fetchMotions?.scramble.trajectory==='single-pass-target';},
221
+ get canPickup(){return !!fetchMotions;},
222
+ get canCarryTrot(){return !!fetchMotions?.carryTrot;},
223
+ 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;},
224
+ 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
225
  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
226
  update(dt){
174
227
  if(!Number.isFinite(dt)||dt<0||dt>.1)throw Error('Invalid paper step');
175
228
  const beforeX=animal.position.x,beforeZ=animal.position.z;
229
+ let enteredScramble=false;
176
230
  if(state==='run'&&travel&&target){
177
231
  travelAge+=dt;const p=travel.plan.at(travelAge),ratio=travel.plan.distance?p.distance/travel.plan.distance:1;
178
- travelDistance=p.distance;motionSpeed=p.speed;motionStage=p.stage;
232
+ travelDistance=(travel.distanceBase??0)+p.distance;motionSpeed=p.speed;motionStage=p.stage;
179
233
  const gain=travel.plan.distance?p.speed/travel.plan.distance:0;
180
234
  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
235
  arrived=p.done;
236
+ if(scrambleArmed&&travel.scrambleAt!==undefined&&travelAge>=travel.scrambleAt){
237
+ const entry=travel.plan.at(travel.scrambleAt),ratio=entry.distance/travel.plan.distance,n=travel.plan.distance;
238
+ const x=travel.x+travel.dx*ratio,z=travel.z+travel.dz*ratio,ux=travel.dx/n,uz=travel.dz/n;
239
+ const end=paperGroundTarget(x+ux*entry.speed*fetchMotions.scramble.brakeSeconds/2,z+uz*entry.speed*fetchMotions.scramble.brakeSeconds/2);
240
+ const distance=Math.hypot(end[0]-x,end[1]-z),brake=entry.speed?2*distance/entry.speed:0;
241
+ const fullEnd=paperGroundTarget(end[0]+ux*(recovery?.distance??0),end[1]+uz*(recovery?.distance??0));
242
+ 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};
243
+ scrambleArmed=false;target=null;waypoints=[];comfort={kind:'scramble',seconds:scrambleSeconds};enter('scramble');age=travelAge-travel.scrambleAt;enteredScramble=true;
244
+ }
245
+ }
246
+ if(state==='scramble'&&movingScramble){
247
+ 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;
248
+ 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;
249
+ 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);
250
+ 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
251
  }
183
252
  animal.update(dt);
184
253
  const prepareDt=dt*preparationRate;
185
- age+=['view-turn','turn','rise','watch','arrival-exit','launch','wake'].includes(state)?prepareDt:dt;
254
+ // Urgent fetches accelerate only authored preparation/recovery, never root
255
+ // travel or its distance-driven stride. Deferred tricks reach their shared
256
+ // endpoint promptly instead of ignoring a thrown ball for several seconds.
257
+ if(!enteredScramble)age+=['view-turn','turn','rise','watch','arrival-exit','launch','wake','finish','settle'].includes(state)||(state==='action'&&deferred)?prepareDt:dt;
186
258
  const distance=Math.hypot(animal.position.x-beforeX,animal.position.z-beforeZ);
187
259
  director.update(dt,state==='run',distance);
188
260
  if(state==='run'){
189
- phase+=distance/travel.stride;
190
- if(target&&arrived){completed=true;stop();target=null;if(movementMode==='walk')playAction('walk-exit',()=>{movementMode='run';enter(walkTransitions?'idle':'settle');drain();});else enter('finish');}
261
+ phase+=distance/(travel.carry&&travelDistance<travel.carry.runDistance?PAPER_GAITS.approach.stride:travel.stride);
262
+ if(target&&arrived){completed=true;stop();target=null;if(movementMode==='walk')playAction('walk-exit',()=>{movementMode='run';enter(walkTransitions?'idle':'settle');if(pendingAction)prepareDirection();else drain();});else enter('finish');}
191
263
  }else if(state==='action'&&age>=activeAction.duration){
192
264
  const done=actionDone;activeAction=null;actionDone=null;done();
193
265
  }else if(state==='launch'&&age>=launchSeconds()){
@@ -200,13 +272,14 @@ export function createPaperActor(animal,info,{manualTricks=false,trickTimings=nu
200
272
  }else if(pendingAction)prepareDirection();else settle();
201
273
  }else if(state==='view-turn'&&age>=viewTurnSeconds()){view=turnView;prepareDirection();}
202
274
  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();}}
275
+ 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
276
  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
277
  else if(state==='arrival-exit'&&age>=.22){seatVariant='default';prepareDirection();}
206
278
  else if(state==='watch'&&age>=PAPER_WATCH_TIMING.total){if(pendingAction)prepareDirection();else if(target)enter('rise');else enter('idle');}
207
279
  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
280
  else if(state==='sleep'&&age>=comfort.seconds){comfort=null;enter('wake');}
209
281
  else if(state==='tug'&&tugProgress()>=1){comfort=null;enter('idle');drain();}
282
+ else if((state==='scramble'||state==='pickup')&&age>=comfort.seconds){comfort=null;movingScramble=null;stop();enter('idle');drain();}
210
283
  else if(state==='nap'&&age>=5&&!stayDown){enter('wake');}
211
284
  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
285
  else if(state==='idle'&&route&&age>=idleHold)nextRoute();
@@ -217,7 +290,7 @@ export function createPaperActor(animal,info,{manualTricks=false,trickTimings=nu
217
290
  let pair=20,blend=0,kind=state,rearPair=null,approachPair=null;
218
291
  let seat=state==='sit-down'||state==='rise'||seatProgress>0?Math.round(seatProgress*48)/48:null;
219
292
  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;
293
+ 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
294
  if(activeAction?.comfort)kind=activeAction.comfort;
222
295
  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
296
  if(manualDown&&state==='down'){actionName='lie-down';actionProgress=1;}
@@ -227,7 +300,11 @@ export function createPaperActor(animal,info,{manualTricks=false,trickTimings=nu
227
300
  // This clip is a calibrated standing half-turn, not walk-specific motion.
228
301
  // The old turn's intro/outro added an unrelated squat to every reversal.
229
302
  if(walkTransitions&&state==='turn'){actionName='walk-turn';actionProgress=Math.min(1,age/turnSeconds());}
230
- 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;}}
303
+ if(view==='approach'&&approachPlan&&(state==='run'||(state==='finish'&&completed))){const p=approachPlan.at(travelDistance);if(state==='finish'&&!travel?.attending){behavior='brake';behaviorProgress=1;}else if(p.clip!=='run'){behavior=p.clip;behaviorProgress=p.progress;}}
304
+ if(view==='approach'&&travel?.carry&&(state==='run'||state==='finish')){
305
+ const p=state==='finish'?{clip:'carry-settle',progress:1}:travel.carry.poseAt(travelDistance);
306
+ if(p.clip!=='run'){comfortPose=p;behavior=null;actionName=null;}
307
+ }
231
308
  if(state==='arrival-exit'){behavior='finish';behaviorProgress=Math.min(1,age/.22);}
232
309
  if(state==='watch'){behavior=`watch-${watchSide}`;behaviorProgress=paperWatchProgress(age);}
233
310
  if(restProgress>0||state==='recline'||state==='wake'||state==='nap'){behavior='rest';behaviorProgress=restProgress;}
@@ -250,7 +327,8 @@ export function createPaperActor(animal,info,{manualTricks=false,trickTimings=nu
250
327
  blend=Math.round(blend*16)/16;
251
328
  const key=`${state}/${actionName}/${view}/${turnView}/${poseFps}/${Math.floor(clock*poseFps)}`;
252
329
  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}}:{})};
330
+ const hopProgress=recovery&&movingScramble?Math.max(0,Math.min(1,(age-recovery.start)/(recovery.end-recovery.start))):0;
331
+ 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
332
  },get state(){return state;}
255
333
  };
256
334
  }
@@ -293,18 +371,23 @@ export function createPaperRenderer(cache,info,meta,width,height,{rear,approach,
293
371
  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
372
  // Quantize source lookup only, never the projected rectangle. Rounding side,
295
373
  // 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;
374
+ const motion=isComfort?comfortMotion(state.comfortPose,comfort.info):null;
375
+ 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
376
  let feet=[];
298
- if(contactEnabled&&isAction&&state.actionName==='walk'){
377
+ if(contactEnabled&&isComfort&&state.comfortPose.clip==='carry-trot'){
378
+ const k=comfortMotionKey(state.comfortPose.progress,motion);
379
+ feet=motion.feet[k].map(f=>({...f,episode:`carry/${state.comfortPose.cycle}/${f.id}`}));
380
+ if(state.flip)feet=feet.map(p=>({...p,x:319-p.x}));
381
+ }else if(contactEnabled&&isAction&&state.actionName==='walk'){
299
382
  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
383
  if(state.flip)feet=feet.map(p=>({...p,x:319-p.x}));
301
- }else if(contactEnabled&&rear&&state.state==='run'&&!isAction){
384
+ }else if(contactEnabled&&rear&&state.state==='run'&&!isAction&&!isComfort){
302
385
  const b=isBehavior?paperBehaviorPose(state,behaviors.info):null;
303
386
  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
387
  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
388
  if(state.flip)feet=feet.map(p=>({...p,x:319-p.x}));
306
389
  }
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;
390
+ 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
391
  const transport=pawTransport?createPawTransport(feet,pins,{opaque:(x,y)=>{
309
392
  const rawX=x+originX,rawY=y+originY;if(rawX<0||rawX>=sourceN||rawY<0||rawY>=sourceN)return false;
310
393
  let sx=Math.floor(rawX);if(state.flip)sx=sourceN-1-sx;
@@ -319,6 +402,10 @@ export function createPaperRenderer(cache,info,meta,width,height,{rear,approach,
319
402
  const mouthInfo={behaviors:behaviors?.info,seat:seat?.info,arrivalSeat:arrivalSeat?.info,approach:approach?.info}[mouthFamily];
320
403
  const mouthFrame=mouthFamily?mouthSourceFrame(mouthFamily,state,mouthInfo):null;
321
404
  render.lastMouth=projectMouth(mouthFamily?mouthAnchor(mouthFamily,mouthFrame,mouthInfo?.sha256):null,{left,top,side,sideX},state.flip,{family:mouthFamily,frame:mouthFrame});
405
+ if(motion?.mouth){
406
+ const k=comfortMotionKey(state.comfortPose.progress,motion),p=motion.mouth[k];
407
+ 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]});
408
+ }
322
409
  const drawTop=top-originY/320*side,bottom=top+Math.max(sourceN-originY,...pins.map(p=>p.target.y+20))/320*side;
323
410
  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
411
  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;
@@ -79,8 +89,12 @@ export interface Pet {
79
89
  /** Seconds. Fixed 60 Hz simulation, at most .1 s catch-up per call. */
80
90
  step(dt: number): number;
81
91
  frame(options: FrameOptions): PetFrame;
92
+ /** Conservative pre-render check for held poses. Simulation still needs step(). */
93
+ needsFrame(options: FrameOptions): boolean;
82
94
  /** False when busy or cooling down. */
83
95
  interact(kind: 'ball' | 'treat' | 'tug'): boolean;
96
+ /** Sets the next throw's return style. Does not interrupt a fetch in progress. */
97
+ setReturnStyle(style: FetchReturnStyle): FetchReturnStyle;
84
98
  readonly disposed: boolean;
85
99
  dispose(): void;
86
100
  }
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
 
@@ -59,13 +59,29 @@ async function gunzip(bytes){
59
59
  // Only immutable art/metadata is shared. Each pet still owns its animal, actor,
60
60
  // life, renderer and output. Last dispose drops the shared lease, not other pets.
61
61
  const sharedAssets=new Map();
62
+ // Conservative pre-render invalidation: only known held source poses qualify.
63
+ // Moving poses, gestures, sleep cues and visitors always take the normal path.
64
+ // One small key, no second pixel buffer or animation cache. Needs/cooldowns are
65
+ // intentionally absent: they still advance, but do not draw anything themselves.
66
+ function heldFrameKey(state,report,{columns,rows,cellAspect=.5,camera,poseFps=24,contactEnabled=true}={}){
67
+ const seated=state.state==='idle'&&state.seatProgress===1&&!state.behavior&&!state.actionName&&!state.comfortPose;
68
+ const down=state.state==='down'&&(state.actionName==='lie-down'&&state.actionProgress===1||state.behavior==='rest');
69
+ if((!seated&&!down)||report.gesture||report.butterfly||report.snore!==null&&report.snore!==undefined)return null;
70
+ return JSON.stringify([columns,rows,cellAspect,poseFps,!!contactEnabled,camera?.viewportWidth,camera?.viewportHeight,
71
+ camera?.rect?.left,camera?.rect?.top,camera?.rect?.width,camera?.rect?.height,
72
+ state.state,state.x,state.z,state.bodyLift??0,state.view,state.flip,state.seatProgress,state.seatVariant,
73
+ state.actionName,down?state.actionProgress:0,state.behavior,state.behaviorProgress,
74
+ report.ball,report.treat]);
75
+ }
62
76
  async function loadPetAssets(loadAsset,artProfile){
63
77
  // Node keeps filtered crops compressed; only the existing two-frame LRU is
64
78
  // expanded. Browser bundles retain the portable bit-packed implementation.
65
79
  let codec;
66
80
  if(typeof process!=='undefined'&&process.versions?.node){
67
81
  const {deflateRawSync,inflateRawSync}=await import('node:zlib');
68
- codec={compress:bytes=>deflateRawSync(bytes,{level:1}),expand:(bytes,size)=>inflateRawSync(bytes,{maxOutputLength:size})};
82
+ // Level 3 trims retained crop payload without the multi-second startup cost
83
+ // of levels 6..9. Pixels, synchronous frame access and the LRU stay identical.
84
+ codec={compress:bytes=>deflateRawSync(bytes,{level:3}),expand:(bytes,size)=>inflateRawSync(bytes,{maxOutputLength:size})};
69
85
  }
70
86
  const load=async name=>bytesOf(name,await loadAsset(name));
71
87
  const json=async name=>JSON.parse(new TextDecoder().decode(await load(name)));
@@ -94,13 +110,14 @@ async function acquireAssets(loadAsset,artProfile){
94
110
  try{return{data:await entry.promise,release};}catch(error){release();throw error;}
95
111
  }
96
112
 
97
- export async function createPet({loadAsset=defaultLoadAsset,seed,energy,pawTransport=true,artProfile=DEFAULT_ART_PROFILE}={}){
113
+ export async function createPet({loadAsset=defaultLoadAsset,seed,energy,pawTransport=true,artProfile=DEFAULT_ART_PROFILE,returnStyle='auto'}={}){
98
114
  if(typeof loadAsset!=='function')throw TypeError('loadAsset must be a function');
99
115
  if(seed===undefined)seed=globalThis.crypto?.getRandomValues?globalThis.crypto.getRandomValues(new Uint32Array(1))[0]:731;
100
116
  if(!Number.isInteger(seed)||seed<0||seed>0xffffffff)throw RangeError('seed must be a uint32');
101
117
  if(energy!==undefined&&(!Number.isFinite(energy)||energy<0||energy>1))throw RangeError('energy must be within 0..1');
102
118
  if(typeof pawTransport!=='boolean')throw TypeError('pawTransport must be boolean');
103
119
  if(!ART_PROFILES.includes(artProfile))throw RangeError('artProfile must be original or q5');
120
+ if(!FETCH_RETURN_STYLES.includes(returnStyle))throw RangeError('returnStyle must be auto, brake, trot or run-trot');
104
121
  let lease=await acquireAssets(loadAsset,artProfile),data=lease.data;
105
122
  const release=lease.release;lease=null;
106
123
  let {cache,info,meta}=data;const assets={...data.assets};data=null;
@@ -108,10 +125,10 @@ export async function createPet({loadAsset=defaultLoadAsset,seed,energy,pawTrans
108
125
  try{
109
126
  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
127
  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}});
128
+ 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});
129
+ life=createCompanionLife(actor,{seed,returnStyle,...energy===undefined?{}:{energy}});
113
130
  }catch(e){animal?.dispose?.();release();throw e;}
114
- let paint=null,paintKey='',time=0,acc=0,disposed=false;
131
+ let paint=null,paintKey='',time=0,acc=0,disposed=false,lastHeldFrameKey=null;
115
132
  const alive=()=>{if(disposed)throw Error('Pet is disposed');};
116
133
  return {
117
134
  // Fixed 1/60 s simulation; host deltas are clamped to 0.1 s so a stalled host
@@ -122,6 +139,9 @@ export async function createPet({loadAsset=defaultLoadAsset,seed,energy,pawTrans
122
139
  while(acc>=STEP_SECONDS-1e-9){actor.update(STEP_SECONDS);life.update(STEP_SECONDS);time+=STEP_SECONDS;acc=Math.max(0,acc-STEP_SECONDS);n++;}
123
140
  return n;
124
141
  },
142
+ // Optional host optimization. False only when a known held pose and all
143
+ // visible objects/settings match the last frame; frame() itself never skips.
144
+ needsFrame(options){alive();const key=heldFrameKey(actor.sample(time,{poseFps:options?.poseFps??24}),life.report,options);return key===null||key!==lastHeldFrameKey;},
125
145
  // Plain Braille text sized exactly columns x rows (2x4 dots per cell). `pixels`
126
146
  // is the renderer's buffer, valid only until the next frame()/dispose().
127
147
  frame({columns,rows,cellAspect=.5,camera,poseFps=24,contactEnabled=true}={}){
@@ -135,18 +155,21 @@ export async function createPet({loadAsset=defaultLoadAsset,seed,energy,pawTrans
135
155
  const projection=createPaperProjection({viewportWidth,viewportHeight,rect:camera?.rect??{left:0,top:0,width:viewportWidth,height:viewportHeight},width,height});
136
156
  const state=actor.sample(time,{poseFps}),report=life.report;
137
157
  if(report.gesture)state.interactionPose=report.gesture;
138
- const placement=projection.placement(state.x,state.z),pixels=paint(state,{...placement,contactEnabled:!!contactEnabled});
158
+ const placement=projection.placement(state.x,state.z,state.bodyLift??0),pixels=paint(state,{...placement,contactEnabled:!!contactEnabled});
139
159
  paintCompanionObjects(pixels,width,height,projection,report,state,paint.lastMouth);
140
160
  const {text}=rgbaToBraille(pixels,width,height,{method:'stable',brightness:1.18,contrast:1.1});
161
+ lastHeldFrameKey=heldFrameKey(state,report,{columns,rows,cellAspect,camera,poseFps,contactEnabled});
141
162
  return {text,columns,rows,pixels,state,report,placement,projection,artProfile,mouth:paint.lastMouth,contacts:paint.report()};
142
163
  },
143
164
  // 'ball' | 'treat' | 'tug'. False when busy or cooling down; unknown kinds throw.
144
165
  interact(kind){alive();if(!INTERACTIONS.includes(kind))throw TypeError(`Unknown interaction: ${kind}`);return life.interact(kind);},
166
+ // Applies to the next ball throw; never rewrites a fetch already in motion.
167
+ setReturnStyle(style){alive();return life.setReturnStyle(style);},
145
168
  get disposed(){return disposed;},
146
169
  dispose(){
147
170
  if(disposed)return;disposed=true;
148
171
  try{animal.dispose?.();}finally{
149
- animal=actor=life=paint=cache=info=meta=null;paintKey='';
172
+ animal=actor=life=paint=cache=info=meta=null;paintKey='';lastHeldFrameKey=null;
150
173
  for(const k of BUNDLES)assets[k]=null;
151
174
  release();
152
175
  }