Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
1e7fcbc
WIP
luxaritas Aug 30, 2025
f837b3b
UI observability
luxaritas Sep 16, 2025
6a7baef
Restore mutation tracking
luxaritas Sep 16, 2025
39b2383
More o11y events
luxaritas Sep 16, 2025
121aebd
Observability streaming handlers, postmessage output, event filtering
luxaritas Sep 16, 2025
b1404ae
Merge move tracking into o11y
luxaritas Sep 16, 2025
e96c741
abstract class --> interface, console reporter for debugging
luxaritas Sep 16, 2025
f65c8f4
Tweak log string
luxaritas Sep 16, 2025
b8db367
Prevent toolbaraction events from being double-fired when fired on cl…
luxaritas Sep 16, 2025
a3bab07
Fix folder and marker layer firing on puzzle setup, fix folder event …
luxaritas Sep 16, 2025
330c845
o11y events for mode changes
luxaritas Sep 16, 2025
3e0ee99
Merge branch 'dev' into feat/observability
luxaritas Sep 16, 2025
4fe6934
Add qualtrics reporter
luxaritas Sep 23, 2025
3260756
Fix broken return from script callbacks
luxaritas Sep 23, 2025
0cdad67
Merge branch 'feat/observability' into feat/salhai-report
luxaritas Sep 23, 2025
e410c5e
Add recording to scriptconstraint results
luxaritas Sep 23, 2025
cee5d7f
Timer constraint
luxaritas Sep 24, 2025
2ebd321
Record solution submission, skip upload for qualtrics report
luxaritas Sep 25, 2025
ea706e1
Trigger updates for timer constraint
luxaritas Sep 25, 2025
5c718a6
Avoid reevaluating constraints due to only time update
luxaritas Sep 25, 2025
65ccc79
Prevent async text flashing
luxaritas Sep 25, 2025
742ccfe
Supress glow and sound for timer constraint satisfied state change
luxaritas Sep 25, 2025
631c21a
Support soft constraints for non-experimental puzzles
luxaritas Sep 25, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions assets/stopwatch.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 3 additions & 1 deletion src/eterna/Eterna.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions src/eterna/EternaApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/eterna/constraints/Constraint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface ConstraintContext {
targetConditions?: (TargetConditions | undefined)[];
puzzle?: Puzzle;
scriptConstraintCtx?: ExternalInterfaceCtx;
elapsed?: number;
}

export default abstract class Constraint<ConstraintStatus extends BaseConstraintStatus> {
Expand Down
43 changes: 30 additions & 13 deletions src/eterna/constraints/ConstraintBar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Status extends BaseConstraintStatus = BaseConstraintStatus> {
constraint: Constraint<Status>;
Expand Down Expand Up @@ -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
Expand All @@ -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();

Expand Down Expand Up @@ -518,4 +534,5 @@ export default class ConstraintBar extends ContainerObject {

private _constraints: ConstraintWrapper[];
private _flaggedConstraint: ConstraintWrapper | null;
private _lastConstraintContext: ConstraintContext | null;
}
29 changes: 16 additions & 13 deletions src/eterna/constraints/ConstraintBox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions src/eterna/constraints/constraints/ScriptConstraint.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -28,6 +29,12 @@ export default class ScriptConstraint extends Constraint<ScriptConstraintStatus>
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 : '',
Expand Down
82 changes: 82 additions & 0 deletions src/eterna/constraints/constraints/TimerConstraint.ts
Original file line number Diff line number Diff line change
@@ -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<BaseConstraintStatus> {
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);
}
}
1 change: 1 addition & 0 deletions src/eterna/mode/DesignBrowser/DesignBrowserMode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down
5 changes: 5 additions & 0 deletions src/eterna/mode/ErrorDialogMode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
4 changes: 4 additions & 0 deletions src/eterna/mode/FeedbackViewMode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
9 changes: 6 additions & 3 deletions src/eterna/mode/GameMode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
}
Expand All @@ -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 {
Expand All @@ -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))
Expand All @@ -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; });
});
}
Expand Down Expand Up @@ -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;
}
Expand Down
4 changes: 4 additions & 0 deletions src/eterna/mode/PoseEdit/Booster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
1 change: 1 addition & 0 deletions src/eterna/mode/PoseEdit/MissionIntroMode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ export default class MissionIntroMode extends AppMode {

protected enter(): void {
super.enter();
Eterna.observability.recordEvent('ModeEnter', {mode: 'MissionIntro'});
Eterna.chat.pushHideChat();
}

Expand Down
Loading