// Recovered main-game rules. Source references and limitations are in README.md. export const COLORS=['Blue','Green','Orange','Purple','Red','Yellow']; export const frameScale = value => Math.fround(Math.fround(Math.fround(Math.fround(value)*2)/Math.fround(23.976))*Math.fround(29.976)); export class NightTrapEngine { constructor(data, seed=1987) {this.data=data;this.seed=seed;this.reset();} reset() { this.nodes=structuredClone(this.data.nodes);this.time=0;this.room='Hall 1';this.score=0;this.possible=0; this.color=1;this.securityColor=1;this.flags={};this.log=[];this.events=[];this.cursor=0; this.active=new Set();this.done=new Set();this.dead=new Set();this.videoStarts={};this.lastRoomFrames={}; this.randomState=this.seed;this.end=null;this.override=null;this.phase='game';this.forced=null;this.controlLocked=false;this.clockFrozenAt=null;this.checkpoint=null;this.lives=3;this.practice=false;this.inputs=[]; for(const r of this.data.rooms)this.activate(r.node,0); this.processSchedule(0);this.update(0); } note(text,kind='event') {this.log.push({time:this.time,text,kind});if(this.log.length>300)this.log.shift();} random(min,max) {this.randomState=(Math.imul(this.randomState,1664525)+1013904223)>>>0;return min+Math.floor(this.randomState/4294967296*(max-min));} activate(id,at=this.time) { const n=this.nodes[id];if(!n||this.active.has(n.id)||this.dead.has(n.id))return; n.active=true;this.active.add(n.id);n.started=at; if(n.video)this.videoStarts[n.id]=at; for(const c of n.components.filter(c=>c.enabled)) { if(c.type==='codeChange') { let color=this.random(c.startOverride,7);if(color===this.securityColor)color=color%6+1; c.chosen=color;n.video.clip=c[COLORS[color-1].toLowerCase()]; } if(c.type==='setGameFlag')for(const k of Object.keys(c))if((k.endsWith('Captured')||k==='goofyBathroomSequence')&&k!=='cindyCaptured'&&c[k])this.flags[k]=true; if(['videoSkip','defaultSimms'].includes(c.type)&&n.video)this.videoStarts[n.id]=at-c.startingFrame/this.clip(n).fps; if(c.type==='destroyAlternatePath')this.deactivate(c.destroyThisVideo,c.destroyVideo); if(c.type==='disableFix')this.deactivate(c.disableVideo); if(c.type==='stopNextVideo'){this.deactivate(c.videoToStop);this.disableComponent(c.scriptToStop,'captureSequence');} if(c.type==='stopJeff')this.disableComponent(c.sequenceContainer,'activateJeff'); if(c.type==='destroyAlternate'){ this.deactivate(c.alternateScene); if(c.trapSequence)this.disableComponent(c.componentToDisable,'captureSequence'); } if(c.type==='deactivateVideo')this.deactivate(c.videoToDeactivate); if(c.type==='gameOverCheck')this.events.push({time:at+1,kind:'scoreCheck',component:c,ratio:this.possible?this.score/this.possible:0}); } for(const child of n.children)if(this.nodes[child]?.active)this.activate(child,at); } forceSequence(component, original, failure) { const room=component.roomName||original?.room||this.room; this.forced={room,component:component.id,original:original?.id??null,failure}; this.controlLocked=true;this.clockFrozenAt=this.time;this.room=room; if(failure){this.activate(failure);this.override=failure;} this.note('SCAT override: camera control locked.','failure'); } disableComponent(id,type) {const c=this.nodes[id]?.components.find(c=>c.type===type);if(c)c.enabled=false;} deactivate(id,destroy=false) { const n=this.nodes[id];if(!n)return; for(const child of n.children)this.deactivate(child,destroy); if(this.active.has(n.id))for(const c of n.components)if(c.enabled&&c.type==='codeChange'&&c.chosen)this.securityColor=c.chosen; this.active.delete(n.id);n.active=false;if(destroy)this.dead.add(n.id); } clip(n) {return this.data.media[n?.video?.clip];} elapsed(n) {return Math.max(0,this.time-(this.videoStarts[n?.id]??n?.started??this.time));} frame(n) {const m=this.clip(n);return m?Math.min(m.frames-1,Math.floor(this.elapsed(n)*m.fps*(n.video.speed||1)+1e-6)):-1;} processSchedule(to) { while(this.cursor=828)this.checkpoint={time:828,room:this.room,color:this.color,inputs:structuredClone(this.inputs)}; } } once(c,key,fn) {const id=c.id+':'+key;if(this.done.has(id))return;this.done.add(id);fn();} update() { for(const id of [...this.active]) { const n=this.nodes[id];if(!this.active.has(id))continue; const f=this.frame(n);const m=this.clip(n); for(const c of n.components) { if(!c.enabled)continue; if(c.type==='possibleCaptured'&&f>=frameScale(c.whenPossible))this.once(c,'score',()=>{ if(c.totalToAddTo==='Captured')this.score+=c.total; if(c.totalToAddTo==='Possible')this.possible+=c.total; }); if(c.type==='setGameFlag'&&c.cindyCaptured&&f>=frameScale(c.cindyTime))this.flags.cindyCaptured=true; if(c.type==='codeChange'&&(c.chosen===1||f>=frameScale(c.whenToChangeColor)))this.once(c,'color',()=>{this.securityColor=c.chosen;this.note('Security code changed. Listen to the conversation.','code');}); if(c.type==='nextVideo'&&m&&f>=(c.whenToDeactivateOverride?frameScale(c.whenToStart):m.frames-2))this.once(c,'next',()=>{this.activate(c.next);if(c.deactivateCurrentObject)this.deactivate(n.id);}); if(c.type==='alternateBathroom'&&!this.flags.goofyBathroomSequence&&f>=frameScale(c.whenToStart))this.once(c,'branch',()=>{this.activate(c.alternateVideo);this.deactivate(n.id);}); if(c.type==='alternateBranchCindy'&&this.flags.cindyCaptured&&this.frame(this.nodes[c.originalVideo])>=frameScale(c.whenToPlay))this.once(c,'branch',()=>{this.activate(c.nextVideo);this.deactivate(c.originalVideo);}); if(c.type==='updateStill')this.once(c,'still',()=>{if(this.nodes[c.stills])this.nodes[c.stills].sprite=c.newStill;}); if(c.type==='disableVideo'&&m){this.disableComponent(c.containerToDisable,'gameOverCheck');if(f>=m.frames-5)this.deactivate(c.containerToDisable);} if(c.type==='gameEnding'&&f>=frameScale(c.whenToPlay))this.once(c,'ending',()=>{ const ending=!this.flags.sheilaCaptured?c.sheilaEnding:!this.flags.victorCaptured?c.victorEnding:this.score===this.possible?c.betterEnding:c.goodEnding; this.activate(ending);this.deactivate(n.id);this.override=ending;this.note('Ending branch selected.','ending'); }); if(c.type==='startEnding'&&m&&f>=m.frames-2)this.once(c,'end',()=>{ this.phase='credits';this.override=c.next||277;this.activate(c.next||277);this.activate(200);this.activate(431);this.activate(544);this.deactivate(n.id); this.note('Ending complete. Rolling credits started.','ending'); }); if(this.phase==='credits'&&n.id===544&&m&&f>=m.frames-2)this.once(c,'creditsEnd',()=>{this.phase='results';this.finish('Credits complete');}); if(c.type==='gameOverTrigger') { const original=this.nodes[c.originalSequence]; if(this.active.has(original?.id)&&this.frame(original)>=frameScale(c.gameOverSequenceStart))this.once(c,'fail',()=>{ this.forceSequence(c,original,c.gameOver); }); } if(c.type==='autoCamera') { const original=this.nodes[c.sequence]; if(this.active.has(original?.id)&&this.frame(original)>=frameScale(c.whenToSwitchCamera))this.once(c,'force',()=>this.forceSequence(c,original,c.scatOverrideVideo)); } if(c.type==='scatOverride') { const original=this.nodes[c.sequence]; if(this.active.has(original?.id)&&this.frame(original)>=frameScale(c.whenToActivateScatOverride))this.once(c,'force',()=>this.forceSequence(c,original,c.scatOverrideVideo)); } if(c.type==='endingGameOver'&&f>=frameScale(c.gameOverStart))this.once(c,'over',()=>this.finish('Game over')); // Some source thresholds exceed the last valid frame by a fraction (e.g. 500.1001 vs 500). // Browser adaptation: a finished failure clip must still reach the game-over screen. if(c.type==='gameOver'&&m&&(f>=frameScale(c.gameOverTrigger)||this.elapsed(n)>=m.duration))this.once(c,'over',()=>this.finish('Game over')); if(c.type==='screenController'&&m&&!c.disableOverride&&!c.manualOverride&&f>=m.frames-1) { this.lastRoomFrames[n.room]={clip:n.video.clip,time:Math.max(0,(m.frames-2)/m.fps)}; this.deactivate(c.destroyParent?n.parent:n.id,true); } } } // Components on the Sequences container use the global clock. for(const c of this.nodes[731]?.components??[]) { if(!c.enabled)continue; if(c.type==='activateJeff'&&this.time>=c.whenToPlay&&!this.flags.troyCaptured)this.once(c,'jeff',()=>this.activate(c.videoSequence)); if(c.type==='tooManyAugs'&&this.time>=c.whenToStart)this.once(c,'checkpoint',()=>{ if(this.score/Math.max(1,this.possible)<.75&&!this.practice){this.activate(c.gameOverVideo);this.override=c.gameOverVideo;this.note('Capture ratio below 75%. SCAT override.','failure');} }); } for(const e of this.events.filter(e=>!e.done&&e.time<=this.time)) { e.done=true; if(e.kind==='scoreCheck') {const c=e.component;const pass=this.practice||e.ratio>=.75;this.activate(pass?c.continueGameVideo:c.gameOverVideo);if(!pass)this.override=c.gameOverVideo;} } } windows(room=this.room) { const result=[]; for(const id of this.active)for(const c of this.nodes[id].components) { if(!c.enabled||!['captureSequence','captureMegan'].includes(c.type)||c.roomName!==room||this.done.has(c.id+':capture'))continue; const original=this.nodes[c.originalSequence];if(!this.active.has(original?.id))continue; const begin=frameScale(c.trapStart);const end=c.type==='captureMegan'?frameScale(c.last):Math.fround(begin+37.5); result.push({component:c,node:id,original:original.id,frame:this.frame(original),begin,end,startTime:this.videoStarts[original.id]+Math.ceil(begin)/this.clip(original).fps}); } return result; } trap() { if(this.end)return {ok:false,reason:'ended'}; if(this.controlLocked){this.note('Trap controls disabled during SCAT override.','miss');return {ok:false,reason:'locked'};} this.inputs.push({time:this.time,type:'trap',room:this.room,color:this.color}); const valid=this.windows().filter(w=>w.frame>=w.begin&&w.frame<=w.end); if(!valid.length){this.note('Trap fired outside the capture window.','miss');return {ok:false,reason:'timing'};} if(this.color!==this.securityColor){this.note('Trap rejected: security code mismatch.','miss');return {ok:false,reason:'color'};} for(const w of valid) { const c=w.component;this.done.add(c.id+':capture');this.activate(c.capturedSequence); if(!c.dontDestroy)this.deactivate(c.originalSequence); this.note('Capture confirmed in '+this.room+'.','capture'); } this.update();return {ok:true,count:valid.length}; } selectRoom(room) {if(!this.controlLocked&&this.data.rooms.some(r=>r.id===room))this.room=room;} selectColor(color) {if(!this.controlLocked&&color>=1&&color<=6)this.color=color;} view(room=this.room) { if(this.override) { const candidates=this.descendants(this.override).map(id=>this.nodes[id]).filter(n=>this.active.has(n.id)&&n.video); if(candidates.length){const n=candidates[0];return {node:n.id,clip:n.video.clip,time:this.elapsed(n),loop:n.video.loop,override:true};} } const candidates=[...this.active].map(id=>this.nodes[id]).filter(n=>n.room===room&&n.video&&this.clip(n)); candidates.sort((a,b)=>(this.videoStarts[b.id]-this.videoStarts[a.id])||b.id-a.id); const n=candidates[0];if(n)return {node:n.id,clip:n.video.clip,time:this.elapsed(n),loop:n.video.loop}; const stills=Object.values(this.nodes).filter(n=>n.room===room&&n.name==='stills'); const still=stills.find(n=>n.children?.length)||stills[0]; return {sprite:still?.sprite,still:true}; } descendants(id) {const n=this.nodes[id];return n?[n.id,...n.children.flatMap(c=>this.descendants(c))]:[];} finish(reason) {if(this.practice)return;this.end=reason;this.note(reason,'end');} retry() { if(!this.checkpoint||this.lives<=0)return false; const cp=structuredClone(this.checkpoint);this.lives--;this.reset();this.practice=false;this.lives=Math.max(0,this.lives); for(const input of cp.inputs??[]){this.advance(Math.max(0,input.time-this.time));this.selectRoom(input.room);this.selectColor(input.color);this.trap();} this.advance(Math.max(0,cp.time-this.time));this.selectRoom(cp.room);this.selectColor(cp.color); this.note(`Retry resumed at ${Math.floor(cp.time/60)}:${String(Math.floor(cp.time%60)).padStart(2,'0')} ยท ${this.lives} lives remaining.`,'retry'); return true; } snapshot() {return {version:2,seed:this.seed,time:this.time,room:this.room,color:this.color,practice:this.practice,inputs:this.inputs,lives:this.lives,checkpoint:this.checkpoint};} restore(save) { if(![1,2].includes(save.version)||!Number.isFinite(save.time)||save.time<0||save.time>1900)throw Error('Invalid save'); this.seed=save.seed;this.reset();this.practice=!!save.practice;this.lives=save.lives??3;this.checkpoint=save.checkpoint??null; for(const input of save.inputs??[]){this.advance(Math.max(0,input.time-this.time));this.selectRoom(input.room);this.selectColor(input.color);this.trap();} this.advance(Math.max(0,save.time-this.time));this.selectRoom(save.room);this.selectColor(save.color); } }