diff --git a/assets/stopwatch.svg b/assets/stopwatch.svg new file mode 100644 index 00000000..f520fa97 --- /dev/null +++ b/assets/stopwatch.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/eterna/Eterna.ts b/src/eterna/Eterna.ts index b027413f..31da58a1 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,13 +39,14 @@ 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; 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/EternaApp.ts b/src/eterna/EternaApp.ts index 0b0aa825..fd05411b 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, @@ -211,6 +212,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/constraints/Constraint.ts b/src/eterna/constraints/Constraint.ts index e2aa9680..1a9b7061 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/ConstraintBar.ts b/src/eterna/constraints/ConstraintBar.ts index 0424f909..c21a7f46 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; } diff --git a/src/eterna/constraints/ConstraintBox.ts b/src/eterna/constraints/ConstraintBox.ts index 235f0ee6..e4501de1 100644 --- a/src/eterna/constraints/ConstraintBox.ts +++ b/src/eterna/constraints/ConstraintBox.ts @@ -23,6 +23,9 @@ export interface ConstraintBoxConfig { tooltip: string | StyledTextBuilder; // Show the green/red outline showOutline?: 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; @@ -144,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; + 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. @@ -172,12 +175,10 @@ export default class ConstraintBox extends ContainerObject implements Enableable this.initOpaqueBackdrop(config.fullTexture.width, config.fullTexture.height); } - this._outline.visible = config.showOutline || false; - if (this._outline.visible) { - this._outline.texture = config.satisfied - ? BitmapManager.getBitmap(Bitmaps.NovaPassOutline) - : BitmapManager.getBitmap(Bitmaps.NovaFailOutline); - } + this._outline.texture = config.satisfied + ? BitmapManager.getBitmap(Bitmaps.NovaPassOutline) + : BitmapManager.getBitmap(Bitmaps.NovaFailOutline); + 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/ScriptConstraint.ts b/src/eterna/constraints/constraints/ScriptConstraint.ts index 9e304d0d..d96581c4 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 : '', diff --git a/src/eterna/constraints/constraints/TimerConstraint.ts b/src/eterna/constraints/constraints/TimerConstraint.ts new file mode 100644 index 00000000..96a2624c --- /dev/null +++ b/src/eterna/constraints/constraints/TimerConstraint.ts @@ -0,0 +1,82 @@ +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 * 1000) - ((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 / 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. + // When the time limit passes, calling code is responsible for + // halting the puzzle and doing whatever is necessary + satisfiedIndicators: 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/mode/DesignBrowser/DesignBrowserMode.ts b/src/eterna/mode/DesignBrowser/DesignBrowserMode.ts index e6d8d5d3..8e82004e 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 b2d7eb26..0d12c3fc 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 01ae585a..ec213c36 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/GameMode.ts b/src/eterna/mode/GameMode.ts index 7e48bb17..9a199bdf 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)) @@ -688,6 +691,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 +746,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 fe462f86..f068d4ce 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/MissionIntroMode.ts b/src/eterna/mode/PoseEdit/MissionIntroMode.ts index faa7e593..6afa6e66 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 1188abd8..3142ac47 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'; @@ -84,6 +88,8 @@ 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 TimerConstraint from 'eterna/constraints/constraints/TimerConstraint'; import GameMode from '../GameMode'; import SubmittingDialog from './SubmittingDialog'; import SubmitPoseDialog from './SubmitPoseDialog'; @@ -264,9 +270,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(); @@ -458,7 +474,13 @@ 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 { + Eterna.observability.recordEvent('RunTool:Hint'); if (this._hintBoxRef.isLive) { this._hintBoxRef.destroyObject(); } else { @@ -468,6 +490,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( @@ -540,6 +563,7 @@ export default class PoseEditMode extends GameMode { this.setSolutionTargetStructure(foldData); await this.poseEditByTarget(0); } + Eterna.observability.recordEvent('Move:StartSeq', solution.sequence.sequenceString()); this.setAncestorId(solution.nodeID); const annotations = solution.annotations; @@ -606,6 +630,14 @@ export default class PoseEditMode extends GameMode { }); }; + const bindTrackMoves = (pose: Pose2D, _index: number) => { + pose.trackMovesCallback = ((count: number, moves: Move[]) => { + if (moves.length) { + Eterna.observability.recordEvent('Move', {moves, count}); + } + }); + }; + const bindMousedownEvent = (pose: Pose2D, index: number) => { pose.startMousedownCallback = ((e: FederatedPointerEvent, _closestDist: number, closestIndex: number) => { for (let ii = 0; ii < poseFields.length; ++ii) { @@ -637,6 +669,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); } @@ -734,6 +767,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(); @@ -780,9 +816,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 @@ -792,13 +828,16 @@ export default class PoseEditMode extends GameMode { initialFolder, this._puzzle.puzzleType === PuzzleType.EXPERIMENTAL ); - this._folderSwitcher.selectedFolder.connectNotify((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; @@ -1000,8 +1039,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(); + Eterna.observability.recordEvent('Move:StartSeq', this._puzzle.transformSequence( + this.getCurrentUndoBlock(0).sequence, 0 + ).sequenceString()); } if (this._params.isReset) { @@ -1018,6 +1063,45 @@ 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 (checkSolved + // updates undoblock) + new CallbackTask(() => { + 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 + // queued up faster than we can process them in some unfortunate situation where + // things take forever + new FunctionTask(() => poseOpComplete) + ); + })); + } } )); } @@ -1600,6 +1684,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; } @@ -2624,7 +2709,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 !== '' @@ -3127,12 +3216,31 @@ export default class PoseEditMode extends GameMode { return true; } + 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; + Eterna.observability.recordEvent('Move', {count: 1, moves: muts}); + } + + private moveHistoryAddSequence(changeType: string, seq: string): void { + const muts: Move[] = []; + muts.push({type: changeType, sequence: seq}); + Eterna.observability.recordEvent('Move', {count: 1, moves: muts}); + } + private checkConstraints(soft: boolean = false): boolean { return this._constraintBar.updateConstraints({ undoBlocks: this._seqStacks[this._stackLevel], targetConditions: this._targetConditions, puzzle: this._puzzle, - scriptConstraintCtx: this._scriptConstraintContext + scriptConstraintCtx: this._scriptConstraintContext, + elapsed: new Date().getTime() - this._startSolvingTime }, soft); } @@ -3348,18 +3456,24 @@ 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 = 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) { @@ -4237,9 +4351,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(); } @@ -4250,9 +4369,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(); } @@ -4260,11 +4384,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; @@ -4400,5 +4532,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'; } diff --git a/src/eterna/mode/PuzzleEdit/PuzzleEditMode.ts b/src/eterna/mode/PuzzleEdit/PuzzleEditMode.ts index 52e37f31..a600deee 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() diff --git a/src/eterna/observability/ConsoleReporter.ts b/src/eterna/observability/ConsoleReporter.ts new file mode 100644 index 00000000..e0df16aa --- /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('O11Y EVENT', event); + } +} diff --git a/src/eterna/observability/ObservabilityManager.ts b/src/eterna/observability/ObservabilityManager.ts new file mode 100644 index 00000000..c9ba6491 --- /dev/null +++ b/src/eterna/observability/ObservabilityManager.ts @@ -0,0 +1,30 @@ +import ObservabilityReporter from './ObservabilityReporter'; + +interface ObservabilityCapture { + reporter: ObservabilityReporter; + filter?: (event: {name: string, details?: unknown}) => boolean; +} + +export default class ObservabilityManager { + public startCapture( + reporter: ObservabilityReporter, + filter?: ObservabilityCapture['filter'] + ) { + this._captures.push({reporter, filter}); + } + + public endCapture(reporter: ObservabilityReporter) { + this._captures = this._captures.filter((capture) => capture.reporter !== reporter); + } + + public recordEvent(name: string, details?: unknown) { + 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}); + } + } + } + + private _captures: ObservabilityCapture[] = []; +} diff --git a/src/eterna/observability/ObservabilityReporter.ts b/src/eterna/observability/ObservabilityReporter.ts new file mode 100644 index 00000000..c4cb53d6 --- /dev/null +++ b/src/eterna/observability/ObservabilityReporter.ts @@ -0,0 +1,3 @@ +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 new file mode 100644 index 00000000..437668c0 --- /dev/null +++ b/src/eterna/observability/PostMessageReporter.ts @@ -0,0 +1,19 @@ +import ObservabilityReporter from './ObservabilityReporter'; + +export default class PostMessageReporter implements ObservabilityReporter { + 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 new file mode 100644 index 00000000..451fb996 --- /dev/null +++ b/src/eterna/observability/__tests__/ObservabilityManager.test.ts @@ -0,0 +1,120 @@ +import {jest} from '@jest/globals' +import ObservabilityManager from '../ObservabilityManager'; +import ObservabilityReporter from '../ObservabilityReporter'; + +function createReporter() { + class NullReporter implements 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(` +[ + [ + { + "name": "EV1", + }, + ], + [ + { + "details": { + "abc": 123, + }, + "name": "EV2", + }, + ], +] +`); +}) + +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(` +[ + [ + { + "name": "EV2", + }, + ], +] +`); +}) + +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(` +[ + [ + { + "name": "EV1", + }, + ], +] +`); + expect(reporter2.recordEvent.mock.calls).toMatchInlineSnapshot(` +[ + [ + { + "name": "EV2", + }, + ], +] +`); +}) + +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(` +[ + [ + { + "name": "EV1", + }, + ], + [ + { + "name": "EV2", + }, + ], +] +`); + expect(reporter2.recordEvent.mock.calls).toMatchInlineSnapshot(` +[ + [ + { + "name": "EV2", + }, + ], + [ + { + "name": "EV3", + }, + ], +] +`); +}) diff --git a/src/eterna/pose2D/Pose2D.ts b/src/eterna/pose2D/Pose2D.ts index b5608423..1343d7ac 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 @@ -890,12 +911,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 +932,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 +953,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 +1068,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 +1096,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 +1110,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 +1577,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 +1642,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]; @@ -2050,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; } @@ -3510,6 +3551,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 +3562,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 +3592,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 +3626,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 +3638,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 +3650,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 +3663,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; } @@ -4431,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; diff --git a/src/eterna/puzzle/PuzzleManager.ts b/src/eterna/puzzle/PuzzleManager.ts index 01abd47e..43c2c4a8 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 6b1ced36..c308cf94 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) { diff --git a/src/eterna/ui/BoosterDialog.ts b/src/eterna/ui/BoosterDialog.ts index 15640290..f06b8b39 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/GameButton.ts b/src/eterna/ui/GameButton.ts index 2d08fd8e..1a3fc5d7 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; diff --git a/src/eterna/ui/toolbar/Toolbar.ts b/src/eterna/ui/toolbar/Toolbar.ts index 60ba7901..a5a3d079 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 eef7c51e..790f4a42 100644 --- a/src/eterna/ui/toolbar/ToolbarButton.ts +++ b/src/eterna/ui/toolbar/ToolbarButton.ts @@ -34,6 +34,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 +79,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) { diff --git a/src/eterna/ui/toolbar/ToolbarButtons.ts b/src/eterna/ui/toolbar/ToolbarButtons.ts index 06e20a6b..cf0d41f5 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 = { diff --git a/src/eterna/util/ExternalInterface.ts b/src/eterna/util/ExternalInterface.ts index 539184ea..d27054d5 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}`); + return callback(...args); + }); this.changed.emit(); }