From 1e7fcbc97eefee49404b387f30037cecca4a22c2 Mon Sep 17 00:00:00 2001 From: Jonathan Romano Date: Sat, 30 Aug 2025 12:21:59 -0400 Subject: [PATCH 01/21] WIP --- src/eterna/observability/ObservabilityManager.ts | 3 +++ src/eterna/ui/GameButton.ts | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) create mode 100644 src/eterna/observability/ObservabilityManager.ts diff --git a/src/eterna/observability/ObservabilityManager.ts b/src/eterna/observability/ObservabilityManager.ts new file mode 100644 index 000000000..cbc349f48 --- /dev/null +++ b/src/eterna/observability/ObservabilityManager.ts @@ -0,0 +1,3 @@ +export default class ObservabilityManager { + +} diff --git a/src/eterna/ui/GameButton.ts b/src/eterna/ui/GameButton.ts index 2d08fd8e0..1a3fc5d7e 100644 --- a/src/eterna/ui/GameButton.ts +++ b/src/eterna/ui/GameButton.ts @@ -75,9 +75,9 @@ export default class GameButton extends Button implements KeyboardListener { this._rscriptID = value; this._rscriptClickReg.close(); if (value != null) { - this._rscriptClickReg = this.clicked.connect(() => { + this._rscriptClickReg = this.regs.add(this.clicked.connect(() => { ROPWait.notifyClickUI(this._rscriptID); - }); + })); } } return this; From f837b3b092424a65b25e03b634132a409386b2b0 Mon Sep 17 00:00:00 2001 From: Jonathan Romano Date: Tue, 16 Sep 2025 14:00:44 -0400 Subject: [PATCH 02/21] UI observability --- src/eterna/Eterna.ts | 2 + src/eterna/EternaApp.ts | 6 ++ src/eterna/mode/GameMode.ts | 6 +- src/eterna/mode/PoseEdit/Booster.ts | 4 + src/eterna/mode/PoseEdit/PoseEditMode.ts | 1 + .../observability/ObservabilityManager.ts | 50 +++++++++ .../__tests__/ObservabilityManager.test.ts | 101 ++++++++++++++++++ src/eterna/pose2D/Pose2D.ts | 22 ++++ src/eterna/ui/toolbar/ToolbarButton.ts | 8 +- src/eterna/ui/toolbar/ToolbarButtons.ts | 4 +- 10 files changed, 199 insertions(+), 5 deletions(-) create mode 100644 src/eterna/observability/__tests__/ObservabilityManager.test.ts diff --git a/src/eterna/Eterna.ts b/src/eterna/Eterna.ts index b027413fb..28fdf6776 100644 --- a/src/eterna/Eterna.ts +++ b/src/eterna/Eterna.ts @@ -5,6 +5,7 @@ import EternaApp from 'eterna/EternaApp'; import EternaSettings from './settings/EternaSettings'; import GameClient from './net/GameClient'; import ErrorDialogMode from './mode/ErrorDialogMode'; +import ObservabilityManager from './observability/ObservabilityManager'; /** Return env.APP_SERVER_URL; if unspecified, default to window.location.origin */ function GetServerURL(): string { @@ -38,6 +39,7 @@ export default class Eterna { public static saveManager: SaveGameManager; public static client: GameClient; public static chat: ChatManager; + public static observability: ObservabilityManager; public static playerID: number; public static playerName: string; diff --git a/src/eterna/EternaApp.ts b/src/eterna/EternaApp.ts index 0b0aa8258..3af74194b 100644 --- a/src/eterna/EternaApp.ts +++ b/src/eterna/EternaApp.ts @@ -45,6 +45,7 @@ import ROPWait from './rscript/ROPWait'; import Band from './ui/Band'; import BaseGlow from './vfx/BaseGlow'; import RNet from './folding/RNet'; +import ObservabilityManager from './observability/ObservabilityManager'; export enum PuzzleID { FunAndEasy = 4350940, @@ -127,6 +128,10 @@ interface ProcessedEternaAppParams extends EternaAppParams { /** Entry point for the game */ export default class EternaApp extends FlashbangApp { + get o11y() { + return Eterna.observability; + } + constructor(params: EternaAppParams) { super(); @@ -211,6 +216,7 @@ export default class EternaApp extends FlashbangApp { Eterna.gameDiv = document.getElementById(this._params.containerID); Eterna.noGame = this._params.noGame; Eterna.experimentalFeatures = this._params.experimentalFeatures; + Eterna.observability = new ObservabilityManager(); // Without this, we stop the pointer events from propagating to NGL in Pose3D/PointerEventPropagator, // but the original mouse events will still get fired, so NGL will get confused since it tracks some diff --git a/src/eterna/mode/GameMode.ts b/src/eterna/mode/GameMode.ts index 7e48bb176..58e1ba09d 100644 --- a/src/eterna/mode/GameMode.ts +++ b/src/eterna/mode/GameMode.ts @@ -688,6 +688,7 @@ export default abstract class GameMode extends AppMode { // Already live if (!factorDialog) return; factorDialog.factor.connect((factor) => { + Eterna.observability.recordEvent('Display:ExplosionFactor', {factor}); this._poseFields.forEach((pf) => { pf.explosionFactor = factor; }); }); } @@ -742,17 +743,16 @@ export default abstract class GameMode extends AppMode { } else if (!ctrl && key === KeyCode.KeyL) { Eterna.settings.usePuzzlerLayout.value = !Eterna.settings.usePuzzlerLayout.value; handled = true; - } else if (ctrl && key === KeyCode.KeyS) { - this.downloadSVG(); - handled = true; } else if (key === KeyCode.BracketLeft) { const factor = Math.max(0, Math.round((this._poseFields[0].explosionFactor - 0.25) * 1000) / 1000); + Eterna.observability.recordEvent('Display:ExplosionFactor', {factor}); for (const pf of this._poseFields) { pf.explosionFactor = factor; } handled = true; } else if (key === KeyCode.BracketRight) { const factor = Math.max(0, Math.round((this._poseFields[0].explosionFactor + 0.25) * 1000) / 1000); + Eterna.observability.recordEvent('Display:ExplosionFactor', {factor}); for (const pf of this._poseFields) { pf.explosionFactor = factor; } diff --git a/src/eterna/mode/PoseEdit/Booster.ts b/src/eterna/mode/PoseEdit/Booster.ts index fe462f865..f068d4ce7 100644 --- a/src/eterna/mode/PoseEdit/Booster.ts +++ b/src/eterna/mode/PoseEdit/Booster.ts @@ -137,6 +137,10 @@ export default class Booster { return this._buttonStateTextures; } + public get scriptID() { + return this._scriptID; + } + public onLoad(): void { this.executeScript(null, 'ON_LOAD', -1); } diff --git a/src/eterna/mode/PoseEdit/PoseEditMode.ts b/src/eterna/mode/PoseEdit/PoseEditMode.ts index 5d62624b3..4b74e8d7b 100644 --- a/src/eterna/mode/PoseEdit/PoseEditMode.ts +++ b/src/eterna/mode/PoseEdit/PoseEditMode.ts @@ -1600,6 +1600,7 @@ export default class PoseEditMode extends GameMode { const ctrl = e.ctrlKey; if (ctrl && key === KeyCode.KeyZ) { + Eterna.observability.recordEvent('RunTool:lastStable'); this.moveUndoStackToLastStable(); handled = true; } diff --git a/src/eterna/observability/ObservabilityManager.ts b/src/eterna/observability/ObservabilityManager.ts index cbc349f48..22adef971 100644 --- a/src/eterna/observability/ObservabilityManager.ts +++ b/src/eterna/observability/ObservabilityManager.ts @@ -1,3 +1,53 @@ +class ObservabilityEvent { + constructor(name: string, details: unknown) { + this.name = name; + this.details = details; + } + + public readonly name: string; + public readonly details: unknown; + public next: ObservabilityEvent | null; +} + +class RootEvent { + public next: ObservabilityEvent | null; +} + +export class ObservabilityEventCapture { + constructor(firstEvent: ObservabilityEvent | RootEvent) { + this._firstEvent = firstEvent; + } + + public report() { + const out = []; + let ev = this._firstEvent.next; + while (ev) { + if (ev.details !== undefined) { + out.push({name: ev.name, details: ev.details}); + } else { + out.push({name: ev.name}); + } + ev = ev.next; + } + return out; + } + + private _firstEvent: ObservabilityEvent | RootEvent; +} + export default class ObservabilityManager { + public eventCapture(): ObservabilityEventCapture { + const capture = new ObservabilityEventCapture(this._lastEvent); + return capture; + } + + public recordEvent(name: string, details?: unknown) { + const event = new ObservabilityEvent(name, details); + if (this._lastEvent) { + this._lastEvent.next = event; + } + this._lastEvent = event; + } + private _lastEvent: ObservabilityEvent | RootEvent = new RootEvent(); } diff --git a/src/eterna/observability/__tests__/ObservabilityManager.test.ts b/src/eterna/observability/__tests__/ObservabilityManager.test.ts new file mode 100644 index 000000000..1b894fb8e --- /dev/null +++ b/src/eterna/observability/__tests__/ObservabilityManager.test.ts @@ -0,0 +1,101 @@ +import ObservabilityManager from '../ObservabilityManager'; + +test('ObservabilityCapture - immediate capture', () => { + const om = new ObservabilityManager(); + const cap = om.eventCapture(); + om.recordEvent('FIRST', {a:1, b:2, c:3}); + om.recordEvent('SECOND'); + expect(cap.report()).toMatchInlineSnapshot(` +[ + { + "details": { + "a": 1, + "b": 2, + "c": 3, + }, + "name": "FIRST", + }, + { + "name": "SECOND", + }, +] +`) +}) + +test('ObservabilityCapture - skipped event', () => { + const om = new ObservabilityManager(); + om.recordEvent('FIRST', {}); + const cap = om.eventCapture(); + om.recordEvent('SECOND', {}); + om.recordEvent('THIRD', {}); + expect(cap.report()).toMatchInlineSnapshot(` +[ + { + "details": {}, + "name": "SECOND", + }, + { + "details": {}, + "name": "THIRD", + }, +] +`) +}) + +test('ObservabilityCapture - mixed lifetimes', () => { + const om = new ObservabilityManager(); + om.recordEvent('FIRST', {}); + const cap = om.eventCapture(); + om.recordEvent('SECOND', {}); + const cap2 = om.eventCapture(); + om.recordEvent('THIRD', {}); + expect(cap.report()).toMatchInlineSnapshot(` +[ + { + "details": {}, + "name": "SECOND", + }, + { + "details": {}, + "name": "THIRD", + }, +] +`) + expect(cap2.report()).toMatchInlineSnapshot(` +[ + { + "details": {}, + "name": "THIRD", + }, +] +`) +}) + +test('ObservabilityCapture - mixed lifetimes', () => { + const om = new ObservabilityManager(); + om.recordEvent('FIRST', {}); + const cap = om.eventCapture(); + om.recordEvent('SECOND', {}); + const cap2 = om.eventCapture(); + om.recordEvent('THIRD', {}); + expect(cap.report()).toMatchInlineSnapshot(` +[ + { + "details": {}, + "name": "SECOND", + }, + { + "details": {}, + "name": "THIRD", + }, +] +`) + expect(cap2.report()).toMatchInlineSnapshot(` +[ + { + "details": {}, + "name": "THIRD", + }, +] +`) +}) \ No newline at end of file diff --git a/src/eterna/pose2D/Pose2D.ts b/src/eterna/pose2D/Pose2D.ts index b5608423b..374963375 100644 --- a/src/eterna/pose2D/Pose2D.ts +++ b/src/eterna/pose2D/Pose2D.ts @@ -890,12 +890,15 @@ export default class Pose2D extends ContainerObject implements Updatable { this.addObject(dragger); if (this._currentArrangementTool === Layout.MOVE) { + Eterna.observability.recordEvent('Base:LayoutMove'); dragger.dragged.connect((p) => { this.onMouseMoved(p as Point, closestIndex); }); } else if (this._currentArrangementTool === Layout.ROTATE_STEM) { + Eterna.observability.recordEvent('Base:LayoutRotate'); this.rotateStem(closestIndex); } else if (this._currentArrangementTool === Layout.FLIP_STEM) { + Eterna.observability.recordEvent('Base:LayoutFlip'); this.flipStem(closestIndex); } dragger.dragComplete.connect(() => { @@ -908,6 +911,7 @@ export default class Pose2D extends ContainerObject implements Updatable { && closestIndex < this.fullSequenceLength && !this._annotationManager.annotationModeActive.value ) { + Eterna.observability.recordEvent('Base:Mark'); this.toggleBaseMark(closestIndex); return; } @@ -928,6 +932,7 @@ export default class Pose2D extends ContainerObject implements Updatable { return; } if (this._annotationManager.annotationModeActive.value) { + Eterna.observability.recordEvent('Base:Annotate'); this.hideAnnotationContextMenu(); const strand = this.getStrandLabel(closestIndex) ?? undefined; @@ -1042,6 +1047,7 @@ export default class Pose2D extends ContainerObject implements Updatable { this._lastShiftedCommand = this._currentColor; this._lastShiftedIndex = closestIndex; + Eterna.observability.recordEvent(`Base:${cmd[1]}`); this.callAddBaseCallback(cmd[0], cmd[1], closestIndex); } @@ -1069,6 +1075,7 @@ export default class Pose2D extends ContainerObject implements Updatable { && closestIndex < this.fullSequenceLength && !this._annotationManager.annotationModeActive.value ) { + Eterna.observability.recordEvent('Base:Mark'); this.toggleBaseMark(closestIndex); return; } @@ -1082,6 +1089,7 @@ export default class Pose2D extends ContainerObject implements Updatable { this._lastShiftedCommand = this._currentColor; this._lastShiftedIndex = closestIndex; + Eterna.observability.recordEvent(`Base:${cmd[1]}`); this.callAddBaseCallback(cmd[0], cmd[1], closestIndex); } } @@ -1548,6 +1556,7 @@ export default class Pose2D extends ContainerObject implements Updatable { if (q == null) { return; } + Eterna.observability.recordEvent('Base:Shift3'); const first: number = q[0]; const last: number = q[1]; @@ -1612,6 +1621,7 @@ export default class Pose2D extends ContainerObject implements Updatable { if (q == null) { return; } + Eterna.observability.recordEvent('Base:Shift5'); const first: number = q[0]; const last: number = q[1]; @@ -3510,6 +3520,7 @@ export default class Pose2D extends ContainerObject implements Updatable { this._mutatedSequence = this.fullSequence.slice(0); if (this._currentColor === RNAPaint.LOCK) { + Eterna.observability.recordEvent('Base:Lock'); if (!this._locks) { this._locks = []; for (let ii = 0; ii < this._sequence.length; ii++) { @@ -3520,6 +3531,7 @@ export default class Pose2D extends ContainerObject implements Updatable { this._bases[seqnum].setDirty(); this._lockUpdated = true; } else if (this._currentColor === RNAPaint.BINDING_SITE) { + Eterna.observability.recordEvent('Base:BindingSite'); if (!this._canAddBindingSite) { (this.mode as GameMode).showNotification('The current folding engine does not support molecules'); } else if (this._bindingSite != null && this._bindingSite[seqnum]) { @@ -3549,22 +3561,27 @@ export default class Pose2D extends ContainerObject implements Updatable { } } } else if (this._mouseDownAltKey || this._currentColor === RNAPaint.MAGIC_GLUE) { + Eterna.observability.recordEvent('Base:Glue'); if (this.toggleDesignStruct(seqnum)) { this._designStructUpdated = true; } } else if (this._currentColor === RNAPaint.STAMP_TLOOPA) { + Eterna.observability.recordEvent('Base:TLoopA'); this._lastStamp = {type: 'TLOOPA', baseIndex: seqnum}; } else if (this._currentColor === RNAPaint.STAMP_TLOOPB) { + Eterna.observability.recordEvent('Base:TLoopB'); this._lastStamp = {type: 'TLOOPB', baseIndex: seqnum}; } else if (!this.isLocked(seqnum)) { if ( this._currentColor === RNABase.ADENINE || this._currentColor === RNABase.URACIL || this._currentColor === RNABase.GUANINE || this._currentColor === RNABase.CYTOSINE ) { + Eterna.observability.recordEvent(`Base:${EPars.nucleotideToString(this._currentColor)}`); this._mutatedSequence.setNt(seqnum, this._currentColor); ROPWait.notifyPaint(seqnum, this._bases[seqnum].type, this._currentColor); this._bases[seqnum].setType(this._currentColor, true); } else if (this._currentColor === RNAPaint.PAIR && this._pairs.isPaired(seqnum)) { + Eterna.observability.recordEvent('Base:Swap'); const pi = this._pairs.pairingPartner(seqnum); if (this.isLocked(pi)) { return; @@ -3578,6 +3595,7 @@ export default class Pose2D extends ContainerObject implements Updatable { this._bases[seqnum].setType(this._mutatedSequence.nt(seqnum), true); this._bases[pi].setType(this._mutatedSequence.nt(pi), true); } else if (this._currentColor === RNAPaint.AU_PAIR && this._pairs.isPaired(seqnum)) { + Eterna.observability.recordEvent('Base:AuPair'); const pi = this._pairs.pairingPartner(seqnum); if (this.isLocked(pi)) { return; @@ -3589,6 +3607,7 @@ export default class Pose2D extends ContainerObject implements Updatable { this._bases[seqnum].setType(this._mutatedSequence.nt(seqnum), true); this._bases[pi].setType(this._mutatedSequence.nt(pi), true); } else if (this._currentColor === RNAPaint.GC_PAIR && this._pairs.isPaired(seqnum)) { + Eterna.observability.recordEvent('Base:GcPair'); const pi = this._pairs.pairingPartner(seqnum); if (this.isLocked(pi)) { return; @@ -3600,6 +3619,7 @@ export default class Pose2D extends ContainerObject implements Updatable { this._bases[seqnum].setType(this._mutatedSequence.nt(seqnum), true); this._bases[pi].setType(this._mutatedSequence.nt(pi), true); } else if (this._currentColor === RNAPaint.GU_PAIR && this._pairs.isPaired(seqnum)) { + Eterna.observability.recordEvent('Base:GuPair'); const pi = this._pairs.pairingPartner(seqnum); if (this.isLocked(pi)) { return; @@ -3612,8 +3632,10 @@ export default class Pose2D extends ContainerObject implements Updatable { this._bases[pi].setType(this._mutatedSequence.nt(pi), true); } else if (this._dynPaintColors.indexOf(this._currentColor) >= 0) { const index: number = this._dynPaintColors.indexOf(this._currentColor); + Eterna.observability.recordEvent(`Base:DynPaint:Script${this._dynPaintTools[index].scriptID}`); this._dynPaintTools[index].onPaint(this, seqnum); } else if (this._currentColor === RNAPaint.LIBRARY_SELECT && seqnum < this.sequenceLength) { + Eterna.observability.recordEvent('Base:LibrarySelect'); this.toggleLibrarySelection(seqnum); this._librarySelectionsChanged = true; } diff --git a/src/eterna/ui/toolbar/ToolbarButton.ts b/src/eterna/ui/toolbar/ToolbarButton.ts index eef7c51e9..3c4537499 100644 --- a/src/eterna/ui/toolbar/ToolbarButton.ts +++ b/src/eterna/ui/toolbar/ToolbarButton.ts @@ -7,6 +7,7 @@ import { Graphics, Sprite, Texture } from 'pixi.js'; import {Value} from 'signals'; +import Eterna from 'eterna/Eterna'; import GameButton from '../GameButton'; export const BUTTON_WIDTH = 55; @@ -34,6 +35,7 @@ export type ToolbarParam = { tooltip:string, selectedImg?:string | Texture, hotKey?:KeyCode, + hotKeyCtrl?: boolean; rscriptID?:RScriptUIElementID, color?:{color:number, alpha:number}, toggleColor?:{color:number, alpha:number}, @@ -78,7 +80,7 @@ export default class ToolbarButton extends GameButton { this.disabled(info.disableImg); if (info.selectedImg) this.selected(info.selectedImg); this.tooltip(info.tooltip); - if (info.hotKey) this.hotkey(info.hotKey); + if (info.hotKey) this.hotkey(info.hotKey, info.hotKeyCtrl); if (info.rscriptID) this.rscriptID(info.rscriptID); if (info.label) { @@ -134,6 +136,10 @@ export default class ToolbarButton extends GameButton { this._arrow.visible = toggled; this.drawBackground(toggled); })); + + this.regs.add(this.clicked.connect(() => { + Eterna.observability.recordEvent(`RunTool:${this.id}`); + })); } private drawBackground(toggled: boolean) { diff --git a/src/eterna/ui/toolbar/ToolbarButtons.ts b/src/eterna/ui/toolbar/ToolbarButtons.ts index 06e20a6bc..cf0d41f5c 100644 --- a/src/eterna/ui/toolbar/ToolbarButtons.ts +++ b/src/eterna/ui/toolbar/ToolbarButtons.ts @@ -358,7 +358,9 @@ export const downloadSVGButtonProps: ToolbarParam = { allImg: Bitmaps.ImgDownloadSVG, overImg: Bitmaps.ImgOverDownloadSVG, disableImg: Bitmaps.ImgGreyDownloadSVG, - tooltip: 'Download an SVG of the current custom layout' + tooltip: 'Download an SVG of the current custom layout', + hotKey: KeyCode.KeyS, + hotKeyCtrl: true }; export const screenshotButtonProps: ToolbarParam = { From 6a7baef619200ad9f83d96c167b7c42facf52429 Mon Sep 17 00:00:00 2001 From: Jonathan Romano Date: Tue, 16 Sep 2025 14:28:49 -0400 Subject: [PATCH 03/21] Restore mutation tracking --- src/eterna/mode/PoseEdit/PoseEditMode.ts | 71 +++++++++++++++++++++++- src/eterna/pose2D/Pose2D.ts | 32 +++++++++++ 2 files changed, 102 insertions(+), 1 deletion(-) diff --git a/src/eterna/mode/PoseEdit/PoseEditMode.ts b/src/eterna/mode/PoseEdit/PoseEditMode.ts index 4b74e8d7b..1d1fcacc2 100644 --- a/src/eterna/mode/PoseEdit/PoseEditMode.ts +++ b/src/eterna/mode/PoseEdit/PoseEditMode.ts @@ -458,6 +458,11 @@ export default class PoseEditMode extends GameMode { this.setPosesColor(RNAPaint.PAIR); } + public async pasteSequence(pasteSequence: Sequence): Promise { + super.pasteSequence(pasteSequence); + this.moveHistoryAddSequence('paste', pasteSequence.toString()); + } + public onHintClicked(): void { if (this._hintBoxRef.isLive) { this._hintBoxRef.destroyObject(); @@ -540,6 +545,7 @@ export default class PoseEditMode extends GameMode { this.setSolutionTargetStructure(foldData); await this.poseEditByTarget(0); } + this.clearMoveTracking(solution.sequence.sequenceString()); this.setAncestorId(solution.nodeID); const annotations = solution.annotations; @@ -606,6 +612,15 @@ export default class PoseEditMode extends GameMode { }); }; + const bindTrackMoves = (pose: Pose2D, _index: number) => { + pose.trackMovesCallback = ((count: number, moves: Move[]) => { + this._moveCount += count; + if (moves) { + this._moves.push(moves.slice()); + } + }); + }; + const bindMousedownEvent = (pose: Pose2D, index: number) => { pose.startMousedownCallback = ((e: FederatedPointerEvent, _closestDist: number, closestIndex: number) => { for (let ii = 0; ii < poseFields.length; ++ii) { @@ -637,6 +652,7 @@ export default class PoseEditMode extends GameMode { const pose: Pose2D = poseField.pose; bindAddBaseCB(pose, ii); bindPoseEdit(pose, ii); + bindTrackMoves(pose, ii); bindMousedownEvent(pose, ii); poseFields.push(poseField); } @@ -1000,8 +1016,14 @@ export default class PoseEditMode extends GameMode { this._opQueue.push(new PoseOp( null, () => { - if (!this._params.isReset) { + if (this._params.isReset) { + const newSeq: Sequence = this._puzzle.transformSequence(this.getCurrentUndoBlock(0).sequence, 0); + this.moveHistoryAddSequence('reset', newSeq.sequenceString()); + } else { this._startSolvingTime = new Date().getTime(); + this._startingPoint = this._puzzle.transformSequence( + this.getCurrentUndoBlock(0).sequence, 0 + ).sequenceString(); } if (this._params.isReset) { @@ -3128,6 +3150,32 @@ export default class PoseEditMode extends GameMode { return true; } + private clearMoveTracking(seq: string): void { + this._startingPoint = seq; + this._moveCount = 0; + this._moves = []; + } + + private moveHistoryAddMutations(before: Sequence, after: Sequence): void { + const muts: Move[] = []; + for (let ii = 0; ii < after.length; ii++) { + if (after.nt(ii) !== before.nt(ii)) { + muts.push({pos: ii + 1, base: EPars.nucleotideToString(after.nt(ii))}); + } + } + + if (muts.length === 0) return; + this._moveCount++; + this._moves.push(muts.slice()); + } + + private moveHistoryAddSequence(changeType: string, seq: string): void { + const muts: Move[] = []; + muts.push({type: changeType, sequence: seq}); + this._moveCount++; + this._moves.push(muts.slice()); + } + private checkConstraints(soft: boolean = false): boolean { return this._constraintBar.updateConstraints({ undoBlocks: this._seqStacks[this._stackLevel], @@ -4238,9 +4286,14 @@ export default class PoseEditMode extends GameMode { } this.savePosesMarkersContexts(); + const before: Sequence = this._puzzle.transformSequence(this.getCurrentUndoBlock(0).sequence, 0); + this._stackLevel++; this.moveUndoStack(); + const after: Sequence = this._puzzle.transformSequence(this.getCurrentUndoBlock(0).sequence, 0); + this.moveHistoryAddMutations(before, after); + this.updateScore(); this.transformPosesMarkers(); } @@ -4251,9 +4304,14 @@ export default class PoseEditMode extends GameMode { } this.savePosesMarkersContexts(); + const before: Sequence = this._puzzle.transformSequence(this.getCurrentUndoBlock(0).sequence, 0); + this._stackLevel--; this.moveUndoStack(); + const after: Sequence = this._puzzle.transformSequence(this.getCurrentUndoBlock(0).sequence, 0); + this.moveHistoryAddMutations(before, after); + this.updateScore(); this.transformPosesMarkers(); } @@ -4261,11 +4319,19 @@ export default class PoseEditMode extends GameMode { private moveUndoStackToLastStable(): void { this.savePosesMarkersContexts(); + const before: Sequence = this._puzzle.transformSequence(this.getCurrentUndoBlock(0).sequence, 0); + const stackLevel: number = this._stackLevel; while (this._stackLevel >= 1) { if (this.getCurrentUndoBlock(0).stable) { this.moveUndoStack(); + const after: Sequence = this._puzzle.transformSequence( + this.getCurrentUndoBlock(0).sequence, 0 + ); + + this.moveHistoryAddMutations(before, after); + this.updateScore(); this.transformPosesMarkers(); return; @@ -4341,6 +4407,9 @@ export default class PoseEditMode extends GameMode { private _alreadyCleared: boolean = false; private _paused: boolean; private _startSolvingTime: number; + private _startingPoint: string; + private _moveCount: number = 0; + private _moves: Move[][] = []; protected _curTargetIndex: number = 0; private _shouldMarkMutations: boolean = false; diff --git a/src/eterna/pose2D/Pose2D.ts b/src/eterna/pose2D/Pose2D.ts index 374963375..1343d7ac9 100644 --- a/src/eterna/pose2D/Pose2D.ts +++ b/src/eterna/pose2D/Pose2D.ts @@ -31,6 +31,7 @@ import Bitmaps from 'eterna/resources/Bitmaps'; import AnnotationView from 'eterna/ui/AnnotationView'; import AnnotationDialog from 'eterna/ui/AnnotationDialog'; import {FederatedPointerEvent} from '@pixi/events'; +import {Move} from 'eterna/mode/PoseEdit/PoseEditMode'; import Base from './Base'; import BaseDrawFlags from './BaseDrawFlags'; import EnergyScoreDisplay from './EnergyScoreDisplay'; @@ -46,6 +47,11 @@ import RNALayout, {RNATreeNode} from './RNALayout'; import ScoreDisplayNode, {ScoreDisplayNodeType} from './ScoreDisplayNode'; import triangulate from './triangulate'; +interface Mut { + pos: number; + base: string; +} + export enum Layout { MOVE, ROTATE_STEM, @@ -426,16 +432,31 @@ export default class Pose2D extends ContainerObject implements Updatable { throw new Error("Mutated sequence and original sequence lengths don't match"); } + let mutationsPerInteraction = 1; + if (this._currentColor === RNAPaint.PAIR + || this._currentColor === RNAPaint.GC_PAIR + || this._currentColor === RNAPaint.AU_PAIR + || this._currentColor === RNAPaint.GU_PAIR) { + mutationsPerInteraction = 2; + } + const offset: number = ( this._oligo != null && this._oligoMode === OligoMode.EXT5P ) ? this._oligo.length : 0; + let numMut = 0; + const muts: Mut[] = []; for (let ii = 0; ii < this._sequence.length; ii++) { if (this._sequence.nt(ii) !== this._mutatedSequence.nt(ii + offset)) { + numMut++; this._sequence.setNt(ii, this._mutatedSequence.nt(ii + offset)); + muts.push({pos: ii + 1, base: EPars.nucleotideToString(this._sequence.nt(ii))}); needUpdate = true; } } + if (needUpdate) { + this.callTrackMovesCallback(numMut / mutationsPerInteraction, muts); + } if ( needUpdate || this._lockUpdated @@ -2060,6 +2081,16 @@ export default class Pose2D extends ContainerObject implements Updatable { } } + public set trackMovesCallback(cb: (count: number, moves: Move[]) => void) { + this._trackMovesCallback = cb; + } + + public callTrackMovesCallback(count: number, moves: Move[]): void { + if (this._trackMovesCallback != null) { + this._trackMovesCallback(count, moves); + } + } + public set addBaseCallback(cb: (parenthesis: string | null, op: PuzzleEditOp | null, index: number) => void) { this._addBaseCallback = cb; } @@ -4453,6 +4484,7 @@ export default class Pose2D extends ContainerObject implements Updatable { // Pointer to callback function to be called after change in pose private _poseEditCallback: (() => void) | null = null; + private _trackMovesCallback: ((count: number, moves: Move[]) => void) | null = null; private _addBaseCallback: (parenthesis: string | null, op: PuzzleEditOp | null, index: number) => void; private _startMousedownCallback: PoseMouseDownCallback; private _startPickCallback: PosePickCallback; From 39b238343e8e2a1039f82b77ca3c31f8e6afc58e Mon Sep 17 00:00:00 2001 From: Jonathan Romano Date: Tue, 16 Sep 2025 15:05:53 -0400 Subject: [PATCH 04/21] More o11y events --- src/eterna/mode/GameMode.ts | 3 +++ src/eterna/mode/PoseEdit/PoseEditMode.ts | 6 +++++- src/eterna/ui/BoosterDialog.ts | 6 +++++- src/eterna/ui/toolbar/ToolbarButton.ts | 2 +- src/eterna/util/ExternalInterface.ts | 6 +++++- 5 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/eterna/mode/GameMode.ts b/src/eterna/mode/GameMode.ts index 58e1ba09d..9a199bdf9 100644 --- a/src/eterna/mode/GameMode.ts +++ b/src/eterna/mode/GameMode.ts @@ -644,6 +644,7 @@ export default abstract class GameMode extends AppMode { // Already live if (!pasteDialog) return; pasteDialog.applyClicked.connect((sequence) => { + Eterna.observability.recordEvent('RunTool:PasteSequence'); this.pasteSequence(sequence); }); } @@ -653,6 +654,7 @@ export default abstract class GameMode extends AppMode { // Already live if (!finder) return; finder.jumpClicked.connect((baseNum) => { + Eterna.observability.recordEvent('Display:JumpToNt'); if (this._isPipMode) { this._poses.forEach((p) => p.focusNucleotide(baseNum)); } else { @@ -662,6 +664,7 @@ export default abstract class GameMode extends AppMode { } protected showNucleotideRange(): void { + Eterna.observability.recordEvent('Display:ShowRange'); const fullRange: [number, number] = [ 1, Math.max(...this._poses.map((p) => p.fullSequenceLength)) diff --git a/src/eterna/mode/PoseEdit/PoseEditMode.ts b/src/eterna/mode/PoseEdit/PoseEditMode.ts index 1d1fcacc2..16c867d6b 100644 --- a/src/eterna/mode/PoseEdit/PoseEditMode.ts +++ b/src/eterna/mode/PoseEdit/PoseEditMode.ts @@ -464,6 +464,7 @@ export default class PoseEditMode extends GameMode { } public onHintClicked(): void { + Eterna.observability.recordEvent('RunTool:Hint'); if (this._hintBoxRef.isLive) { this._hintBoxRef.destroyObject(); } else { @@ -473,6 +474,7 @@ export default class PoseEditMode extends GameMode { } private onHelpClicked() { + Eterna.observability.recordEvent('RunTool:Help'); const getBounds = (elem: ContainerObject) => { const globalPos = elem.container.toGlobal(new Point()); return new Rectangle( @@ -580,6 +582,7 @@ export default class PoseEditMode extends GameMode { } private setMarkerLayer(layer: string) { + Eterna.observability.recordEvent('RunTool:MarkerLayer', {layer}); for (const pose of this._poses) { pose.setMarkerLayer(layer); } @@ -809,6 +812,7 @@ export default class PoseEditMode extends GameMode { this._puzzle.puzzleType === PuzzleType.EXPERIMENTAL ); this._folderSwitcher.selectedFolder.connectNotify((folder) => { + Eterna.observability.recordEvent('RunTool:ChangeFolder', {folder}); for (const pose of this._poses) { pose.scoreFolder = folder; } @@ -1622,7 +1626,7 @@ export default class PoseEditMode extends GameMode { const ctrl = e.ctrlKey; if (ctrl && key === KeyCode.KeyZ) { - Eterna.observability.recordEvent('RunTool:lastStable'); + Eterna.observability.recordEvent('RunTool:LastStable'); this.moveUndoStackToLastStable(); handled = true; } diff --git a/src/eterna/ui/BoosterDialog.ts b/src/eterna/ui/BoosterDialog.ts index 156402906..f06b8b393 100644 --- a/src/eterna/ui/BoosterDialog.ts +++ b/src/eterna/ui/BoosterDialog.ts @@ -2,6 +2,7 @@ import Booster from 'eterna/mode/PoseEdit/Booster'; import PoseEditMode from 'eterna/mode/PoseEdit/PoseEditMode'; import {BoostersData} from 'eterna/puzzle/Puzzle'; import {HAlign, VAlign, VLayoutContainer} from 'flashbang'; +import Eterna from 'eterna/Eterna'; import WindowDialog from './WindowDialog'; export default class BoosterDialog extends WindowDialog { @@ -25,7 +26,10 @@ export default class BoosterDialog extends WindowDialog { Booster.create(this.mode as PoseEditMode, data).then((booster) => { const button = booster.createButton(14); this.addObject(button, content); - button.clicked.connect(() => { booster.onRun(); }); + button.clicked.connect(() => { + booster.onRun(); + Eterna.observability.recordEvent(`RunScript:${booster.scriptID}`); + }); content.layout(); this._window.layout(); }); diff --git a/src/eterna/ui/toolbar/ToolbarButton.ts b/src/eterna/ui/toolbar/ToolbarButton.ts index 3c4537499..7af4b933f 100644 --- a/src/eterna/ui/toolbar/ToolbarButton.ts +++ b/src/eterna/ui/toolbar/ToolbarButton.ts @@ -138,7 +138,7 @@ export default class ToolbarButton extends GameButton { })); this.regs.add(this.clicked.connect(() => { - Eterna.observability.recordEvent(`RunTool:${this.id}`); + Eterna.observability.recordEvent(`ToolbarAction:${this.id}`); })); } diff --git a/src/eterna/util/ExternalInterface.ts b/src/eterna/util/ExternalInterface.ts index 539184ea5..d2d071a4d 100644 --- a/src/eterna/util/ExternalInterface.ts +++ b/src/eterna/util/ExternalInterface.ts @@ -1,6 +1,7 @@ import log from 'loglevel'; import {Registration, UnitSignal} from 'signals'; import {Deferred, Assert} from 'flashbang'; +import Eterna from 'eterna/Eterna'; // We have to deal with callbacks weakly. Ideally there'd be a mechanism to make this a bit // more explicit using `unknown`, but I couldn't immediately figure out a way to do it @@ -17,7 +18,10 @@ export class ExternalInterfaceCtx { public readonly changed = new UnitSignal(); public addCallback(name: string, callback: AnyFunction): void { - this.callbacks.set(name, callback); + this.callbacks.set(name, (...args) => { + Eterna.observability.recordEvent(`ScriptFunc:${name}`); + callback(...args); + }); this.changed.emit(); } From 121aebd690d656b55d75baed457ef484fec83b51 Mon Sep 17 00:00:00 2001 From: Jonathan Romano Date: Tue, 16 Sep 2025 16:13:37 -0400 Subject: [PATCH 05/21] Observability streaming handlers, postmessage output, event filtering --- src/eterna/EternaApp.ts | 4 - .../observability/ObservabilityManager.ts | 59 ++---- .../observability/ObservabilityReporter.ts | 3 + .../observability/PostMessageReporter.ts | 17 ++ .../__tests__/ObservabilityManager.test.ts | 179 ++++++++++-------- 5 files changed, 137 insertions(+), 125 deletions(-) create mode 100644 src/eterna/observability/ObservabilityReporter.ts create mode 100644 src/eterna/observability/PostMessageReporter.ts diff --git a/src/eterna/EternaApp.ts b/src/eterna/EternaApp.ts index 3af74194b..fd05411be 100644 --- a/src/eterna/EternaApp.ts +++ b/src/eterna/EternaApp.ts @@ -128,10 +128,6 @@ interface ProcessedEternaAppParams extends EternaAppParams { /** Entry point for the game */ export default class EternaApp extends FlashbangApp { - get o11y() { - return Eterna.observability; - } - constructor(params: EternaAppParams) { super(); diff --git a/src/eterna/observability/ObservabilityManager.ts b/src/eterna/observability/ObservabilityManager.ts index 22adef971..c9ba64910 100644 --- a/src/eterna/observability/ObservabilityManager.ts +++ b/src/eterna/observability/ObservabilityManager.ts @@ -1,53 +1,30 @@ -class ObservabilityEvent { - constructor(name: string, details: unknown) { - this.name = name; - this.details = details; - } - - public readonly name: string; - public readonly details: unknown; - public next: ObservabilityEvent | null; -} +import ObservabilityReporter from './ObservabilityReporter'; -class RootEvent { - public next: ObservabilityEvent | null; +interface ObservabilityCapture { + reporter: ObservabilityReporter; + filter?: (event: {name: string, details?: unknown}) => boolean; } -export class ObservabilityEventCapture { - constructor(firstEvent: ObservabilityEvent | RootEvent) { - this._firstEvent = firstEvent; - } - - public report() { - const out = []; - let ev = this._firstEvent.next; - while (ev) { - if (ev.details !== undefined) { - out.push({name: ev.name, details: ev.details}); - } else { - out.push({name: ev.name}); - } - ev = ev.next; - } - return out; +export default class ObservabilityManager { + public startCapture( + reporter: ObservabilityReporter, + filter?: ObservabilityCapture['filter'] + ) { + this._captures.push({reporter, filter}); } - private _firstEvent: ObservabilityEvent | RootEvent; -} - -export default class ObservabilityManager { - public eventCapture(): ObservabilityEventCapture { - const capture = new ObservabilityEventCapture(this._lastEvent); - return capture; + public endCapture(reporter: ObservabilityReporter) { + this._captures = this._captures.filter((capture) => capture.reporter !== reporter); } public recordEvent(name: string, details?: unknown) { - const event = new ObservabilityEvent(name, details); - if (this._lastEvent) { - this._lastEvent.next = event; + for (const capture of this._captures) { + if (!capture.filter || capture.filter({name, details})) { + if (details !== undefined) capture.reporter.recordEvent({name, details}); + else capture.reporter.recordEvent({name}); + } } - this._lastEvent = event; } - private _lastEvent: ObservabilityEvent | RootEvent = new RootEvent(); + private _captures: ObservabilityCapture[] = []; } diff --git a/src/eterna/observability/ObservabilityReporter.ts b/src/eterna/observability/ObservabilityReporter.ts new file mode 100644 index 000000000..377671eaa --- /dev/null +++ b/src/eterna/observability/ObservabilityReporter.ts @@ -0,0 +1,3 @@ +export default abstract class ObservabilityReporter { + abstract recordEvent(event: {name: string, details?: unknown}): void; +} diff --git a/src/eterna/observability/PostMessageReporter.ts b/src/eterna/observability/PostMessageReporter.ts new file mode 100644 index 000000000..c795b7830 --- /dev/null +++ b/src/eterna/observability/PostMessageReporter.ts @@ -0,0 +1,17 @@ +export default class PostMessageReporter { + constructor(id: string, targetOrigin?: string) { + this._id = id; + this._targetOrigin = targetOrigin; + } + + public recordEvent(event: {name: string, details?: unknown}) { + window.postMessage({ + type: 'observability-event', + reporterId: this._id, + event + }, {targetOrigin: this._targetOrigin}); + } + + private _id: string; + private _targetOrigin?: string; +} diff --git a/src/eterna/observability/__tests__/ObservabilityManager.test.ts b/src/eterna/observability/__tests__/ObservabilityManager.test.ts index 1b894fb8e..c67e570ca 100644 --- a/src/eterna/observability/__tests__/ObservabilityManager.test.ts +++ b/src/eterna/observability/__tests__/ObservabilityManager.test.ts @@ -1,101 +1,120 @@ +import {jest} from '@jest/globals' import ObservabilityManager from '../ObservabilityManager'; +import ObservabilityReporter from '../ObservabilityReporter'; -test('ObservabilityCapture - immediate capture', () => { - const om = new ObservabilityManager(); - const cap = om.eventCapture(); - om.recordEvent('FIRST', {a:1, b:2, c:3}); - om.recordEvent('SECOND'); - expect(cap.report()).toMatchInlineSnapshot(` +function createReporter() { + class NullReporter extends ObservabilityReporter { + recordEvent = jest.fn(); + } + return new NullReporter(); +} + +test('ObservabilityManager - basic', () => { + const manager = new ObservabilityManager(); + const reporter = createReporter(); + manager.startCapture(reporter); + manager.recordEvent('EV1'); + manager.recordEvent('EV2', {abc: 123}); + expect(reporter.recordEvent.mock.calls).toMatchInlineSnapshot(` [ - { - "details": { - "a": 1, - "b": 2, - "c": 3, + [ + { + "name": "EV1", + }, + ], + [ + { + "details": { + "abc": 123, + }, + "name": "EV2", }, - "name": "FIRST", - }, - { - "name": "SECOND", - }, + ], ] -`) +`); }) -test('ObservabilityCapture - skipped event', () => { - const om = new ObservabilityManager(); - om.recordEvent('FIRST', {}); - const cap = om.eventCapture(); - om.recordEvent('SECOND', {}); - om.recordEvent('THIRD', {}); - expect(cap.report()).toMatchInlineSnapshot(` +test('ObservabilityManager - filter', () => { + const manager = new ObservabilityManager(); + const reporter = createReporter(); + manager.startCapture(reporter, (ev) => ev.name === 'EV2'); + manager.recordEvent('EV1'); + manager.recordEvent('EV2'); + expect(reporter.recordEvent.mock.calls).toMatchInlineSnapshot(` [ - { - "details": {}, - "name": "SECOND", - }, - { - "details": {}, - "name": "THIRD", - }, + [ + { + "name": "EV2", + }, + ], ] -`) +`); }) -test('ObservabilityCapture - mixed lifetimes', () => { - const om = new ObservabilityManager(); - om.recordEvent('FIRST', {}); - const cap = om.eventCapture(); - om.recordEvent('SECOND', {}); - const cap2 = om.eventCapture(); - om.recordEvent('THIRD', {}); - expect(cap.report()).toMatchInlineSnapshot(` +test('ObservabilityManager - multiple', () => { + const manager = new ObservabilityManager(); + const reporter = createReporter(); + const reporter2 = createReporter(); + manager.startCapture(reporter, (ev) => ev.name === 'EV1'); + manager.startCapture(reporter2, (ev) => ev.name === 'EV2'); + manager.recordEvent('EV1'); + manager.recordEvent('EV2'); + expect(reporter.recordEvent.mock.calls).toMatchInlineSnapshot(` [ - { - "details": {}, - "name": "SECOND", - }, - { - "details": {}, - "name": "THIRD", - }, + [ + { + "name": "EV1", + }, + ], ] -`) - expect(cap2.report()).toMatchInlineSnapshot(` +`); + expect(reporter2.recordEvent.mock.calls).toMatchInlineSnapshot(` [ - { - "details": {}, - "name": "THIRD", - }, + [ + { + "name": "EV2", + }, + ], ] -`) +`); }) -test('ObservabilityCapture - mixed lifetimes', () => { - const om = new ObservabilityManager(); - om.recordEvent('FIRST', {}); - const cap = om.eventCapture(); - om.recordEvent('SECOND', {}); - const cap2 = om.eventCapture(); - om.recordEvent('THIRD', {}); - expect(cap.report()).toMatchInlineSnapshot(` +test('ObservabilityManager - registration lifetimes', () => { + const manager = new ObservabilityManager(); + const reporter = createReporter(); + const reporter2 = createReporter(); + manager.startCapture(reporter); + manager.recordEvent('EV1'); + manager.startCapture(reporter2); + manager.recordEvent('EV2'); + manager.endCapture(reporter); + manager.recordEvent('EV3'); + expect(reporter.recordEvent.mock.calls).toMatchInlineSnapshot(` [ - { - "details": {}, - "name": "SECOND", - }, - { - "details": {}, - "name": "THIRD", - }, + [ + { + "name": "EV1", + }, + ], + [ + { + "name": "EV2", + }, + ], ] -`) - expect(cap2.report()).toMatchInlineSnapshot(` +`); + expect(reporter2.recordEvent.mock.calls).toMatchInlineSnapshot(` [ - { - "details": {}, - "name": "THIRD", - }, + [ + { + "name": "EV2", + }, + ], + [ + { + "name": "EV3", + }, + ], ] -`) -}) \ No newline at end of file +`); +}) From b1404ae4e12442e90306b1cca5421508267ce64f Mon Sep 17 00:00:00 2001 From: Jonathan Romano Date: Tue, 16 Sep 2025 16:27:23 -0400 Subject: [PATCH 06/21] Merge move tracking into o11y --- src/eterna/mode/PoseEdit/PoseEditMode.ts | 26 +++++++----------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/src/eterna/mode/PoseEdit/PoseEditMode.ts b/src/eterna/mode/PoseEdit/PoseEditMode.ts index 16c867d6b..115915702 100644 --- a/src/eterna/mode/PoseEdit/PoseEditMode.ts +++ b/src/eterna/mode/PoseEdit/PoseEditMode.ts @@ -547,7 +547,7 @@ export default class PoseEditMode extends GameMode { this.setSolutionTargetStructure(foldData); await this.poseEditByTarget(0); } - this.clearMoveTracking(solution.sequence.sequenceString()); + Eterna.observability.recordEvent('Move:StartSeq', solution.sequence.sequenceString()); this.setAncestorId(solution.nodeID); const annotations = solution.annotations; @@ -617,9 +617,8 @@ export default class PoseEditMode extends GameMode { const bindTrackMoves = (pose: Pose2D, _index: number) => { pose.trackMovesCallback = ((count: number, moves: Move[]) => { - this._moveCount += count; - if (moves) { - this._moves.push(moves.slice()); + if (moves.length) { + Eterna.observability.recordEvent('Move', {moves, count}); } }); }; @@ -1025,9 +1024,9 @@ export default class PoseEditMode extends GameMode { this.moveHistoryAddSequence('reset', newSeq.sequenceString()); } else { this._startSolvingTime = new Date().getTime(); - this._startingPoint = this._puzzle.transformSequence( + Eterna.observability.recordEvent('Move:StartSeq', this._puzzle.transformSequence( this.getCurrentUndoBlock(0).sequence, 0 - ).sequenceString(); + ).sequenceString()); } if (this._params.isReset) { @@ -3154,12 +3153,6 @@ export default class PoseEditMode extends GameMode { return true; } - private clearMoveTracking(seq: string): void { - this._startingPoint = seq; - this._moveCount = 0; - this._moves = []; - } - private moveHistoryAddMutations(before: Sequence, after: Sequence): void { const muts: Move[] = []; for (let ii = 0; ii < after.length; ii++) { @@ -3169,15 +3162,13 @@ export default class PoseEditMode extends GameMode { } if (muts.length === 0) return; - this._moveCount++; - this._moves.push(muts.slice()); + Eterna.observability.recordEvent('Move', {count: 1, moves: muts}); } private moveHistoryAddSequence(changeType: string, seq: string): void { const muts: Move[] = []; muts.push({type: changeType, sequence: seq}); - this._moveCount++; - this._moves.push(muts.slice()); + Eterna.observability.recordEvent('Move', {count: 1, moves: muts}); } private checkConstraints(soft: boolean = false): boolean { @@ -4411,9 +4402,6 @@ export default class PoseEditMode extends GameMode { private _alreadyCleared: boolean = false; private _paused: boolean; private _startSolvingTime: number; - private _startingPoint: string; - private _moveCount: number = 0; - private _moves: Move[][] = []; protected _curTargetIndex: number = 0; private _shouldMarkMutations: boolean = false; From e96c74146f04f2fe4732667970b6ad15666c2180 Mon Sep 17 00:00:00 2001 From: Jonathan Romano Date: Tue, 16 Sep 2025 16:35:06 -0400 Subject: [PATCH 07/21] abstract class --> interface, console reporter for debugging --- src/eterna/observability/ConsoleReporter.ts | 8 ++++++++ src/eterna/observability/ObservabilityReporter.ts | 4 ++-- src/eterna/observability/PostMessageReporter.ts | 4 +++- .../observability/__tests__/ObservabilityManager.test.ts | 2 +- 4 files changed, 14 insertions(+), 4 deletions(-) create mode 100644 src/eterna/observability/ConsoleReporter.ts diff --git a/src/eterna/observability/ConsoleReporter.ts b/src/eterna/observability/ConsoleReporter.ts new file mode 100644 index 000000000..9960f4967 --- /dev/null +++ b/src/eterna/observability/ConsoleReporter.ts @@ -0,0 +1,8 @@ +import log from 'loglevel'; +import ObservabilityReporter from './ObservabilityReporter'; + +export default class ConsoleReporter implements ObservabilityReporter { + public recordEvent(event: {name: string, details?: unknown}) { + log.debug('EVENT', event); + } +} diff --git a/src/eterna/observability/ObservabilityReporter.ts b/src/eterna/observability/ObservabilityReporter.ts index 377671eaa..c4cb53d65 100644 --- a/src/eterna/observability/ObservabilityReporter.ts +++ b/src/eterna/observability/ObservabilityReporter.ts @@ -1,3 +1,3 @@ -export default abstract class ObservabilityReporter { - abstract recordEvent(event: {name: string, details?: unknown}): void; +export default interface ObservabilityReporter { + recordEvent(event: {name: string, details?: unknown}): void; } diff --git a/src/eterna/observability/PostMessageReporter.ts b/src/eterna/observability/PostMessageReporter.ts index c795b7830..437668c01 100644 --- a/src/eterna/observability/PostMessageReporter.ts +++ b/src/eterna/observability/PostMessageReporter.ts @@ -1,4 +1,6 @@ -export default class PostMessageReporter { +import ObservabilityReporter from './ObservabilityReporter'; + +export default class PostMessageReporter implements ObservabilityReporter { constructor(id: string, targetOrigin?: string) { this._id = id; this._targetOrigin = targetOrigin; diff --git a/src/eterna/observability/__tests__/ObservabilityManager.test.ts b/src/eterna/observability/__tests__/ObservabilityManager.test.ts index c67e570ca..451fb996e 100644 --- a/src/eterna/observability/__tests__/ObservabilityManager.test.ts +++ b/src/eterna/observability/__tests__/ObservabilityManager.test.ts @@ -3,7 +3,7 @@ import ObservabilityManager from '../ObservabilityManager'; import ObservabilityReporter from '../ObservabilityReporter'; function createReporter() { - class NullReporter extends ObservabilityReporter { + class NullReporter implements ObservabilityReporter { recordEvent = jest.fn(); } return new NullReporter(); From f65c8f465e488598d2116f013a71cd5e7b569cad Mon Sep 17 00:00:00 2001 From: Jonathan Romano Date: Tue, 16 Sep 2025 16:36:39 -0400 Subject: [PATCH 08/21] Tweak log string --- src/eterna/observability/ConsoleReporter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/eterna/observability/ConsoleReporter.ts b/src/eterna/observability/ConsoleReporter.ts index 9960f4967..e0df16aae 100644 --- a/src/eterna/observability/ConsoleReporter.ts +++ b/src/eterna/observability/ConsoleReporter.ts @@ -3,6 +3,6 @@ import ObservabilityReporter from './ObservabilityReporter'; export default class ConsoleReporter implements ObservabilityReporter { public recordEvent(event: {name: string, details?: unknown}) { - log.debug('EVENT', event); + log.debug('O11Y EVENT', event); } } From b8db36772b9d15e807449f46ecee695416a9d600 Mon Sep 17 00:00:00 2001 From: Jonathan Romano Date: Tue, 16 Sep 2025 16:48:00 -0400 Subject: [PATCH 09/21] Prevent toolbaraction events from being double-fired when fired on clones, which propogate their events to the original button --- src/eterna/ui/toolbar/Toolbar.ts | 3 +++ src/eterna/ui/toolbar/ToolbarButton.ts | 5 ----- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/eterna/ui/toolbar/Toolbar.ts b/src/eterna/ui/toolbar/Toolbar.ts index 60ba7901c..a5a3d079e 100644 --- a/src/eterna/ui/toolbar/Toolbar.ts +++ b/src/eterna/ui/toolbar/Toolbar.ts @@ -652,6 +652,9 @@ export default class Toolbar extends ContainerObject { private setupButton(props: ToolbarParam, enabled: boolean = true): ToolbarButton { const button = ToolbarButton.createButton(props); + this.regs.add(button.clicked.connect(() => { + Eterna.observability.recordEvent(`ToolbarAction:${props.id}`); + })); if (enabled) { this._toolShelf.addButton(button); this.setupButtonDrag(button, 'shelf'); diff --git a/src/eterna/ui/toolbar/ToolbarButton.ts b/src/eterna/ui/toolbar/ToolbarButton.ts index 7af4b933f..790f4a428 100644 --- a/src/eterna/ui/toolbar/ToolbarButton.ts +++ b/src/eterna/ui/toolbar/ToolbarButton.ts @@ -7,7 +7,6 @@ import { Graphics, Sprite, Texture } from 'pixi.js'; import {Value} from 'signals'; -import Eterna from 'eterna/Eterna'; import GameButton from '../GameButton'; export const BUTTON_WIDTH = 55; @@ -136,10 +135,6 @@ export default class ToolbarButton extends GameButton { this._arrow.visible = toggled; this.drawBackground(toggled); })); - - this.regs.add(this.clicked.connect(() => { - Eterna.observability.recordEvent(`ToolbarAction:${this.id}`); - })); } private drawBackground(toggled: boolean) { From a3bab07986f0643d66cd22659402df054ac7b4c2 Mon Sep 17 00:00:00 2001 From: Jonathan Romano Date: Tue, 16 Sep 2025 16:55:10 -0400 Subject: [PATCH 10/21] Fix folder and marker layer firing on puzzle setup, fix folder event detail to use name instead of object --- src/eterna/mode/PoseEdit/PoseEditMode.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/eterna/mode/PoseEdit/PoseEditMode.ts b/src/eterna/mode/PoseEdit/PoseEditMode.ts index 115915702..b09c30184 100644 --- a/src/eterna/mode/PoseEdit/PoseEditMode.ts +++ b/src/eterna/mode/PoseEdit/PoseEditMode.ts @@ -582,7 +582,6 @@ export default class PoseEditMode extends GameMode { } private setMarkerLayer(layer: string) { - Eterna.observability.recordEvent('RunTool:MarkerLayer', {layer}); for (const pose of this._poses) { pose.setMarkerLayer(layer); } @@ -752,6 +751,9 @@ export default class PoseEditMode extends GameMode { this._markerSwitcher = this._modeBar.addMarkerSwitcher(); this.regs?.add(this._markerSwitcher.selectedLayer.connectNotify((val) => this.setMarkerLayer(val))); + this.regs?.add(this._markerSwitcher.selectedLayer.connect((layer) => { + Eterna.observability.recordEvent('RunTool:MarkerLayer', {layer}); + })); this._markerSwitcher.display.visible = false; this._modeBar.layout(); @@ -798,9 +800,9 @@ export default class PoseEditMode extends GameMode { ); this._constraintBar.display.visible = false; this.addObject(this._constraintBar, this._constraintsLayer); - this._constraintBar.sequenceHighlights.connect( + this.regs?.add(this._constraintBar.sequenceHighlights.connect( (highlightInfos: HighlightInfo[] | null) => this.highlightSequences(highlightInfos) - ); + )); // We can only set up the folderSwitcher once we have set up the poses // (and constraintBar, because by setting the folder on the pose, it triggers a fold @@ -810,14 +812,16 @@ export default class PoseEditMode extends GameMode { initialFolder, this._puzzle.puzzleType === PuzzleType.EXPERIMENTAL ); - this._folderSwitcher.selectedFolder.connectNotify((folder) => { - Eterna.observability.recordEvent('RunTool:ChangeFolder', {folder}); + this.regs?.add(this._folderSwitcher.selectedFolder.connectNotify((folder) => { for (const pose of this._poses) { pose.scoreFolder = folder; } this.onChangeFolder(); - }); + })); + this.regs?.add(this._folderSwitcher.selectedFolder.connect((folder) => { + Eterna.observability.recordEvent('RunTool:ChangeFolder', {folder: folder.name}); + })); // Initialize sequence and/or solution as relevant let initialSequence: Sequence | null = null; From 330c84570a8ee1338ef9a8bc6ca054ffaa5f4335 Mon Sep 17 00:00:00 2001 From: Jonathan Romano Date: Tue, 16 Sep 2025 17:10:38 -0400 Subject: [PATCH 11/21] o11y events for mode changes --- src/eterna/mode/DesignBrowser/DesignBrowserMode.ts | 1 + src/eterna/mode/ErrorDialogMode.ts | 5 +++++ src/eterna/mode/FeedbackViewMode.ts | 4 ++++ src/eterna/mode/PoseEdit/MissionIntroMode.ts | 1 + src/eterna/mode/PoseEdit/PoseEditMode.ts | 1 + src/eterna/mode/PuzzleEdit/PuzzleEditMode.ts | 4 ++++ 6 files changed, 16 insertions(+) diff --git a/src/eterna/mode/DesignBrowser/DesignBrowserMode.ts b/src/eterna/mode/DesignBrowser/DesignBrowserMode.ts index e6d8d5d32..8e82004e3 100644 --- a/src/eterna/mode/DesignBrowser/DesignBrowserMode.ts +++ b/src/eterna/mode/DesignBrowser/DesignBrowserMode.ts @@ -384,6 +384,7 @@ export default class DesignBrowserMode extends GameMode { protected enter(): void { super.enter(); + Eterna.observability.recordEvent('ModeEnter', {mode: 'DesignBrowser', puzzle: this._puzzle.nodeID}); this.refreshSolutions(); const {existingPoseEditMode} = Eterna.app; this._returnToGameButton.display.visible = ( diff --git a/src/eterna/mode/ErrorDialogMode.ts b/src/eterna/mode/ErrorDialogMode.ts index b2d7eb26f..0d12c3fce 100644 --- a/src/eterna/mode/ErrorDialogMode.ts +++ b/src/eterna/mode/ErrorDialogMode.ts @@ -5,6 +5,7 @@ import { import Fonts from 'eterna/util/Fonts'; import GameButton from 'eterna/ui/GameButton'; import GameWindow from 'eterna/ui/GameWindow'; +import Eterna from 'eterna/Eterna'; export default class ErrorDialogMode extends AppMode { public readonly error: Error | ErrorEvent; @@ -111,5 +112,9 @@ export default class ErrorDialogMode extends AppMode { this.regs.add(this.resized.connect(updateView)); } + protected enter() { + Eterna.observability.recordEvent('ModeEnter', {mode: 'ErrorDialog'}); + } + private readonly _title: string; } diff --git a/src/eterna/mode/FeedbackViewMode.ts b/src/eterna/mode/FeedbackViewMode.ts index 01ae585ad..ec213c361 100644 --- a/src/eterna/mode/FeedbackViewMode.ts +++ b/src/eterna/mode/FeedbackViewMode.ts @@ -261,6 +261,10 @@ export default class FeedbackViewMode extends GameMode { this.updateUILayout(); } + protected enter() { + Eterna.observability.recordEvent('ModeEnter', {mode: 'FeedbackView', puzzle: this._puzzle.nodeID}); + } + private setSolution(solution: Solution) { this._solution = solution; this._sequence = solution.sequence.slice(0); diff --git a/src/eterna/mode/PoseEdit/MissionIntroMode.ts b/src/eterna/mode/PoseEdit/MissionIntroMode.ts index faa7e5939..6afa6e66e 100644 --- a/src/eterna/mode/PoseEdit/MissionIntroMode.ts +++ b/src/eterna/mode/PoseEdit/MissionIntroMode.ts @@ -134,6 +134,7 @@ export default class MissionIntroMode extends AppMode { protected enter(): void { super.enter(); + Eterna.observability.recordEvent('ModeEnter', {mode: 'MissionIntro'}); Eterna.chat.pushHideChat(); } diff --git a/src/eterna/mode/PoseEdit/PoseEditMode.ts b/src/eterna/mode/PoseEdit/PoseEditMode.ts index b09c30184..41560b47e 100644 --- a/src/eterna/mode/PoseEdit/PoseEditMode.ts +++ b/src/eterna/mode/PoseEdit/PoseEditMode.ts @@ -264,6 +264,7 @@ export default class PoseEditMode extends GameMode { protected enter(): void { super.enter(); + Eterna.observability.recordEvent('ModeEnter', {mode: 'PoseEdit', puzzle: this._puzzle.nodeID}); this.hideAsyncText(); } diff --git a/src/eterna/mode/PuzzleEdit/PuzzleEditMode.ts b/src/eterna/mode/PuzzleEdit/PuzzleEditMode.ts index 52e37f31d..a600deeea 100644 --- a/src/eterna/mode/PuzzleEdit/PuzzleEditMode.ts +++ b/src/eterna/mode/PuzzleEdit/PuzzleEditMode.ts @@ -412,6 +412,10 @@ export default class PuzzleEditMode extends GameMode { } } + protected enter() { + Eterna.observability.recordEvent('ModeEnter', {mode: 'PuzzleEdit'}); + } + private canUseFolder(folder: Folder) { const pseudoknots = this._structureInputs.some( (input) => SecStruct.fromParens(input.structureString, true).onlyPseudoknots().nonempty() From 4fe69346392c254662cb0bdc3b6f4a73c518f800 Mon Sep 17 00:00:00 2001 From: Jonathan Romano Date: Tue, 23 Sep 2025 15:35:07 -0400 Subject: [PATCH 12/21] Add qualtrics reporter --- src/eterna/Eterna.ts | 2 +- src/eterna/mode/PoseEdit/PoseEditMode.ts | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/eterna/Eterna.ts b/src/eterna/Eterna.ts index 28fdf6776..31da58a1e 100644 --- a/src/eterna/Eterna.ts +++ b/src/eterna/Eterna.ts @@ -46,7 +46,7 @@ export default class Eterna { public static noGame: boolean; - public static experimentalFeatures: ('rnet-publishing')[]; + public static experimentalFeatures: ('rnet-publishing' | 'qualtrics-report')[]; public static setPlayer(name: string, id: number): void { this.playerName = name; diff --git a/src/eterna/mode/PoseEdit/PoseEditMode.ts b/src/eterna/mode/PoseEdit/PoseEditMode.ts index 587835758..c1cb7432c 100644 --- a/src/eterna/mode/PoseEdit/PoseEditMode.ts +++ b/src/eterna/mode/PoseEdit/PoseEditMode.ts @@ -84,6 +84,7 @@ import Dialog from 'eterna/ui/Dialog'; import WindowDialog from 'eterna/ui/WindowDialog'; import TLoopConstraint, {TLoopSeqB, TLoopSeqA, TLoopPairs} from 'eterna/constraints/constraints/TLoopConstraint'; import FoldingAPI from 'eterna/eternaScript/FoldingAPI'; +import PostMessageReporter from 'eterna/observability/PostMessageReporter'; import GameMode from '../GameMode'; import SubmittingDialog from './SubmittingDialog'; import SubmitPoseDialog from './SubmitPoseDialog'; @@ -264,10 +265,19 @@ export default class PoseEditMode extends GameMode { protected enter(): void { super.enter(); + if (Eterna.experimentalFeatures.includes('qualtrics-report')) { + Eterna.observability.startCapture(this._qualtricsReporter, (event) => !event.name.match(/^(ScriptFunc):/)); + } Eterna.observability.recordEvent('ModeEnter', {mode: 'PoseEdit', puzzle: this._puzzle.nodeID}); this.hideAsyncText(); } + protected exit(): void { + if (Eterna.experimentalFeatures.includes('qualtrics-report')) { + Eterna.observability.endCapture(this._qualtricsReporter); + } + } + public onResized(): void { this.updateUILayout(); super.onResized(); @@ -4467,5 +4477,10 @@ export default class PoseEditMode extends GameMode { private _lastStampedTLoopA = -1; private _lastStampedTLoopB = -1; + private _qualtricsReporter: PostMessageReporter = new PostMessageReporter( + 'qualtrics', + 'stanfordmedicine.yul1.qualtrics.com' + ); + private static readonly FOLDING_LOCK = 'Folding'; } From 32607563e46497484e4e167a2b8f9cc31b59f4cc Mon Sep 17 00:00:00 2001 From: Jonathan Romano Date: Tue, 23 Sep 2025 15:50:28 -0400 Subject: [PATCH 13/21] Fix broken return from script callbacks --- src/eterna/util/ExternalInterface.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/eterna/util/ExternalInterface.ts b/src/eterna/util/ExternalInterface.ts index d2d071a4d..d27054d55 100644 --- a/src/eterna/util/ExternalInterface.ts +++ b/src/eterna/util/ExternalInterface.ts @@ -20,7 +20,7 @@ export class ExternalInterfaceCtx { public addCallback(name: string, callback: AnyFunction): void { this.callbacks.set(name, (...args) => { Eterna.observability.recordEvent(`ScriptFunc:${name}`); - callback(...args); + return callback(...args); }); this.changed.emit(); } From e410c5ec00518e94388a953ab1dbcfbf4b76af78 Mon Sep 17 00:00:00 2001 From: Jonathan Romano Date: Tue, 23 Sep 2025 15:56:26 -0400 Subject: [PATCH 14/21] Add recording to scriptconstraint results --- src/eterna/constraints/constraints/ScriptConstraint.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/eterna/constraints/constraints/ScriptConstraint.ts b/src/eterna/constraints/constraints/ScriptConstraint.ts index 9e304d0d6..d96581c42 100644 --- a/src/eterna/constraints/constraints/ScriptConstraint.ts +++ b/src/eterna/constraints/constraints/ScriptConstraint.ts @@ -1,5 +1,6 @@ import ExternalInterface from 'eterna/util/ExternalInterface'; import {HighlightType} from 'eterna/pose2D/HighlightBox'; +import Eterna from 'eterna/Eterna'; import Constraint, {BaseConstraintStatus, HighlightInfo, ConstraintContext} from '../Constraint'; import ConstraintBox, {ConstraintBoxConfig} from '../ConstraintBox'; @@ -28,6 +29,12 @@ export default class ScriptConstraint extends Constraint throw new Error(`SCRIPT constraint ${this.scriptID} failed with error: ${result.cause}`); } + Eterna.observability.recordEvent(`ConstraintResult:Script:${this.scriptID}`, { + // We include the goal because the OpenVaccine constraint includes additional statistics there + goal: result.cause.goal, + value: result.cause.value + }); + return { goal: result.cause.goal != null ? result.cause.goal : '', resultValue: result.cause.value != null ? result.cause.value : '', From cee5d7f09ee8bd062d5a15ec416c14d600227bda Mon Sep 17 00:00:00 2001 From: Jonathan Romano Date: Wed, 24 Sep 2025 17:14:51 -0400 Subject: [PATCH 15/21] Timer constraint --- assets/stopwatch.svg | 1 + src/eterna/constraints/Constraint.ts | 1 + src/eterna/constraints/ConstraintBox.ts | 12 +-- .../constraints/TimerConstraint.ts | 83 +++++++++++++++++++ src/eterna/puzzle/PuzzleManager.ts | 4 + src/eterna/resources/Bitmaps.ts | 1 + 6 files changed, 96 insertions(+), 6 deletions(-) create mode 100644 assets/stopwatch.svg create mode 100644 src/eterna/constraints/constraints/TimerConstraint.ts diff --git a/assets/stopwatch.svg b/assets/stopwatch.svg new file mode 100644 index 000000000..f520fa97a --- /dev/null +++ b/assets/stopwatch.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/eterna/constraints/Constraint.ts b/src/eterna/constraints/Constraint.ts index e2aa9680a..1a9b70617 100644 --- a/src/eterna/constraints/Constraint.ts +++ b/src/eterna/constraints/Constraint.ts @@ -21,6 +21,7 @@ export interface ConstraintContext { targetConditions?: (TargetConditions | undefined)[]; puzzle?: Puzzle; scriptConstraintCtx?: ExternalInterfaceCtx; + elapsed?: number; } export default abstract class Constraint { diff --git a/src/eterna/constraints/ConstraintBox.ts b/src/eterna/constraints/ConstraintBox.ts index 235f0ee6a..125ce1442 100644 --- a/src/eterna/constraints/ConstraintBox.ts +++ b/src/eterna/constraints/ConstraintBox.ts @@ -23,6 +23,8 @@ export interface ConstraintBoxConfig { tooltip: string | StyledTextBuilder; // Show the green/red outline showOutline?: boolean; + // Show the checkmark when satisfied + showCheck?: boolean; // Used when the constraint image includes a background // Due to a type constraint from Pixi, we need this to be nullable, not optional fullTexture?: Texture; @@ -144,7 +146,7 @@ export default class ConstraintBox extends ContainerObject implements Enableable } public setContent(config: ConstraintBoxConfig, toolTipContainer?: Container): void { - this._check.visible = config.satisfied && !this._forMissionScreen; + this._check.visible = config.satisfied && !this._forMissionScreen && config.showCheck !== false; // If clarificationText is a string we're fine; if it's a StyledTextBuilder // we need to get .text from it to check this. @@ -172,12 +174,10 @@ export default class ConstraintBox extends ContainerObject implements Enableable this.initOpaqueBackdrop(config.fullTexture.width, config.fullTexture.height); } + this._outline.texture = config.satisfied + ? BitmapManager.getBitmap(Bitmaps.NovaPassOutline) + : BitmapManager.getBitmap(Bitmaps.NovaFailOutline); this._outline.visible = config.showOutline || false; - if (this._outline.visible) { - this._outline.texture = config.satisfied - ? BitmapManager.getBitmap(Bitmaps.NovaPassOutline) - : BitmapManager.getBitmap(Bitmaps.NovaFailOutline); - } this._reqClarifyText.visible = config.clarificationText !== undefined; if (config.clarificationText !== undefined) { diff --git a/src/eterna/constraints/constraints/TimerConstraint.ts b/src/eterna/constraints/constraints/TimerConstraint.ts new file mode 100644 index 000000000..a1703cf2a --- /dev/null +++ b/src/eterna/constraints/constraints/TimerConstraint.ts @@ -0,0 +1,83 @@ +import {Container, Sprite, Texture} from 'pixi.js'; +import Bitmaps from 'eterna/resources/Bitmaps'; +import {TextureUtil} from 'flashbang'; +import ConstraintBox, {ConstraintBoxConfig} from '../ConstraintBox'; +import Constraint, {BaseConstraintStatus, ConstraintContext} from '../Constraint'; + +interface TimerConstraintStatus extends BaseConstraintStatus { + timeRemaining: number; +} + +export default class TimerConstraint extends Constraint { + public static readonly NAME = 'TIMERMIN'; + // SOFT constraints are intended to allow out-of-spec solutions. However, + // this constraint is intended not as part of the spec, but as a limitation + // of the puzzle solving process. Concretely, this constraint was built for user studies, + // in which case we may not need the user to solve everything, but if they havent finished + // their time yet, we can't solve + public readonly hard: boolean = true; + public readonly timeLimit: number; + + constructor(timeLimit: number) { + super(); + this.timeLimit = timeLimit; + } + + public evaluate(context: ConstraintContext): TimerConstraintStatus { + const timeRemaining = Math.max( + // In puzzlemaker, the elapsed time doesn't really make sense + // in the current setup, as solving time is conflated with + // design time. That said even if we un-conflated it to verify the + // puzzle can be solved within the limit, it's liable to result in + // realistically-unsolvable puzzles anyways (the player already knows the + // solution so they can solve it much faster than a fresh solver), plus + // a player might just save their results elsewhere and resume... + // If we ever use this for time trials as opposed to just research + // studies we'd need to think through all that. + this.timeLimit - (context.elapsed ?? 0), + 0 + ); + + return { + satisfied: timeRemaining <= 0, + timeRemaining + }; + } + + public getConstraintBoxConfig( + status: TimerConstraintStatus + ): ConstraintBoxConfig { + return { + satisfied: status.satisfied, + drawBG: true, + tooltip: ConstraintBox.createTextStyle().append(`You have ${this.timeLimit} seconds to complete this puzzle`), + clarificationText: `${this.timeLimit} SECONDS`, + statText: status.timeRemaining.toString(), + icon: TimerConstraint._icon, + // This isn't really something that needs to be "satisfied" perse, + // so the outline/check indication is liable to be confusing. + // When the time limit passes, calling code is responsible for + // halting the puzzle and doing whatever is necessary + showOutline: false, + showCheck: false + }; + } + + public serialize(): [string, string] { + return [ + TimerConstraint.NAME, + this.timeLimit.toString() + ]; + } + + private static get _icon(): Texture { + const icon = new Container(); + + const img = new Sprite(Texture.from(Bitmaps.ImgStopwatch, {resourceOptions: {scale: 2}})); + img.scale.x = 0.4; + img.scale.y = 0.4; + icon.addChild(img); + + return TextureUtil.renderToTexture(icon); + } +} diff --git a/src/eterna/puzzle/PuzzleManager.ts b/src/eterna/puzzle/PuzzleManager.ts index 01abd47eb..43c2c4a84 100644 --- a/src/eterna/puzzle/PuzzleManager.ts +++ b/src/eterna/puzzle/PuzzleManager.ts @@ -48,6 +48,7 @@ import { TargetPKConfidenceConstraint } from 'eterna/constraints/constraints/ConfidenceConstraint'; import TLoopConstraint from 'eterna/constraints/constraints/TLoopConstraint'; +import TimerConstraint from 'eterna/constraints/constraints/TimerConstraint'; import SolutionManager from './SolutionManager'; import Puzzle, {PuzzleType} from './Puzzle'; @@ -390,6 +391,9 @@ export default class PuzzleManager { case TLoopConstraint.NAME: constraints.push(new TLoopConstraint()); break; + case TimerConstraint.NAME: + constraints.push(new TimerConstraint(Number(parameter))); + break; default: log.warn(`Unknown constraint ${name} - skipping`); } diff --git a/src/eterna/resources/Bitmaps.ts b/src/eterna/resources/Bitmaps.ts index 6b1ced363..c308cf94f 100644 --- a/src/eterna/resources/Bitmaps.ts +++ b/src/eterna/resources/Bitmaps.ts @@ -494,6 +494,7 @@ export default class Bitmaps { public static readonly ImgDlgClose: string = new URL('assets/UI/close.svg', import.meta.url).href; public static readonly ImgOverDlgClose: string = new URL('assets/UI/close-over.svg', import.meta.url).href; + public static readonly ImgStopwatch: string = new URL('assets/stopwatch.svg', import.meta.url).href; public static get all(): string[] { if (Bitmaps.ALL_URLS == null) { From 2ebd321a7c693268549ffc94ae48640087067541 Mon Sep 17 00:00:00 2001 From: Jonathan Romano Date: Thu, 25 Sep 2025 13:26:49 -0400 Subject: [PATCH 16/21] Record solution submission, skip upload for qualtrics report --- src/eterna/mode/PoseEdit/PoseEditMode.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/eterna/mode/PoseEdit/PoseEditMode.ts b/src/eterna/mode/PoseEdit/PoseEditMode.ts index c1cb7432c..ec3162953 100644 --- a/src/eterna/mode/PoseEdit/PoseEditMode.ts +++ b/src/eterna/mode/PoseEdit/PoseEditMode.ts @@ -2665,7 +2665,11 @@ export default class PoseEditMode extends GameMode { let data: SubmitSolutionData; - if ( + Eterna.observability.recordEvent('SubmitSolution', this.createSubmitData(details, undoBlock)); + if (Eterna.experimentalFeatures.includes('qualtrics-report')) { + this.showMissionClearedPanel(null, false); + return; + } if ( !this._puzzle.alreadySolved || this._puzzle.puzzleType === PuzzleType.EXPERIMENTAL || this._puzzle.rscript !== '' @@ -3191,7 +3195,8 @@ export default class PoseEditMode extends GameMode { undoBlocks: this._seqStacks[this._stackLevel], targetConditions: this._targetConditions, puzzle: this._puzzle, - scriptConstraintCtx: this._scriptConstraintContext + scriptConstraintCtx: this._scriptConstraintContext, + elapsed: new Date().getTime() - this._startSolvingTime }, soft); } From ea706e1139474b4d4baf292067cd122cf2c495a7 Mon Sep 17 00:00:00 2001 From: Jonathan Romano Date: Thu, 25 Sep 2025 15:16:54 -0400 Subject: [PATCH 17/21] Trigger updates for timer constraint --- .../constraints/TimerConstraint.ts | 4 +-- src/eterna/mode/PoseEdit/PoseEditMode.ts | 36 ++++++++++++++++++- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/eterna/constraints/constraints/TimerConstraint.ts b/src/eterna/constraints/constraints/TimerConstraint.ts index a1703cf2a..946ac6ce7 100644 --- a/src/eterna/constraints/constraints/TimerConstraint.ts +++ b/src/eterna/constraints/constraints/TimerConstraint.ts @@ -34,7 +34,7 @@ export default class TimerConstraint extends Constraint { // a player might just save their results elsewhere and resume... // If we ever use this for time trials as opposed to just research // studies we'd need to think through all that. - this.timeLimit - (context.elapsed ?? 0), + (this.timeLimit * 1000) - ((context.elapsed ?? 0)), 0 ); @@ -52,7 +52,7 @@ export default class TimerConstraint extends Constraint { drawBG: true, tooltip: ConstraintBox.createTextStyle().append(`You have ${this.timeLimit} seconds to complete this puzzle`), clarificationText: `${this.timeLimit} SECONDS`, - statText: status.timeRemaining.toString(), + statText: (status.timeRemaining / 1000).toFixed(0), icon: TimerConstraint._icon, // This isn't really something that needs to be "satisfied" perse, // so the outline/check indication is liable to be confusing. diff --git a/src/eterna/mode/PoseEdit/PoseEditMode.ts b/src/eterna/mode/PoseEdit/PoseEditMode.ts index ec3162953..8c0de00d9 100644 --- a/src/eterna/mode/PoseEdit/PoseEditMode.ts +++ b/src/eterna/mode/PoseEdit/PoseEditMode.ts @@ -19,7 +19,11 @@ import GameButton from 'eterna/ui/GameButton'; import Bitmaps from 'eterna/resources/Bitmaps'; import { KeyCode, SpriteObject, DisplayUtil, HAlign, VAlign, Flashbang, KeyboardEventType, Assert, - GameObjectRef, SerialTask, AlphaTask, Easing, SelfDestructTask, ContainerObject, ErrorUtil + GameObjectRef, SerialTask, AlphaTask, Easing, SelfDestructTask, ContainerObject, ErrorUtil, + RepeatingTask, + DelayTask, + FunctionTask, + CallbackTask } from 'flashbang'; import Fonts from 'eterna/util/Fonts'; import EternaSettingsDialog, {EternaViewOptionsMode} from 'eterna/ui/EternaSettingsDialog'; @@ -85,6 +89,7 @@ import WindowDialog from 'eterna/ui/WindowDialog'; import TLoopConstraint, {TLoopSeqB, TLoopSeqA, TLoopPairs} from 'eterna/constraints/constraints/TLoopConstraint'; import FoldingAPI from 'eterna/eternaScript/FoldingAPI'; import PostMessageReporter from 'eterna/observability/PostMessageReporter'; +import TimerConstraint from 'eterna/constraints/constraints/TimerConstraint'; import GameMode from '../GameMode'; import SubmittingDialog from './SubmittingDialog'; import SubmitPoseDialog from './SubmitPoseDialog'; @@ -1058,6 +1063,35 @@ export default class PoseEditMode extends GameMode { this.setPip(Eterna.settings.pipEnabled.value); this.ropPresets(); + + // If we have a timer constraint, we need to trigger a state update + // (namely, constraint update) not just when user interaction happens, but + // over time. We only due this when the constraint is present so we don't do + // a bunch of unnecessary work + if (this._puzzle.constraints?.find((constraint) => constraint instanceof TimerConstraint)) { + this.addObject(new RepeatingTask(() => { + let poseOpComplete = false; + return new SerialTask( + // Once a second should be responsive enough without incurring unnecessary + // performance cost from having to re-sync all the other state and + // constraint checks, etc. (This is unscientific) + new DelayTask(1), + // We push this to the opqueue to ensure we aren't triggering a state + // resync while folding operations are half-complete + new CallbackTask(() => { + this._opQueue.push(new PoseOp(null, () => { + this.updateScore(); + poseOpComplete = true; + })); + }), + // We wait until the sync has completed before we continue on to the next + // interation of this repeaing task in order to prevent multiple syncs being + // queued up faster than we can process them in some unfortunate situation where + // things take forever + new FunctionTask(() => poseOpComplete) + ); + })); + } } )); } From 5c718a6fc6feab740e2c7d0b512eea1a1dbf468e Mon Sep 17 00:00:00 2001 From: Jonathan Romano Date: Thu, 25 Sep 2025 15:37:30 -0400 Subject: [PATCH 18/21] Avoid reevaluating constraints due to only time update In particular we want to avoid re-running eternascript constraints, which could be doing who knows what. In addition to unknown expectations they may have around only being called on certain user interactions, we also want to avoid setting an expectation that they'll be called regularly because that seems ripe for poor author decisions and later breakakge if we change something --- src/eterna/constraints/ConstraintBar.ts | 43 +++++++++++++++++-------- 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/src/eterna/constraints/ConstraintBar.ts b/src/eterna/constraints/ConstraintBar.ts index 0424f9093..c21a7f46b 100644 --- a/src/eterna/constraints/ConstraintBar.ts +++ b/src/eterna/constraints/ConstraintBar.ts @@ -16,6 +16,7 @@ import GraphicsObject from 'flashbang/objects/GraphicsObject'; import ShapeConstraint, {AntiShapeConstraint} from './constraints/ShapeConstraint'; import ConstraintBox from './ConstraintBox'; import Constraint, {BaseConstraintStatus, HighlightInfo, ConstraintContext} from './Constraint'; +import TimerConstraint from './constraints/TimerConstraint'; interface ConstraintWrapper { constraint: Constraint; @@ -321,19 +322,33 @@ export default class ConstraintBar extends ContainerObject { let satisfied = true; for (const constraint of this._constraints) { - const status = constraint.constraint.evaluate(context); - constraint.constraintBox.setContent( - constraint.constraint.getConstraintBoxConfig( - status, - false, - context.undoBlocks, - context.targetConditions - ), - this._constraintsTooltips - ); - constraint.evalCache = [status, false, context.undoBlocks, context.targetConditions]; - constraint.highlightCache = status.satisfied - ? null : constraint.constraint.getHighlight(status, context); + let status; + // For constraints that dont rely on the elapsed time, dont reevaluate if + // no other properties have changed + if ( + !constraint.evalCache + || constraint.constraint instanceof TimerConstraint + || !(Object.entries(context) as Array<[keyof ConstraintContext, unknown]>).every(( + ([key, val]) => key === 'elapsed' + || this._lastConstraintContext?.[key] === val + )) + ) { + status = constraint.constraint.evaluate(context); + constraint.constraintBox.setContent( + constraint.constraint.getConstraintBoxConfig( + status, + false, + context.undoBlocks, + context.targetConditions + ), + this._constraintsTooltips + ); + constraint.evalCache = [status, false, context.undoBlocks, context.targetConditions]; + constraint.highlightCache = status.satisfied + ? null : constraint.constraint.getHighlight(status, context); + } else { + status = constraint.evalCache[0]; + } // Hack to allow certain constraints to be required to be met even if the SOFT // constraint would otherwise mean no constraint is required. Really we should allow @@ -342,6 +357,7 @@ export default class ConstraintBar extends ContainerObject { const isSoft = soft && !constraint.constraint.hard; satisfied = satisfied && (status.satisfied || isSoft); } + this._lastConstraintContext = context; this.updateHighlights(); @@ -518,4 +534,5 @@ export default class ConstraintBar extends ContainerObject { private _constraints: ConstraintWrapper[]; private _flaggedConstraint: ConstraintWrapper | null; + private _lastConstraintContext: ConstraintContext | null; } From 65ccc793f4db0f55da5e5a6fe2cd6409f09f54d6 Mon Sep 17 00:00:00 2001 From: Jonathan Romano Date: Thu, 25 Sep 2025 16:24:45 -0400 Subject: [PATCH 19/21] Prevent async text flashing This was due to two things: 1) Using the opqueue to trigger updatescore 2) In updatescore, we also use the opqueue for updating the melting point and dot plot for the specbox We only actually need to recheck constraints, so this also is a bit of an optimization --- src/eterna/mode/PoseEdit/PoseEditMode.ts | 33 +++++++++++++++++------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/src/eterna/mode/PoseEdit/PoseEditMode.ts b/src/eterna/mode/PoseEdit/PoseEditMode.ts index 8c0de00d9..925ab0aff 100644 --- a/src/eterna/mode/PoseEdit/PoseEditMode.ts +++ b/src/eterna/mode/PoseEdit/PoseEditMode.ts @@ -1077,12 +1077,22 @@ export default class PoseEditMode extends GameMode { // constraint checks, etc. (This is unscientific) new DelayTask(1), // We push this to the opqueue to ensure we aren't triggering a state - // resync while folding operations are half-complete + // resync while folding operations are half-complete (checkSolved + // updates undoblock) new CallbackTask(() => { - this._opQueue.push(new PoseOp(null, () => { - this.updateScore(); + if (this._opQueue.length > 0) { + this._opQueue.push(new PoseOp(null, () => { + this.checkSolved(); + poseOpComplete = true; + })); + } else { + // If we know we're not racing with some other operation, + // we run this immediately rather than running through the + // opqueue to prevent the "folding..." message from showing + // for a brief period of time/"flickering" + this.checkSolved(); poseOpComplete = true; - })); + } }), // We wait until the sync has completed before we continue on to the next // interation of this repeaing task in order to prevent multiple syncs being @@ -3446,16 +3456,21 @@ export default class PoseEditMode extends GameMode { this._toolbar.redoButton.enabled = !(this._stackLevel + 1 > this._stackSize - 1); } - const constraintsSatisfied: boolean = this.checkConstraints(); - for (let ii = 0; ii < this._poses.length; ii++) { - this.getCurrentUndoBlock(ii).stable = constraintsSatisfied; - } - // Update open dialogs this.updateSpecBox(); this.updateCopySequenceDialog(); this.updateCopyStructureDialog(); + // Reevaluate constraints and submit if solved (and we want to autosubmit) + this.checkSolved(); + } + + private checkSolved() { + const constraintsSatisfied: boolean = this.checkConstraints(); + for (let ii = 0; ii < this._poses.length; ii++) { + this.getCurrentUndoBlock(ii).stable = constraintsSatisfied; + } + if ( (constraintsSatisfied && this._rscript.done) || (this._puzzle.alreadySolved && this._puzzle.rscript === '') From 742ccfe246bfbaa14e0a1bf0629a8ac192e81758 Mon Sep 17 00:00:00 2001 From: Jonathan Romano Date: Thu, 25 Sep 2025 16:37:37 -0400 Subject: [PATCH 20/21] Supress glow and sound for timer constraint satisfied state change --- src/eterna/constraints/ConstraintBox.ts | 23 +++++++++++-------- .../constraints/TimerConstraint.ts | 3 +-- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/eterna/constraints/ConstraintBox.ts b/src/eterna/constraints/ConstraintBox.ts index 125ce1442..e4501de13 100644 --- a/src/eterna/constraints/ConstraintBox.ts +++ b/src/eterna/constraints/ConstraintBox.ts @@ -23,8 +23,9 @@ export interface ConstraintBoxConfig { tooltip: string | StyledTextBuilder; // Show the green/red outline showOutline?: boolean; - // Show the checkmark when satisfied - showCheck?: boolean; + // Whether to enable satisfied indicators (outline and check, glow and sound on change) + // Default true, overrides showOutline if set + satisfiedIndicators?: boolean; // Used when the constraint image includes a background // Due to a type constraint from Pixi, we need this to be nullable, not optional fullTexture?: Texture; @@ -146,7 +147,7 @@ export default class ConstraintBox extends ContainerObject implements Enableable } public setContent(config: ConstraintBoxConfig, toolTipContainer?: Container): void { - this._check.visible = config.satisfied && !this._forMissionScreen && config.showCheck !== false; + this._check.visible = config.satisfied && !this._forMissionScreen && config.satisfiedIndicators !== false; // If clarificationText is a string we're fine; if it's a StyledTextBuilder // we need to get .text from it to check this. @@ -177,7 +178,7 @@ export default class ConstraintBox extends ContainerObject implements Enableable this._outline.texture = config.satisfied ? BitmapManager.getBitmap(Bitmaps.NovaPassOutline) : BitmapManager.getBitmap(Bitmaps.NovaFailOutline); - this._outline.visible = config.showOutline || false; + this._outline.visible = config.satisfiedIndicators ?? config.showOutline ?? false; this._reqClarifyText.visible = config.clarificationText !== undefined; if (config.clarificationText !== undefined) { @@ -276,12 +277,14 @@ export default class ConstraintBox extends ContainerObject implements Enableable this._noText.visible = config.noText || false; - if (config.satisfied && !this._satisfied) { - Flashbang.sound.playSound(Sounds.SoundCondition); - this.flare(true); - } else if (!config.satisfied && this._satisfied) { - Flashbang.sound.playSound(Sounds.SoundDecondition); - this.flare(false); + if (config.satisfiedIndicators !== false) { + if (config.satisfied && !this._satisfied) { + Flashbang.sound.playSound(Sounds.SoundCondition); + this.flare(true); + } else if (!config.satisfied && this._satisfied) { + Flashbang.sound.playSound(Sounds.SoundDecondition); + this.flare(false); + } } this._satisfied = config.satisfied; diff --git a/src/eterna/constraints/constraints/TimerConstraint.ts b/src/eterna/constraints/constraints/TimerConstraint.ts index 946ac6ce7..96a2624ca 100644 --- a/src/eterna/constraints/constraints/TimerConstraint.ts +++ b/src/eterna/constraints/constraints/TimerConstraint.ts @@ -58,8 +58,7 @@ export default class TimerConstraint extends Constraint { // so the outline/check indication is liable to be confusing. // When the time limit passes, calling code is responsible for // halting the puzzle and doing whatever is necessary - showOutline: false, - showCheck: false + satisfiedIndicators: false }; } From 631c21abe60ef99e6201dfca0272a4553b8de658 Mon Sep 17 00:00:00 2001 From: Jonathan Romano Date: Thu, 25 Sep 2025 16:44:13 -0400 Subject: [PATCH 21/21] Support soft constraints for non-experimental puzzles --- src/eterna/mode/PoseEdit/PoseEditMode.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/eterna/mode/PoseEdit/PoseEditMode.ts b/src/eterna/mode/PoseEdit/PoseEditMode.ts index 925ab0aff..3142ac47b 100644 --- a/src/eterna/mode/PoseEdit/PoseEditMode.ts +++ b/src/eterna/mode/PoseEdit/PoseEditMode.ts @@ -3466,13 +3466,14 @@ export default class PoseEditMode extends GameMode { } private checkSolved() { - const constraintsSatisfied: boolean = this.checkConstraints(); + const constraintsSatisfied = this.checkConstraints(); for (let ii = 0; ii < this._poses.length; ii++) { this.getCurrentUndoBlock(ii).stable = constraintsSatisfied; } + const submittable = this.checkConstraints(this._puzzle.isSoftConstraint); if ( - (constraintsSatisfied && this._rscript.done) + (submittable && this._rscript.done) || (this._puzzle.alreadySolved && this._puzzle.rscript === '') ) { if (this._puzzle.puzzleType !== PuzzleType.EXPERIMENTAL && !this._alreadyCleared) {