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();
}