mirror of
https://github.com/Heretek-AI/GDevelop-Heretek.git
synced 2026-10-12 02:02:42 +00:00
Merge 4ian/GDevelop master (7a7736a3a4) into the Heretek fork.
- Resolve conflicts in README.md, newIDE/app/package.json and
AiRequestChat (ChatMessages.js, index.js), re-applying BYOK
behavior on top of upstream's AiUsageIndicator refactor.
- Keep Heretek Vite/prettier TS scripts alongside upstream's
check-build-output.js build checks.
- Fix BYOK event-generation path after upstream dropped
existingEventsAsText, now derived from existingEventsJson.
- Lock .github/workflows to master to prevent upstream workflow
tampering.
3300 lines
125 KiB
TypeScript
3300 lines
125 KiB
TypeScript
/*
|
|
* GDevelop JS Platform
|
|
* Copyright 2013-present Florian Rival ([email protected]). All rights
|
|
* reserved. This project is released under the MIT License.
|
|
*/
|
|
namespace gdjs {
|
|
const logger = new gdjs.Logger('Gameplay tests');
|
|
|
|
/**
|
|
* Gameplay tests: run a JavaScript test script against the running game,
|
|
* stepping frames deterministically, simulating inputs and asserting on the
|
|
* game state. Used by the editor (and the AI) through the debugger client
|
|
* (`gameplayTest.run` command) - see `gdjs.gameplayTests.runGameplayTest`.
|
|
*
|
|
* @category Gameplay tests
|
|
*/
|
|
export namespace gameplayTests {
|
|
/**
|
|
* One readable state entry, derived by the editor from an extension's own
|
|
* declarations: `name` is the event-sheet name of a condition or
|
|
* expression ('IsOnFloor', 'CurrentSpeed', 'PropertyHealth'...) and
|
|
* `functionName` the runtime method evaluating it.
|
|
*/
|
|
export type GameplayTestStateInspectorEntry = {
|
|
name: string;
|
|
functionName: string;
|
|
kind: 'boolean' | 'number' | 'string';
|
|
};
|
|
|
|
/**
|
|
* The state inspectors for the behavior and object types used in the
|
|
* project, computed by the editor from the extensions metadata (single
|
|
* source of truth) and sent with the run payload.
|
|
*/
|
|
export type GameplayTestStateInspectors = {
|
|
behaviors: {
|
|
[behaviorType: string]: Array<GameplayTestStateInspectorEntry>;
|
|
};
|
|
objects: {
|
|
[objectType: string]: Array<GameplayTestStateInspectorEntry>;
|
|
};
|
|
};
|
|
|
|
/**
|
|
* The state of an object or behavior: its conditions and expressions,
|
|
* evaluated at snapshot time, under the exact names used in the game's
|
|
* events. Reading an unknown name throws with the list of available ones.
|
|
*/
|
|
export type GameplayTestEvaluatedState = {
|
|
[conditionOrExpressionName: string]: boolean | number | string;
|
|
};
|
|
|
|
export type GameplayTestRunPayload = {
|
|
testName: string;
|
|
/** The body of `async (harness) => { ... }`. */
|
|
source: string;
|
|
/** Readable state to evaluate on object/behavior snapshots. */
|
|
stateInspectors?: GameplayTestStateInspectors;
|
|
/** Wall-clock timeout for the RUNNING part of the test: the time
|
|
* spent waiting for the game to boot or for scene assets to load is
|
|
* excluded (bounded separately by `loadingTimeoutMs`). Default: 30000. */
|
|
timeoutMs?: number;
|
|
/** Wall-clock bound on each loading wait (game boot, scene assets),
|
|
* which is excluded from `timeoutMs`. Default: 300000. */
|
|
loadingTimeoutMs?: number;
|
|
/** Maximum number of frames stepped. Default: 20000. */
|
|
maxFrames?: number;
|
|
/**
|
|
* Pace the run for a human watching it: game seconds simulated per
|
|
* real second (1 = normal speed, 4 = 4x...). Omitted: run as fast
|
|
* as possible.
|
|
*/
|
|
speedFactor?: number;
|
|
/** Maximum number of screenshots kept. Default: 5. */
|
|
maxScreenshots?: number;
|
|
/**
|
|
* When true, the game is left paused and muted when the test finishes,
|
|
* instead of resuming. Used when the game only exists to run tests
|
|
* (the editor gameplay test frame), so the last frame stays visible.
|
|
*/
|
|
freezeWhenFinished?: boolean;
|
|
};
|
|
|
|
export type GameplayTestAssertion = {
|
|
message: string;
|
|
passed: boolean;
|
|
};
|
|
|
|
export type GameplayTestLog = {
|
|
level: 'log' | 'warn' | 'error';
|
|
message: string;
|
|
};
|
|
|
|
export type GameplayTestEvent = {
|
|
frame: integer;
|
|
/**
|
|
* `sceneReset` is recorded when the running scene was replaced by a NEW
|
|
* instance of the same scene: objects are back to their initial state.
|
|
* Legitimate when the game restarts the level; a symptom of external
|
|
* interference otherwise.
|
|
*/
|
|
event: 'spawned' | 'removed' | 'stuck' | 'sceneChanged' | 'sceneReset';
|
|
object?: string;
|
|
count?: integer;
|
|
sceneName?: string;
|
|
/**
|
|
* Who changed the scene: the test itself (`harness` - a `goToScene`,
|
|
* or `controlsProbe` for the scene restarts that
|
|
* `resetSceneAndProbeControls` performs itself - expected, not a
|
|
* malfunction), the game's own logic (`game` - a scene change/restart
|
|
* action), multiplayer state (`networkSync`), or something outside of
|
|
* the game (`external` - a symptom of interference with the test).
|
|
*/
|
|
cause?:
|
|
| 'harness'
|
|
| 'controlsProbe'
|
|
| 'game'
|
|
| 'networkSync'
|
|
| 'external'
|
|
| 'unknown';
|
|
/** For an `external` cause: where the change came from (call stack). */
|
|
causeDetail?: string;
|
|
};
|
|
|
|
export type GameplayTestScreenshot = {
|
|
label: string;
|
|
frame: integer;
|
|
jpegBase64: string;
|
|
};
|
|
|
|
export type GameplayTestObjectSnapshot = {
|
|
id: integer;
|
|
name: string;
|
|
x: float;
|
|
y: float;
|
|
z?: float;
|
|
angle: float;
|
|
rotationX?: float;
|
|
rotationY?: float;
|
|
width: float;
|
|
height: float;
|
|
depth?: float;
|
|
centerX: float;
|
|
centerY: float;
|
|
centerZ?: float;
|
|
layer: string;
|
|
hidden: boolean;
|
|
animation?: string;
|
|
text?: string;
|
|
opacity?: float;
|
|
variables: Array<Object>;
|
|
/** The object's own conditions/expressions, evaluated (see
|
|
* `GameplayTestEvaluatedState`). */
|
|
state: GameplayTestEvaluatedState;
|
|
behaviors: {
|
|
[behaviorName: string]: {
|
|
act: boolean;
|
|
/** The behavior's conditions/expressions, evaluated: current state
|
|
* and configuration, under their event-sheet names
|
|
* (e.g. `behaviors.PlatformerObject.state.IsOnFloor`). Prefer these
|
|
* over inferring state from coordinates. */
|
|
state: GameplayTestEvaluatedState;
|
|
};
|
|
};
|
|
flippedX?: boolean;
|
|
flippedY?: boolean;
|
|
children?: { [objectName: string]: Array<GameplayTestObjectSnapshot> };
|
|
};
|
|
|
|
export type GameplayTestNearbyObjectSnapshot =
|
|
GameplayTestObjectSnapshot & {
|
|
distance: float;
|
|
relativeX: float;
|
|
relativeY: float;
|
|
relativeZ?: float;
|
|
above: boolean;
|
|
below: boolean;
|
|
left: boolean;
|
|
right: boolean;
|
|
bearingFromReference: float;
|
|
};
|
|
|
|
/**
|
|
* The position of a target relative to a reference object (2D and 3D).
|
|
* How to move toward the target (which keys, when to jump...) is up to
|
|
* the test script - see `resetSceneAndProbeControls` to discover the
|
|
* controls and `makeProgressTracker` to detect a lack of progress.
|
|
*/
|
|
export type GameplayTestRelativePosition = {
|
|
relativeX: float;
|
|
relativeY: float;
|
|
relativeZ?: float;
|
|
/** Full distance to the target (3D when Z is available). */
|
|
distance: float;
|
|
/** Distance to the target ignoring Z (equals `distance` in 2D). */
|
|
horizontalDistance: float;
|
|
/** Difference between the reference object's angle (yaw) and the
|
|
* direction of the target, in degrees, normalized to [-180, 180]. */
|
|
yawDiff: float;
|
|
/** Vertical aim difference in degrees (3D only, 0 in 2D). */
|
|
pitchDiff: float;
|
|
dominantAxis: 'x' | 'y';
|
|
/** True when the target is within `reachRadius` (default: 30). */
|
|
reached: boolean;
|
|
targetX: float;
|
|
targetY: float;
|
|
targetZ?: float;
|
|
};
|
|
|
|
/**
|
|
* The measured effect of holding one key (or nothing, for the
|
|
* baseline): the net displacement after the probe, the extreme
|
|
* displacements observed during it (a jump shows as a negative
|
|
* `minDy` even if the object lands back), and the yaw change.
|
|
*/
|
|
export type GameplayTestControlProbeResult = {
|
|
dx: float;
|
|
dy: float;
|
|
dz?: float;
|
|
minDx: float;
|
|
maxDx: float;
|
|
minDy: float;
|
|
maxDy: float;
|
|
minDz?: float;
|
|
maxDz?: float;
|
|
yawDelta: float;
|
|
};
|
|
|
|
/**
|
|
* A flat, JSON-safe profiling summary, also attached to the test result
|
|
* (`result.profiles`). Sections are sorted by average time descending
|
|
* (nested sections flattened as "parent > child"); `maxTimeMs` is the
|
|
* worst single frame of a section - spikes that averages hide.
|
|
* `worstFrames` and the timeline use the harness frame numbers:
|
|
* correlate a spike with the `eventLog` frames.
|
|
*/
|
|
export type GameplayTestProfilingResult = {
|
|
/** The profiled window, in harness frame numbers (like `eventLog`). */
|
|
startFrame: integer;
|
|
endFrame: integer;
|
|
avgStepTimeMs: number;
|
|
/** The worst single frame (total). */
|
|
maxStepTimeMs: number;
|
|
sections: Array<{ name: string; avgTimeMs: number; maxTimeMs: number }>;
|
|
/** The most expensive profiled frames, worst first. */
|
|
worstFrames: Array<{ frame: integer; timeMs: number }>;
|
|
/** The frame-by-frame timeline, in order. When the window is longer
|
|
* than 120 frames, each entry is the MAX of `frameTimesBucketSize`
|
|
* consecutive frames (spikes are preserved). */
|
|
frameTimesMs: Array<number>;
|
|
frameTimesBucketSize: integer;
|
|
/** Live instances per object at the time profiling stopped. */
|
|
objectCounts: { [objectName: string]: integer };
|
|
/** 3D renderer counters (last rendered frame), when the game uses 3D. */
|
|
renderer: {
|
|
drawCalls: number;
|
|
triangles: number;
|
|
geometries: number;
|
|
textures: number;
|
|
} | null;
|
|
/** JS heap in use (Chromium only), to spot leaks across a long run. */
|
|
jsHeapUsedMb?: number;
|
|
};
|
|
|
|
/**
|
|
* The outcome of `lookTowardWithMouseDelta`: whether the aim succeeded,
|
|
* the remaining aim error, and whether the game responded to the mouse
|
|
* at all. `sawYawResponse: false` usually means the game ignores mouse
|
|
* deltas (e.g. the pointer lock was never engaged: click once first).
|
|
* `sawPitchResponse: false` (with a yaw response) means the vertical
|
|
* aim could not be measured: the aim automatically fell back to
|
|
* yaw-only.
|
|
*/
|
|
export type GameplayTestAimResult = {
|
|
aimed: boolean;
|
|
yawDiff: float;
|
|
pitchDiff: float;
|
|
sawYawResponse: boolean;
|
|
sawPitchResponse: boolean;
|
|
/** What the aim was measured on: the camera of the object's layer
|
|
* (first-person views) or the object's own rotations. */
|
|
measuredFrom: 'camera' | 'object';
|
|
};
|
|
|
|
export type GameplayTestProgressStatus = {
|
|
frame: integer;
|
|
distance: float;
|
|
reached: boolean;
|
|
/** True when the distance to the target shrank by less than
|
|
* `minProgress` over the last `windowFrames` frames. */
|
|
stalled: boolean;
|
|
};
|
|
|
|
export type GameplayTestProgressTracker = {
|
|
/** Sample the current distance to the target and return the current
|
|
* progress status (null if the reference or target is gone). */
|
|
update: () => GameplayTestProgressStatus | null;
|
|
/** Forget the history (call after switching to another target). */
|
|
reset: () => void;
|
|
};
|
|
|
|
export type GameplayTestResult = {
|
|
testName: string;
|
|
status: 'passed' | 'failed' | 'error' | 'stopped' | 'timeout';
|
|
framesExecuted: integer;
|
|
durationMs: number;
|
|
/** Time spent waiting for the game to boot and for scene assets to
|
|
* load, excluded from the `timeoutMs` budget. `durationMs` includes
|
|
* it: `durationMs - loadingMs` is what counted against the budget. */
|
|
loadingMs: number;
|
|
/** The wall-clock budget the run had, loading excluded
|
|
* (`durationMs - loadingMs` close to it means the test is at risk of
|
|
* timing out on a slower machine). */
|
|
timeoutMs: number;
|
|
/** Time during which the game was frozen by the browser because the
|
|
* page was hidden (tab in the background, window minimized or covered
|
|
* by another one): animation frames stop, so the game stops stepping.
|
|
* Excluded from the `timeoutMs` budget - being looked away from is
|
|
* not a reason for a test to fail. */
|
|
hiddenStallMs: number;
|
|
gameTimeMs: number;
|
|
assertions: Array<GameplayTestAssertion>;
|
|
errors: Array<string>;
|
|
consoleLogs: Array<GameplayTestLog>;
|
|
eventLog: Array<GameplayTestEvent>;
|
|
finalState: {
|
|
sceneName: string;
|
|
objectCounts: { [objectName: string]: integer };
|
|
watchedObjects: {
|
|
[objectName: string]: Array<GameplayTestObjectSnapshot>;
|
|
};
|
|
sceneVariables: Array<Object>;
|
|
};
|
|
screenshots: Array<GameplayTestScreenshot>;
|
|
/** The `stopProfiling()` summaries captured during the run. */
|
|
profiles: Array<GameplayTestProfilingResult>;
|
|
performance: {
|
|
avgStepMs: number;
|
|
worstStepMs: number;
|
|
} | null;
|
|
};
|
|
|
|
const DEFAULT_TIMEOUT_MS = 30000;
|
|
/** Loading (game boot, scene assets) is excluded from `timeoutMs` -
|
|
* on the web, a first preview can download assets for minutes - but
|
|
* still bounded, so a dead network cannot hang a run forever. */
|
|
const DEFAULT_LOADING_TIMEOUT_MS = 300000;
|
|
const DEFAULT_MAX_FRAMES = 20000;
|
|
/** How long frames are stepped before yielding once to the browser:
|
|
* the game visibly plays (a rendered frame per refresh), stop/progress
|
|
* messages flow, while keeping near-full stepping throughput. */
|
|
const YIELD_BUDGET_MS = 12;
|
|
// In fast (unpaced) runs, let the game render at most this often...
|
|
const FAST_RUN_RENDER_INTERVAL_MS = 250;
|
|
// ...and never spend more than ~1/(1+multiplier) of the wall clock
|
|
// rendering: when a single render costs more than the interval itself
|
|
// (a 3D scene on software WebGL can take 350ms), an interval cap alone
|
|
// skips nothing - after such a render, wait for multiplier times its
|
|
// cost before the next one.
|
|
const FAST_RUN_RENDER_DUTY_MULTIPLIER = 4;
|
|
const MAX_PLAYED_SOUNDS = 500;
|
|
const DEFAULT_MAX_SCREENSHOTS = 5;
|
|
const DEFAULT_PROBE_FRAMES = 30;
|
|
const MAX_PROFILING_SECTIONS = 50;
|
|
const MAX_PROFILING_TIMELINE_ENTRIES = 120;
|
|
const MAX_PROFILING_WORST_FRAMES = 5;
|
|
const MAX_PROFILES_PER_RESULT = 5;
|
|
/** The scene change cause declared by the harness (see
|
|
* `SceneStack.runWithSceneChangeCause`). */
|
|
const GAMEPLAY_TEST_SCENE_CHANGE_CAUSE = 'gameplayTest';
|
|
const DEFAULT_PROGRESS_WINDOW_FRAMES = 60;
|
|
const DEFAULT_PROGRESS_MIN_PROGRESS = 8;
|
|
const DEFAULT_REACH_RADIUS = 30;
|
|
const MAX_CONSOLE_LOGS = 100;
|
|
const MAX_CONSOLE_LOGS_TOTAL_CHARS = 8000;
|
|
const MAX_ASSERTIONS = 200;
|
|
const MAX_EVENT_LOG_ENTRIES = 500;
|
|
const MAX_ERRORS = 20;
|
|
const SCREENSHOT_MAX_SIZE = 512;
|
|
const DEFAULT_FRAME_DT_MS = 1000 / 60;
|
|
|
|
// Keys that must never throw on the self-describing state, so language
|
|
// internals (JSON.stringify, await inspection, string coercion...) keep
|
|
// working transparently.
|
|
const STATE_SAFE_INSPECTION_KEYS: { [key: string]: boolean } = {
|
|
toJSON: true,
|
|
then: true,
|
|
constructor: true,
|
|
hasOwnProperty: true,
|
|
toString: true,
|
|
valueOf: true,
|
|
inspect: true,
|
|
};
|
|
|
|
/**
|
|
* Wrap an evaluated state so reading an unknown name (a typo, a wrong
|
|
* casing, a hallucinated key) throws immediately with the list of
|
|
* available names, instead of silently returning undefined.
|
|
*/
|
|
const makeSelfDescribingState = (
|
|
state: GameplayTestEvaluatedState,
|
|
ownerDescription: string
|
|
): GameplayTestEvaluatedState => {
|
|
return new Proxy(state, {
|
|
get(target, key) {
|
|
if (
|
|
typeof key === 'string' &&
|
|
!(key in target) &&
|
|
!STATE_SAFE_INSPECTION_KEYS[key]
|
|
) {
|
|
const availableKeys = Object.keys(target);
|
|
throw new Error(
|
|
`Unknown state "${key}" on ${ownerDescription}. Available: ` +
|
|
(availableKeys.length > 0
|
|
? availableKeys.join(', ')
|
|
: '(no state for this ' + ownerDescription + ')') +
|
|
'.'
|
|
);
|
|
}
|
|
// @ts-ignore - keys are indexable.
|
|
return target[key];
|
|
},
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Evaluate the state inspector entries on an object or behavior: each
|
|
* entry calls the same public getter the game's events call. An entry
|
|
* must never break a snapshot: mismatching or throwing ones are skipped.
|
|
*/
|
|
const evaluateStateInspector = (
|
|
target: any,
|
|
entries: Array<GameplayTestStateInspectorEntry> | null,
|
|
ownerDescription: string
|
|
): GameplayTestEvaluatedState => {
|
|
const state: GameplayTestEvaluatedState = {};
|
|
if (entries) {
|
|
for (const entry of entries) {
|
|
const method = target[entry.functionName];
|
|
if (typeof method !== 'function') continue;
|
|
try {
|
|
const value = method.call(target);
|
|
if (
|
|
typeof value === 'boolean' ||
|
|
typeof value === 'number' ||
|
|
typeof value === 'string'
|
|
) {
|
|
state[entry.name] = value;
|
|
}
|
|
} catch (error) {
|
|
// Skip the entry: an inspector must never break a snapshot.
|
|
}
|
|
}
|
|
}
|
|
return makeSelfDescribingState(state, ownerDescription);
|
|
};
|
|
|
|
class GameplayTestAssertionError extends Error {
|
|
isGameplayTestAssertionError = true;
|
|
}
|
|
class GameplayTestStoppedError extends Error {
|
|
isGameplayTestStoppedError = true;
|
|
}
|
|
class GameplayTestTimeoutError extends Error {
|
|
isGameplayTestTimeoutError = true;
|
|
}
|
|
|
|
/** Map both GDevelop event-sheet key names and Web API key names to
|
|
* a location-aware key code usable with the InputManager. */
|
|
const getLocationAwareKeyCodeForName = (keyName: string): number | null => {
|
|
const keysNameToCode: { [name: string]: number } =
|
|
gdjs.evtTools.input.keysNameToCode;
|
|
const webApiKeyNamesAliases: { [name: string]: string } = {
|
|
ArrowLeft: 'Left',
|
|
ArrowRight: 'Right',
|
|
ArrowUp: 'Up',
|
|
ArrowDown: 'Down',
|
|
Enter: 'Return',
|
|
Backspace: 'Back',
|
|
Shift: 'LShift',
|
|
ShiftLeft: 'LShift',
|
|
ShiftRight: 'RShift',
|
|
Control: 'LControl',
|
|
ControlLeft: 'LControl',
|
|
ControlRight: 'RControl',
|
|
Alt: 'LAlt',
|
|
AltLeft: 'LAlt',
|
|
AltRight: 'RAlt',
|
|
' ': 'Space',
|
|
Minus: 'Dash',
|
|
Semicolon: 'SemiColon',
|
|
};
|
|
let name = keyName;
|
|
if (webApiKeyNamesAliases.hasOwnProperty(name)) {
|
|
name = webApiKeyNamesAliases[name];
|
|
}
|
|
// Web API "KeyA".."KeyZ" and "Digit0".."Digit9".
|
|
if (/^Key[A-Z]$/.test(name)) name = name[3].toLowerCase();
|
|
if (/^Digit[0-9]$/.test(name)) name = 'Num' + name[5];
|
|
// Single letters are stored lowercase, digits as "Num0".."Num9".
|
|
if (/^[A-Z]$/.test(name)) name = name.toLowerCase();
|
|
if (/^[0-9]$/.test(name)) name = 'Num' + name;
|
|
|
|
if (!keysNameToCode.hasOwnProperty(name)) return null;
|
|
return keysNameToCode[name];
|
|
};
|
|
|
|
const mouseButtonNameToCode = (button: string): number => {
|
|
if (button === 'right') return gdjs.InputManager.MOUSE_RIGHT_BUTTON;
|
|
if (button === 'middle') return gdjs.InputManager.MOUSE_MIDDLE_BUTTON;
|
|
return gdjs.InputManager.MOUSE_LEFT_BUTTON;
|
|
};
|
|
|
|
const normalizeAngleDifference = (angleInDegrees: float): float => {
|
|
let angle = angleInDegrees % 360;
|
|
if (angle > 180) angle -= 360;
|
|
if (angle < -180) angle += 360;
|
|
return angle;
|
|
};
|
|
|
|
/**
|
|
* The object passed as `harness` to a gameplay test script.
|
|
*/
|
|
export class GameplayTestHarness {
|
|
_runtimeGame: gdjs.RuntimeGame;
|
|
_payload: GameplayTestRunPayload;
|
|
|
|
// Run state:
|
|
_framesExecuted: integer = 0;
|
|
_gameTimeMs: number = 0;
|
|
_startTimeMs: number = 0;
|
|
_stopped: boolean = false;
|
|
_assertions: Array<GameplayTestAssertion> = [];
|
|
_consoleLogs: Array<GameplayTestLog> = [];
|
|
_consoleLogsTotalChars: number = 0;
|
|
_eventLog: Array<GameplayTestEvent> = [];
|
|
_screenshots: Array<GameplayTestScreenshot> = [];
|
|
_watchedObjectNames: Array<string> = [];
|
|
/** The harness frame at which the current profiling started (see
|
|
* `startProfiling`), or null when not profiling. */
|
|
_profilingStartFrame: integer | null = null;
|
|
/** The profiling summaries captured during the run (attached to the
|
|
* result as `profiles`). */
|
|
_profiles: Array<GameplayTestProfilingResult> = [];
|
|
_timeoutMs: number;
|
|
_loadingTimeoutMs: number;
|
|
/** Time spent waiting for loading (game boot, scene assets) so far:
|
|
* excluded from the `_timeoutMs` budget, reported as `loadingMs`. */
|
|
_loadingTimeMs: number = 0;
|
|
/** Time already spent frozen by the browser because the page was
|
|
* hidden, excluded from the `_timeoutMs` budget (see
|
|
* `_installPageVisibilityTracking`). */
|
|
_hiddenStallTimeMs: number = 0;
|
|
/** When the page became hidden, while it still is, or null. */
|
|
_hiddenSinceMs: number | null = null;
|
|
/** When the last game frame was stepped: tells apart a page that is
|
|
* hidden AND frozen from one that is only hidden (in the desktop app,
|
|
* background throttling is disabled and the game keeps running). */
|
|
_lastFrameStepTimeMs: number = 0;
|
|
_uninstallPageVisibilityTracking: () => void = () => {};
|
|
_maxFrames: integer;
|
|
/** Last time the stepping loop yielded to the browser (see
|
|
* `_maybeYield`). */
|
|
_lastYieldTimeMs: number = 0;
|
|
/** The last time an animation frame was awaited (rendering the game). */
|
|
_lastRenderTimeMs: number = 0;
|
|
/** How long the last render (animation frame tick) took. */
|
|
_lastRenderDurationMs: number = 0;
|
|
/** True while resetSceneAndProbeControls runs: its scene restarts are
|
|
* recorded with the `controlsProbe` cause. */
|
|
_isProbingControls: boolean = false;
|
|
_playedSounds: Array<{ sound: string; frame: integer }> = [];
|
|
/** Entries of the sound manager log already copied to `_playedSounds`. */
|
|
_playedSoundsReadCount: integer = 0;
|
|
/** Game seconds simulated per real second, or null to run as fast
|
|
* as possible (see `_maybeYield`). */
|
|
_paceSpeedFactor: float | null = null;
|
|
_paceReferenceWallTimeMs: number = 0;
|
|
_paceReferenceGameTimeMs: number = 0;
|
|
_maxScreenshots: integer;
|
|
_totalStepTimeMs: number = 0;
|
|
_worstStepTimeMs: number = 0;
|
|
_lastTrackedSceneName: string | null = null;
|
|
/** The scene instance itself is tracked (not only its name) so a
|
|
* replacement by a new instance of the SAME scene is detected too
|
|
* (recorded as a `sceneReset` event). */
|
|
_lastTrackedScene: gdjs.RuntimeScene | null = null;
|
|
_lastTrackedObjectCounts: { [objectName: string]: integer } = {};
|
|
_pointerLockRequestedByGame: boolean = false;
|
|
/** Whether the one-time "mouse deltas without pointer lock" hint was
|
|
* already recorded (see `setMouseDelta`). */
|
|
_hasWarnedMouseDelta: boolean = false;
|
|
_onProgress: ((frame: integer) => void) | null = null;
|
|
_lastProgressTimeMs: number = 0;
|
|
/** Rejects the promise raced against the test script, so a stop
|
|
* interrupts the script even when it awaits something else than the
|
|
* harness (a timer, a fetch...). */
|
|
_rejectOnStop: ((error: Error) => void) | null = null;
|
|
/** How to notify the input manager of the end of a stepped frame.
|
|
* Replaced when the game main loop's own call is neutralized. */
|
|
_callOnFrameEnded: () => void;
|
|
|
|
constructor(
|
|
runtimeGame: gdjs.RuntimeGame,
|
|
payload: GameplayTestRunPayload
|
|
) {
|
|
this._runtimeGame = runtimeGame;
|
|
this._payload = payload;
|
|
this._timeoutMs = payload.timeoutMs || DEFAULT_TIMEOUT_MS;
|
|
this._loadingTimeoutMs =
|
|
payload.loadingTimeoutMs || DEFAULT_LOADING_TIMEOUT_MS;
|
|
this._maxFrames = payload.maxFrames || DEFAULT_MAX_FRAMES;
|
|
this._lastYieldTimeMs = Date.now();
|
|
this._paceSpeedFactor = payload.speedFactor
|
|
? Math.max(0.1, Math.min(100, payload.speedFactor))
|
|
: null;
|
|
this._paceReferenceWallTimeMs = Date.now();
|
|
this._paceReferenceGameTimeMs = 0;
|
|
this._maxScreenshots =
|
|
payload.maxScreenshots === undefined
|
|
? DEFAULT_MAX_SCREENSHOTS
|
|
: payload.maxScreenshots;
|
|
const inputManager = runtimeGame.getInputManager();
|
|
this._callOnFrameEnded = () => inputManager.onFrameEnded();
|
|
}
|
|
|
|
private _getCurrentScene(): gdjs.RuntimeScene {
|
|
const sceneStack = this._runtimeGame.getSceneStack();
|
|
const currentScene = sceneStack.getCurrentScene();
|
|
if (!currentScene) {
|
|
if (sceneStack.isNextSceneLoading()) {
|
|
// Should not happen: every stepped frame waits for a
|
|
// game-driven scene switch to complete (see
|
|
// `_waitForGameSceneSwitchIfNeeded`). Kept as a safety net for
|
|
// a synchronous read racing the switch.
|
|
throw new Error(
|
|
'The game is switching scenes and the next scene is still ' +
|
|
'loading. `await harness.stepFrames(1)` waits for it to be ready.'
|
|
);
|
|
}
|
|
if (sceneStack.wasFirstSceneLoaded()) {
|
|
throw new Error(
|
|
'No scene is running anymore: the game removed its last ' +
|
|
'scene (it likely ended or quit). Use ' +
|
|
'`await harness.goToScene(sceneName)` to start a scene again.'
|
|
);
|
|
}
|
|
throw new Error(
|
|
'No scene is running. Call `await harness.goToScene(sceneName)` first.'
|
|
);
|
|
}
|
|
return currentScene;
|
|
}
|
|
|
|
/**
|
|
* Request the test to stop as soon as possible: the next stepped frame
|
|
* throws, and any pending `await` of the script is interrupted (see
|
|
* `_rejectOnStop`).
|
|
*/
|
|
requestStop(): void {
|
|
this._stopped = true;
|
|
if (this._rejectOnStop) {
|
|
this._rejectOnStop(
|
|
new GameplayTestStoppedError('The test was stopped.')
|
|
);
|
|
this._rejectOnStop = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Track the time during which the game is frozen because the page is
|
|
* hidden, so that it can be excluded from the `_timeoutMs` budget.
|
|
*
|
|
* Browsers stop `requestAnimationFrame` in a hidden page (a tab in
|
|
* the background, a minimized window, or - with Chrome's occlusion
|
|
* detection - a window entirely covered by another one). The test
|
|
* loop steps frames from animation frames, so the game simply stops:
|
|
* without this, a test would be reported as timing out just because
|
|
* the user looked at something else while it ran.
|
|
*
|
|
* Being hidden is not enough to conclude the game was frozen: the
|
|
* desktop app disables background throttling, and the game then keeps
|
|
* running while hidden. So only the time after the last stepped frame
|
|
* is counted as a stall.
|
|
*/
|
|
_installPageVisibilityTracking(): void {
|
|
if (typeof document === 'undefined') return;
|
|
|
|
const onVisibilityChanged = () => {
|
|
if (document.visibilityState === 'hidden') {
|
|
this._hiddenSinceMs = Date.now();
|
|
} else {
|
|
this._hiddenStallTimeMs += this._getOngoingHiddenStallMs();
|
|
this._hiddenSinceMs = null;
|
|
}
|
|
};
|
|
if (document.visibilityState === 'hidden') {
|
|
this._hiddenSinceMs = Date.now();
|
|
}
|
|
document.addEventListener('visibilitychange', onVisibilityChanged);
|
|
this._uninstallPageVisibilityTracking = () => {
|
|
document.removeEventListener('visibilitychange', onVisibilityChanged);
|
|
this._uninstallPageVisibilityTracking = () => {};
|
|
};
|
|
}
|
|
|
|
/** The stall of the hidden period currently in progress, if any. */
|
|
private _getOngoingHiddenStallMs(): number {
|
|
if (this._hiddenSinceMs === null) return 0;
|
|
// Frames stepped while hidden are frames the game was not frozen
|
|
// for: the stall only starts after the last of them.
|
|
const stallStartTimeMs = Math.max(
|
|
this._hiddenSinceMs,
|
|
this._lastFrameStepTimeMs
|
|
);
|
|
return Math.max(0, Date.now() - stallStartTimeMs);
|
|
}
|
|
|
|
/** Total time the game was frozen because the page was hidden. */
|
|
_getHiddenStallTimeMs(): number {
|
|
return this._hiddenStallTimeMs + this._getOngoingHiddenStallMs();
|
|
}
|
|
|
|
private _checkGuards(): void {
|
|
if (this._stopped) {
|
|
throw new GameplayTestStoppedError('The test was stopped.');
|
|
}
|
|
if (this._framesExecuted >= this._maxFrames) {
|
|
throw new GameplayTestTimeoutError(
|
|
`The test reached the maximum number of frames (${this._maxFrames}).`
|
|
);
|
|
}
|
|
if (
|
|
Date.now() -
|
|
this._startTimeMs -
|
|
this._loadingTimeMs -
|
|
this._getHiddenStallTimeMs() >
|
|
this._timeoutMs
|
|
) {
|
|
throw new GameplayTestTimeoutError(
|
|
`The test timed out after ${this._timeoutMs}ms ` +
|
|
'(wall-clock, loading and time spent hidden excluded).'
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Await a loading promise (game boot, scene assets...) WITHOUT
|
|
* counting the wait against the `timeoutMs` budget: the timeout is
|
|
* about the running game, not about how long a web preview takes to
|
|
* download its resources. The wait is still bounded (by
|
|
* `loadingTimeoutMs`) so a dead network cannot hang the run, and
|
|
* progress heartbeats keep flowing so the editor knows the run is
|
|
* alive.
|
|
*/
|
|
async _awaitLoading(
|
|
loadingPromise: Promise<unknown>,
|
|
description: string
|
|
): Promise<void> {
|
|
const loadingStartTimeMs = Date.now();
|
|
const heartbeatIntervalId = setInterval(() => {
|
|
if (this._onProgress) this._onProgress(this._framesExecuted);
|
|
}, 1000);
|
|
let loadingTimeoutId: any = null;
|
|
try {
|
|
await Promise.race([
|
|
loadingPromise,
|
|
new Promise<never>((_, reject) => {
|
|
loadingTimeoutId = setTimeout(
|
|
() =>
|
|
reject(
|
|
new Error(
|
|
`${description} did not finish loading within ` +
|
|
`${this._loadingTimeoutMs}ms.`
|
|
)
|
|
),
|
|
this._loadingTimeoutMs
|
|
);
|
|
}),
|
|
]);
|
|
} finally {
|
|
clearInterval(heartbeatIntervalId);
|
|
if (loadingTimeoutId) clearTimeout(loadingTimeoutId);
|
|
this._loadingTimeMs += Date.now() - loadingStartTimeMs;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* When the game's own logic changed scene (a `Scene(...)` action) and
|
|
* the next scene's assets are not loaded yet, the scene stack is
|
|
* empty until the load finishes - the game is not over, a scene is on
|
|
* its way. Wait for it (as loading time, excluded from the timeout
|
|
* budget) so the test script never observes the gap: without this, a
|
|
* `getSceneName()` right after the game changed scene throws a
|
|
* misleading "No scene is running".
|
|
*/
|
|
async _waitForGameSceneSwitchIfNeeded(): Promise<void> {
|
|
const sceneStack = this._runtimeGame.getSceneStack();
|
|
if (sceneStack.getCurrentScene() || !sceneStack.isNextSceneLoading()) {
|
|
return;
|
|
}
|
|
await this._awaitLoading(
|
|
(async () => {
|
|
while (
|
|
!sceneStack.getCurrentScene() &&
|
|
sceneStack.isNextSceneLoading() &&
|
|
!this._stopped
|
|
) {
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
}
|
|
})(),
|
|
'The scene the game is switching to'
|
|
);
|
|
// Record the arrival right away (`sceneChanged`/`sceneReset` with
|
|
// the cause of the game's change request), even if the script never
|
|
// steps another frame.
|
|
this._trackChangesAfterStep();
|
|
}
|
|
|
|
private _recordEvent(event: GameplayTestEvent): void {
|
|
if (this._eventLog.length >= MAX_EVENT_LOG_ENTRIES) return;
|
|
this._eventLog.push(event);
|
|
}
|
|
|
|
private _getObjectCounts(): { [objectName: string]: integer } {
|
|
const objectCounts: { [objectName: string]: integer } = {};
|
|
const currentScene = this._runtimeGame
|
|
.getSceneStack()
|
|
.getCurrentScene();
|
|
if (!currentScene) return objectCounts;
|
|
const objectNames: Array<string> = [];
|
|
// Access the protected map of the instances of the container.
|
|
const instances = (currentScene as any)._instances as Hashtable<
|
|
Array<gdjs.RuntimeObject>
|
|
>;
|
|
instances.keys(objectNames);
|
|
for (const objectName of objectNames) {
|
|
const objectInstances = instances.get(objectName);
|
|
if (objectInstances.length > 0) {
|
|
objectCounts[objectName] = objectInstances.length;
|
|
}
|
|
}
|
|
return objectCounts;
|
|
}
|
|
|
|
private _trackChangesAfterStep(): void {
|
|
const currentScene = this._runtimeGame
|
|
.getSceneStack()
|
|
.getCurrentScene();
|
|
if (
|
|
!currentScene &&
|
|
this._runtimeGame.getSceneStack().isNextSceneLoading()
|
|
) {
|
|
// The game changed scene and the next one is still loading: the
|
|
// stack is only transiently empty. Do not record this gap as a
|
|
// scene change - the arrival is recorded once the new scene
|
|
// exists (see `_waitForGameSceneSwitchIfNeeded`).
|
|
return;
|
|
}
|
|
const sceneName = currentScene ? currentScene.getName() : '';
|
|
if (currentScene !== this._lastTrackedScene) {
|
|
const lastChangeCause = this._runtimeGame
|
|
.getSceneStack()
|
|
.consumeLastSceneChangeCause();
|
|
this._recordEvent({
|
|
frame: this._framesExecuted,
|
|
// A new instance of the SAME scene means the scene was restarted
|
|
// (all objects back to their initial state): record it as a
|
|
// `sceneReset` so it never goes unnoticed in the event log.
|
|
event:
|
|
sceneName === this._lastTrackedSceneName
|
|
? 'sceneReset'
|
|
: 'sceneChanged',
|
|
sceneName,
|
|
cause: !lastChangeCause
|
|
? 'unknown'
|
|
: lastChangeCause.cause === GAMEPLAY_TEST_SCENE_CHANGE_CAUSE
|
|
? this._isProbingControls
|
|
? 'controlsProbe'
|
|
: 'harness'
|
|
: lastChangeCause.cause === 'game' ||
|
|
lastChangeCause.cause === 'networkSync'
|
|
? lastChangeCause.cause
|
|
: 'external',
|
|
...(lastChangeCause && lastChangeCause.stack
|
|
? { causeDetail: lastChangeCause.stack }
|
|
: {}),
|
|
});
|
|
this._lastTrackedScene = currentScene;
|
|
this._lastTrackedSceneName = sceneName;
|
|
this._lastTrackedObjectCounts = this._getObjectCounts();
|
|
return;
|
|
}
|
|
|
|
const newCounts = this._getObjectCounts();
|
|
for (const objectName in newCounts) {
|
|
const previousCount = this._lastTrackedObjectCounts[objectName] || 0;
|
|
if (newCounts[objectName] > previousCount) {
|
|
this._recordEvent({
|
|
frame: this._framesExecuted,
|
|
event: 'spawned',
|
|
object: objectName,
|
|
count: newCounts[objectName],
|
|
});
|
|
}
|
|
}
|
|
for (const objectName in this._lastTrackedObjectCounts) {
|
|
const newCount = newCounts[objectName] || 0;
|
|
if (newCount < this._lastTrackedObjectCounts[objectName]) {
|
|
this._recordEvent({
|
|
frame: this._framesExecuted,
|
|
event: 'removed',
|
|
object: objectName,
|
|
count: newCount,
|
|
});
|
|
}
|
|
}
|
|
this._lastTrackedObjectCounts = newCounts;
|
|
}
|
|
|
|
/**
|
|
* Step a single game frame (game logic + rendering) with a fixed
|
|
* time delta.
|
|
*/
|
|
_stepSingleFrame(dtMs: float): void {
|
|
this._checkGuards();
|
|
const stepStartTimeMs = Date.now();
|
|
this._runtimeGame.getSceneStack().step(dtMs);
|
|
this._callOnFrameEnded();
|
|
this._framesExecuted++;
|
|
this._gameTimeMs += dtMs;
|
|
const stepTimeMs = Date.now() - stepStartTimeMs;
|
|
this._lastFrameStepTimeMs = stepStartTimeMs + stepTimeMs;
|
|
this._totalStepTimeMs += stepTimeMs;
|
|
if (stepTimeMs > this._worstStepTimeMs) {
|
|
this._worstStepTimeMs = stepTimeMs;
|
|
}
|
|
this._trackChangesAfterStep();
|
|
this._drainPlayedSounds();
|
|
if (this._onProgress && Date.now() - this._lastProgressTimeMs > 500) {
|
|
this._lastProgressTimeMs = Date.now();
|
|
this._onProgress(this._framesExecuted);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Let an animation frame happen (the paused main loop renders the
|
|
* game in it) and measure its cost, which drives how often fast runs
|
|
* render (see FAST_RUN_RENDER_DUTY_MULTIPLIER).
|
|
*/
|
|
private async _renderOnce(): Promise<void> {
|
|
const renderStartTimeMs = Date.now();
|
|
await this._waitForNextAnimationFrame();
|
|
// Pass the whole animation frame tick (game render included)
|
|
// before measuring: a slow render must lower the render rate.
|
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
this._lastRenderDurationMs = Date.now() - renderStartTimeMs;
|
|
this._lastRenderTimeMs = Date.now();
|
|
}
|
|
|
|
private _waitForNextAnimationFrame(): Promise<void> {
|
|
return new Promise((resolve) => {
|
|
if (typeof requestAnimationFrame !== 'undefined') {
|
|
requestAnimationFrame(() => resolve());
|
|
} else {
|
|
setTimeout(() => resolve(), 0);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Yield once to the browser when frames were stepped for more than
|
|
* `YIELD_BUDGET_MS` of wall-clock time: the game gets rendered (the
|
|
* test is visible while it runs) and pending events/messages (like a
|
|
* stop request) get processed - whatever the stepping pattern of the
|
|
* test script (one big `stepFrames`, a `stepUntil` or a manual
|
|
* `stepFrames(1)` loop).
|
|
*
|
|
* When the run is paced (`speedFactor` in the payload), also wait
|
|
* until the wall clock catches up with the game time simulated at
|
|
* the desired speed.
|
|
*/
|
|
private async _maybeYield(): Promise<void> {
|
|
if (this._paceSpeedFactor !== null) {
|
|
const targetWallTimeMs =
|
|
this._paceReferenceWallTimeMs +
|
|
(this._gameTimeMs - this._paceReferenceGameTimeMs) /
|
|
this._paceSpeedFactor;
|
|
if (Date.now() < targetWallTimeMs) {
|
|
while (Date.now() < targetWallTimeMs) {
|
|
await this._waitForNextAnimationFrame();
|
|
}
|
|
this._lastYieldTimeMs = Date.now();
|
|
return;
|
|
}
|
|
// The run fell behind its pace (a heavy frame, or work outside of
|
|
// frame stepping like a scene load): re-anchor the pace instead
|
|
// of rushing at full speed to catch up.
|
|
this._paceReferenceWallTimeMs = Date.now();
|
|
this._paceReferenceGameTimeMs = this._gameTimeMs;
|
|
}
|
|
if (Date.now() - this._lastYieldTimeMs < YIELD_BUDGET_MS) return;
|
|
// Fast (unpaced) run: don't wait for an animation frame at every
|
|
// yield - with a slow renderer (software WebGL...), rendering would
|
|
// dominate the wall-clock time of the run. Yield through a timer
|
|
// (pending events, like a stop request, are still processed) and
|
|
// only let an animation frame - which renders the game - happen a
|
|
// few times per second.
|
|
if (
|
|
Date.now() - this._lastRenderTimeMs >=
|
|
Math.max(
|
|
FAST_RUN_RENDER_INTERVAL_MS,
|
|
FAST_RUN_RENDER_DUTY_MULTIPLIER * this._lastRenderDurationMs
|
|
)
|
|
) {
|
|
await this._renderOnce();
|
|
} else {
|
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
}
|
|
this._lastYieldTimeMs = Date.now();
|
|
}
|
|
|
|
/**
|
|
* Load and start the given scene, replacing any running scene.
|
|
*/
|
|
async goToScene(
|
|
sceneName: string,
|
|
options?: { skipCreatingInstances?: boolean }
|
|
): Promise<void> {
|
|
if (!this._runtimeGame.hasScene(sceneName)) {
|
|
throw new Error(
|
|
`The scene "${sceneName}" does not exist in the game.`
|
|
);
|
|
}
|
|
if (!this._runtimeGame.areSceneAssetsReady(sceneName)) {
|
|
// On the web, this can download assets for a long time (notably
|
|
// the first run of a preview): waited for as loading, outside of
|
|
// the `timeoutMs` budget.
|
|
await this._awaitLoading(
|
|
this._runtimeGame.loadSceneAssets(sceneName),
|
|
`The assets of the scene "${sceneName}"`
|
|
);
|
|
}
|
|
this._checkGuards();
|
|
this._runtimeGame
|
|
.getSceneStack()
|
|
.runWithSceneChangeCause(GAMEPLAY_TEST_SCENE_CHANGE_CAUSE, () =>
|
|
this._runtimeGame.getSceneStack().replace({
|
|
sceneName,
|
|
clear: true,
|
|
skipCreatingInstances: options
|
|
? options.skipCreatingInstances
|
|
: undefined,
|
|
})
|
|
);
|
|
// Step one frame so the scene is fully initialized ("beginning of
|
|
// scene" events have run) before the test continues.
|
|
await this.stepFrames(1);
|
|
}
|
|
|
|
/**
|
|
* Step the given number of game frames.
|
|
*/
|
|
async stepFrames(
|
|
frameCount: integer,
|
|
options?: {
|
|
dtMs?: float;
|
|
onFrame?: (context: { frame: integer }) => void;
|
|
}
|
|
): Promise<void> {
|
|
const dtMs = (options && options.dtMs) || DEFAULT_FRAME_DT_MS;
|
|
for (let i = 0; i < frameCount; i++) {
|
|
this._stepSingleFrame(dtMs);
|
|
await this._waitForGameSceneSwitchIfNeeded();
|
|
if (options && options.onFrame) {
|
|
options.onFrame({ frame: this._framesExecuted });
|
|
}
|
|
await this._maybeYield();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Step frames until the condition returns true, or `maxFrames` frames
|
|
* were stepped. Returns true if the condition was met.
|
|
*/
|
|
async stepUntil(
|
|
condition: () => boolean,
|
|
options: {
|
|
maxFrames: integer;
|
|
onFrame?: (context: { frame: integer }) => void;
|
|
stuckDetection?: {
|
|
objectName: string;
|
|
windowFrames?: integer;
|
|
minDisplacement?: float;
|
|
onStuck?: (context: {
|
|
frame: integer;
|
|
x: float;
|
|
y: float;
|
|
z: float;
|
|
}) => void;
|
|
};
|
|
}
|
|
): Promise<boolean> {
|
|
const stuckDetection = options.stuckDetection || null;
|
|
const windowFrames =
|
|
(stuckDetection && stuckDetection.windowFrames) || 30;
|
|
const minDisplacement =
|
|
(stuckDetection && stuckDetection.minDisplacement) || 5;
|
|
let lastCheckPosition: { x: float; y: float; z: float } | null = null;
|
|
let framesSinceLastCheck = 0;
|
|
|
|
for (let i = 0; i < options.maxFrames; i++) {
|
|
if (condition()) return true;
|
|
this._stepSingleFrame(DEFAULT_FRAME_DT_MS);
|
|
await this._waitForGameSceneSwitchIfNeeded();
|
|
if (options.onFrame) {
|
|
options.onFrame({ frame: this._framesExecuted });
|
|
}
|
|
|
|
if (stuckDetection) {
|
|
const instances = this.getObjects(stuckDetection.objectName);
|
|
if (instances.length > 0) {
|
|
const position = {
|
|
x: instances[0].x,
|
|
y: instances[0].y,
|
|
z: instances[0].z || 0,
|
|
};
|
|
framesSinceLastCheck++;
|
|
if (framesSinceLastCheck >= windowFrames) {
|
|
if (lastCheckPosition) {
|
|
const displacement = Math.hypot(
|
|
position.x - lastCheckPosition.x,
|
|
position.y - lastCheckPosition.y,
|
|
position.z - lastCheckPosition.z
|
|
);
|
|
if (displacement < minDisplacement) {
|
|
this._recordEvent({
|
|
frame: this._framesExecuted,
|
|
event: 'stuck',
|
|
object: stuckDetection.objectName,
|
|
});
|
|
// Give a clean slate to the `onStuck` handler.
|
|
this.releaseAllInputs();
|
|
if (stuckDetection.onStuck) {
|
|
stuckDetection.onStuck({
|
|
frame: this._framesExecuted,
|
|
...position,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
lastCheckPosition = position;
|
|
framesSinceLastCheck = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
await this._maybeYield();
|
|
}
|
|
return condition();
|
|
}
|
|
|
|
/**
|
|
* Step until the first instance of the object stops moving: its
|
|
* position changed less than `tolerance` (default 0.5) per frame for
|
|
* `stableFrames` (default 20) consecutive frames. Returns whether it
|
|
* settled within `maxFrames` (default 300). Use this for physics
|
|
* objects instead of a hand-written "read, compare, update"
|
|
* condition in `stepUntil` (which can silently pass without
|
|
* stepping a frame).
|
|
*/
|
|
async stepUntilObjectIsStable(
|
|
objectName: string,
|
|
options?: {
|
|
tolerance?: float;
|
|
stableFrames?: integer;
|
|
maxFrames?: integer;
|
|
}
|
|
): Promise<boolean> {
|
|
const tolerance = (options && options.tolerance) || 0.5;
|
|
const stableFrames = (options && options.stableFrames) || 20;
|
|
const maxFrames = (options && options.maxFrames) || 300;
|
|
let previous: [float, float, float] | null = null;
|
|
let stillFrames = 0;
|
|
return await this.stepUntil(() => stillFrames >= stableFrames, {
|
|
maxFrames,
|
|
onFrame: () => {
|
|
const instance = this._getInstances(objectName)[0];
|
|
if (!instance) {
|
|
previous = null;
|
|
stillFrames = 0;
|
|
return;
|
|
}
|
|
const anyInstance = instance as any;
|
|
const x = instance.getX();
|
|
const y = instance.getY();
|
|
const z =
|
|
typeof anyInstance.getZ === 'function' ? anyInstance.getZ() : 0;
|
|
if (
|
|
previous &&
|
|
Math.abs(x - previous[0]) < tolerance &&
|
|
Math.abs(y - previous[1]) < tolerance &&
|
|
Math.abs(z - previous[2]) < tolerance
|
|
) {
|
|
stillFrames++;
|
|
} else {
|
|
stillFrames = 0;
|
|
}
|
|
previous = [x, y, z];
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get the name of the scene being run.
|
|
*/
|
|
getSceneName(): string {
|
|
return this._getCurrentScene().getName();
|
|
}
|
|
|
|
/**
|
|
* Get the names of all the scenes on the scene stack (the last one
|
|
* is the current scene).
|
|
*/
|
|
getSceneStack(): Array<string> {
|
|
return this._runtimeGame.getSceneStack().getAllSceneNames();
|
|
}
|
|
|
|
// INPUT:
|
|
|
|
/**
|
|
* Press or release a keyboard key. Accepts GDevelop event-sheet key
|
|
* names ("Left", "Space", "a"...) and Web API names ("ArrowLeft"...).
|
|
*/
|
|
setKeyPressed(keyName: string, pressed: boolean): void {
|
|
const locationAwareKeyCode = getLocationAwareKeyCodeForName(keyName);
|
|
if (locationAwareKeyCode === null) {
|
|
throw new Error(
|
|
`Unknown key name: "${keyName}". Use GDevelop key names (like "Left", "Space", "a", "Num1") or Web API names (like "ArrowLeft").`
|
|
);
|
|
}
|
|
const inputManager = this._runtimeGame.getInputManager();
|
|
const rawKeyCode = locationAwareKeyCode % 1000;
|
|
const location = Math.floor(locationAwareKeyCode / 1000);
|
|
if (pressed) {
|
|
inputManager.onKeyPressed(rawKeyCode, location);
|
|
} else {
|
|
inputManager.onKeyReleased(rawKeyCode, location);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Move the mouse cursor to a position, expressed in the scene
|
|
* coordinates of the given layer (pass the layer of the object you
|
|
* want to point at).
|
|
*/
|
|
setMousePosition(x: float, y: float, layerName: string = ''): void {
|
|
const currentScene = this._getCurrentScene();
|
|
if (!currentScene.hasLayer(layerName)) {
|
|
throw new Error(`The layer "${layerName}" does not exist.`);
|
|
}
|
|
const layer = currentScene.getLayer(layerName);
|
|
const screenPosition = this._convertSceneToScreenPosition(layer, x, y);
|
|
this._runtimeGame
|
|
.getInputManager()
|
|
.onMouseMove(screenPosition[0], screenPosition[1]);
|
|
}
|
|
|
|
/**
|
|
* Convert a position in scene coordinates of a layer to screen
|
|
* coordinates - through the actual 3D camera when the layer has a
|
|
* 3D-rotated one: the engine's 2D conversion
|
|
* (`convertInverseCoords`) does not handle 3D rotations, while games
|
|
* read the cursor back through the 3D-aware `convertCoords`, so the
|
|
* 2D conversion would place the cursor somewhere else than asked.
|
|
* TODO: this inversion belongs in GDJS itself
|
|
* (`RuntimeLayer.convertInverseCoords`), so that every consumer gets
|
|
* the right conversion - do it there and remove this.
|
|
*/
|
|
private _convertSceneToScreenPosition(
|
|
layer: gdjs.RuntimeLayer,
|
|
x: float,
|
|
y: float
|
|
): FloatPoint {
|
|
if (
|
|
Math.abs(layer.getCameraRotationX()) > 0.001 ||
|
|
Math.abs(layer.getCameraRotationY()) > 0.001
|
|
) {
|
|
const renderer = layer.getRenderer() as any;
|
|
const threeCamera =
|
|
renderer && typeof renderer.getThreeCamera === 'function'
|
|
? renderer.getThreeCamera()
|
|
: null;
|
|
if (threeCamera && typeof THREE !== 'undefined') {
|
|
threeCamera.updateMatrixWorld();
|
|
// The three.js world Y axis is the opposite of the scene one
|
|
// (see `transformTo3DWorld`, the inverse of this projection).
|
|
const vector = new THREE.Vector3(x, -y, 0);
|
|
vector.project(threeCamera);
|
|
if (Number.isFinite(vector.x) && Number.isFinite(vector.y)) {
|
|
return [
|
|
((vector.x + 1) / 2) * layer.getWidth(),
|
|
((1 - vector.y) / 2) * layer.getHeight(),
|
|
];
|
|
}
|
|
}
|
|
}
|
|
return layer.convertInverseCoords(x, y, 0, [0, 0]);
|
|
}
|
|
|
|
/**
|
|
* Move the mouse cursor to a position in game resolution ("screen")
|
|
* coordinates.
|
|
*/
|
|
setMousePositionScreen(screenX: float, screenY: float): void {
|
|
this._runtimeGame.getInputManager().onMouseMove(screenX, screenY);
|
|
}
|
|
|
|
/**
|
|
* Apply a mouse movement delta (for pointer-lock/FPS mouse look).
|
|
* Call once per frame, from `onFrame`.
|
|
*/
|
|
setMouseDelta(deltaX: float, deltaY: float): void {
|
|
if (!this._pointerLockRequestedByGame && !this._hasWarnedMouseDelta) {
|
|
this._hasWarnedMouseDelta = true;
|
|
this._recordConsoleLog(
|
|
'warn',
|
|
'setMouseDelta was called but the game never requested the pointer lock. ' +
|
|
'If the camera does not rotate, the game probably engages mouse-look after a click: ' +
|
|
'send one first (press, step 1 frame, release, step 1 frame).'
|
|
);
|
|
}
|
|
const inputManager = this._runtimeGame.getInputManager();
|
|
inputManager.onMouseMove(
|
|
inputManager.getMouseX(),
|
|
inputManager.getMouseY(),
|
|
{ movementX: deltaX, movementY: deltaY }
|
|
);
|
|
// Mouse-look extensions often listen to the canvas `pointermove`
|
|
// DOM events directly (instead of the input manager): dispatch a
|
|
// real event carrying the deltas so they receive them too.
|
|
const renderer = this._runtimeGame.getRenderer() as any;
|
|
const canvas =
|
|
typeof renderer.getCanvas === 'function'
|
|
? renderer.getCanvas()
|
|
: null;
|
|
if (canvas && typeof PointerEvent !== 'undefined') {
|
|
const event = new PointerEvent('pointermove', { bubbles: true });
|
|
Object.defineProperty(event, 'movementX', { value: deltaX });
|
|
Object.defineProperty(event, 'movementY', { value: deltaY });
|
|
canvas.dispatchEvent(event);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Press or release a mouse button ('left', 'right' or 'middle').
|
|
*/
|
|
setMouseButtonPressed(
|
|
pressed: boolean,
|
|
button: 'left' | 'right' | 'middle' = 'left'
|
|
): void {
|
|
const inputManager = this._runtimeGame.getInputManager();
|
|
const buttonCode = mouseButtonNameToCode(button);
|
|
if (pressed) {
|
|
inputManager.onMouseButtonPressed(buttonCode);
|
|
} else {
|
|
inputManager.onMouseButtonReleased(buttonCode);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start a touch at a position expressed in the scene coordinates of
|
|
* the given layer.
|
|
*/
|
|
touchStart(
|
|
identifier: integer,
|
|
x: float,
|
|
y: float,
|
|
layerName: string = ''
|
|
): void {
|
|
const layer = this._getCurrentScene().getLayer(layerName);
|
|
const screenPosition = this._convertSceneToScreenPosition(layer, x, y);
|
|
this._runtimeGame
|
|
.getInputManager()
|
|
.onTouchStart(identifier, screenPosition[0], screenPosition[1]);
|
|
}
|
|
|
|
/**
|
|
* Move a touch to a position expressed in the scene coordinates of
|
|
* the given layer.
|
|
*/
|
|
touchMove(
|
|
identifier: integer,
|
|
x: float,
|
|
y: float,
|
|
layerName: string = ''
|
|
): void {
|
|
const layer = this._getCurrentScene().getLayer(layerName);
|
|
const screenPosition = this._convertSceneToScreenPosition(layer, x, y);
|
|
this._runtimeGame
|
|
.getInputManager()
|
|
.onTouchMove(identifier, screenPosition[0], screenPosition[1]);
|
|
}
|
|
|
|
/**
|
|
* End a touch.
|
|
*/
|
|
touchEnd(identifier: integer): void {
|
|
this._runtimeGame.getInputManager().onTouchEnd(identifier);
|
|
}
|
|
|
|
/**
|
|
* Get the game resolution width, in pixels.
|
|
*/
|
|
getGameResolutionWidth(): float {
|
|
return this._runtimeGame.getGameResolutionWidth();
|
|
}
|
|
|
|
/**
|
|
* Get the game resolution height, in pixels.
|
|
*/
|
|
getGameResolutionHeight(): float {
|
|
return this._runtimeGame.getGameResolutionHeight();
|
|
}
|
|
|
|
/** Undo the game window size override (see `setGameResolutionSize`). */
|
|
_uninstallGameWindowSizeOverride: () => void = () => {};
|
|
|
|
/**
|
|
* Change the game resolution, as if the game window was resized to
|
|
* this size. Useful to test layouts made for other screen sizes,
|
|
* before asserting positions or taking a screenshot.
|
|
*
|
|
* This is the ONLY way the game resolution changes during a test:
|
|
* a gameplay test always runs in a game window of the project's game
|
|
* resolution, and moving, resizing or minimizing the gameplay test
|
|
* frame in the editor is never visible to the game (the frame is
|
|
* only a zoomed view of that fixed window).
|
|
*
|
|
* The engine sees a game window of this size until the end of the
|
|
* test: a game adapting its resolution to the game window size would
|
|
* otherwise recompute it from the real window, ignoring the size
|
|
* requested here.
|
|
*/
|
|
setGameResolutionSize(width: float, height: float): void {
|
|
this._uninstallGameWindowSizeOverride();
|
|
const original = {
|
|
getWindowInnerWidth: gdjs.RuntimeGameRenderer.getWindowInnerWidth,
|
|
getWindowInnerHeight: gdjs.RuntimeGameRenderer.getWindowInnerHeight,
|
|
};
|
|
gdjs.RuntimeGameRenderer.getWindowInnerWidth = () => width;
|
|
gdjs.RuntimeGameRenderer.getWindowInnerHeight = () => height;
|
|
this._uninstallGameWindowSizeOverride = () => {
|
|
gdjs.RuntimeGameRenderer.getWindowInnerWidth =
|
|
original.getWindowInnerWidth;
|
|
gdjs.RuntimeGameRenderer.getWindowInnerHeight =
|
|
original.getWindowInnerHeight;
|
|
this._uninstallGameWindowSizeOverride = () => {};
|
|
};
|
|
|
|
this._runtimeGame.setGameResolutionSize(width, height);
|
|
// The paused main loop would only adapt the cameras on its next
|
|
// animation frame: do it now, so the next stepped frame is laid out
|
|
// for the new resolution.
|
|
this._runtimeGame.getSceneStack().onGameResolutionResized();
|
|
}
|
|
|
|
/**
|
|
* Release all pressed keys, mouse buttons and touches.
|
|
*/
|
|
releaseAllInputs(): void {
|
|
const inputManager = this._runtimeGame.getInputManager();
|
|
inputManager.releaseAllPressedKeys();
|
|
inputManager.onMouseButtonReleased(gdjs.InputManager.MOUSE_LEFT_BUTTON);
|
|
inputManager.onMouseButtonReleased(
|
|
gdjs.InputManager.MOUSE_RIGHT_BUTTON
|
|
);
|
|
inputManager.onMouseButtonReleased(
|
|
gdjs.InputManager.MOUSE_MIDDLE_BUTTON
|
|
);
|
|
for (const identifier of inputManager.getAllTouchIdentifiers()) {
|
|
// Public identifiers are raw identifiers + 2.
|
|
inputManager.onTouchEnd(identifier - 2);
|
|
}
|
|
}
|
|
|
|
// INSPECTION:
|
|
|
|
private _makeObjectSnapshot(
|
|
object: gdjs.RuntimeObject,
|
|
includeChildren: boolean
|
|
): GameplayTestObjectSnapshot {
|
|
const anyObject = object as any;
|
|
const stateInspectors = this._payload.stateInspectors || null;
|
|
const behaviors: {
|
|
[behaviorName: string]: {
|
|
act: boolean;
|
|
state: GameplayTestEvaluatedState;
|
|
};
|
|
} = {};
|
|
// Access the protected list of behaviors of the object.
|
|
const objectBehaviors =
|
|
anyObject._behaviors as Array<gdjs.RuntimeBehavior>;
|
|
for (const behavior of objectBehaviors) {
|
|
behaviors[behavior.getName()] = {
|
|
act: behavior.activated(),
|
|
state: evaluateStateInspector(
|
|
behavior,
|
|
(stateInspectors &&
|
|
stateInspectors.behaviors[(behavior as any).type]) ||
|
|
null,
|
|
`the behavior "${behavior.getName()}"`
|
|
),
|
|
};
|
|
}
|
|
const objectState = evaluateStateInspector(
|
|
object,
|
|
(stateInspectors && stateInspectors.objects[object.type]) || null,
|
|
`the object "${object.getName()}"`
|
|
);
|
|
|
|
const snapshot: GameplayTestObjectSnapshot = {
|
|
id: object.id,
|
|
name: object.getName(),
|
|
x: object.getX(),
|
|
y: object.getY(),
|
|
angle: object.getAngle(),
|
|
width: object.getWidth(),
|
|
height: object.getHeight(),
|
|
centerX: object.getCenterXInScene(),
|
|
centerY: object.getCenterYInScene(),
|
|
layer: object.getLayer(),
|
|
hidden: object.isHidden(),
|
|
variables: object.getVariables().getNetworkSyncData({}),
|
|
state: objectState,
|
|
behaviors,
|
|
};
|
|
if (typeof anyObject.getZ === 'function') {
|
|
snapshot.z = anyObject.getZ();
|
|
if (typeof anyObject.getCenterZInScene === 'function') {
|
|
snapshot.centerZ = anyObject.getCenterZInScene();
|
|
}
|
|
}
|
|
if (typeof anyObject.getRotationX === 'function') {
|
|
snapshot.rotationX = anyObject.getRotationX();
|
|
}
|
|
if (typeof anyObject.getRotationY === 'function') {
|
|
snapshot.rotationY = anyObject.getRotationY();
|
|
}
|
|
if (typeof anyObject.getDepth === 'function') {
|
|
snapshot.depth = anyObject.getDepth();
|
|
}
|
|
if (typeof anyObject.getAnimationName === 'function') {
|
|
snapshot.animation = anyObject.getAnimationName();
|
|
}
|
|
if (typeof anyObject.getText === 'function') {
|
|
snapshot.text = anyObject.getText();
|
|
} else if (typeof anyObject.getString === 'function') {
|
|
snapshot.text = anyObject.getString();
|
|
}
|
|
if (typeof anyObject.getOpacity === 'function') {
|
|
snapshot.opacity = anyObject.getOpacity();
|
|
}
|
|
if (typeof anyObject.isFlippedX === 'function') {
|
|
snapshot.flippedX = anyObject.isFlippedX();
|
|
}
|
|
if (typeof anyObject.isFlippedY === 'function') {
|
|
snapshot.flippedY = anyObject.isFlippedY();
|
|
}
|
|
if (
|
|
includeChildren &&
|
|
typeof anyObject.getChildrenContainer === 'function'
|
|
) {
|
|
const childrenContainer: gdjs.RuntimeInstanceContainer =
|
|
anyObject.getChildrenContainer();
|
|
const children: {
|
|
[objectName: string]: Array<GameplayTestObjectSnapshot>;
|
|
} = {};
|
|
const canTransformToScene =
|
|
typeof anyObject.applyObjectTransformation === 'function';
|
|
for (const child of childrenContainer.getAdhocListOfAllInstances()) {
|
|
const childName = child.getName();
|
|
if (!children[childName]) children[childName] = [];
|
|
const childSnapshot = this._makeObjectSnapshot(child, false);
|
|
// The children live in the coordinates space of the custom
|
|
// object: convert to scene coordinates, like every other
|
|
// snapshot (so clicking a child at its centerX/centerY works).
|
|
if (canTransformToScene) {
|
|
const point: FloatPoint = [0, 0];
|
|
anyObject.applyObjectTransformation(
|
|
childSnapshot.x,
|
|
childSnapshot.y,
|
|
point
|
|
);
|
|
childSnapshot.x = point[0];
|
|
childSnapshot.y = point[1];
|
|
anyObject.applyObjectTransformation(
|
|
childSnapshot.centerX,
|
|
childSnapshot.centerY,
|
|
point
|
|
);
|
|
childSnapshot.centerX = point[0];
|
|
childSnapshot.centerY = point[1];
|
|
if (typeof anyObject.getZ === 'function') {
|
|
if (childSnapshot.z !== undefined)
|
|
childSnapshot.z += anyObject.getZ();
|
|
if (childSnapshot.centerZ !== undefined)
|
|
childSnapshot.centerZ += anyObject.getZ();
|
|
}
|
|
}
|
|
// The internal layer name of the parent means nothing outside
|
|
// of it: report the layer of the custom object itself.
|
|
childSnapshot.layer = object.getLayer();
|
|
children[childName].push(childSnapshot);
|
|
}
|
|
snapshot.children = children;
|
|
}
|
|
return snapshot;
|
|
}
|
|
|
|
private _getInstances(objectName: string): Array<gdjs.RuntimeObject> {
|
|
return this._getCurrentScene().getObjects(objectName) || [];
|
|
}
|
|
|
|
/**
|
|
* Get a state snapshot of all the instances of an object.
|
|
* Instances are returned in an unspecified order.
|
|
*/
|
|
getObjects(objectName: string): Array<GameplayTestObjectSnapshot> {
|
|
return this._getInstances(objectName).map((object) =>
|
|
this._makeObjectSnapshot(object, true)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get the instances of `objectName` within `radius` of the first
|
|
* instance of `referenceObjectName`, sorted by distance.
|
|
*/
|
|
getNearby(
|
|
objectName: string,
|
|
referenceObjectName: string,
|
|
radius: float
|
|
): Array<GameplayTestNearbyObjectSnapshot> {
|
|
const referenceInstances = this._getInstances(referenceObjectName);
|
|
if (referenceInstances.length === 0) return [];
|
|
const reference = this._makeObjectSnapshot(
|
|
referenceInstances[0],
|
|
false
|
|
);
|
|
const referenceZ = reference.centerZ || 0;
|
|
|
|
const nearby: Array<GameplayTestNearbyObjectSnapshot> = [];
|
|
for (const object of this._getInstances(objectName)) {
|
|
const snapshot = this._makeObjectSnapshot(object, true);
|
|
const relativeX = snapshot.centerX - reference.centerX;
|
|
const relativeY = snapshot.centerY - reference.centerY;
|
|
const relativeZ = (snapshot.centerZ || 0) - referenceZ;
|
|
const distance = Math.hypot(relativeX, relativeY, relativeZ);
|
|
if (distance > radius) continue;
|
|
nearby.push({
|
|
...snapshot,
|
|
distance,
|
|
relativeX,
|
|
relativeY,
|
|
relativeZ: snapshot.centerZ === undefined ? undefined : relativeZ,
|
|
above: relativeY < 0,
|
|
below: relativeY > 0,
|
|
left: relativeX < 0,
|
|
right: relativeX > 0,
|
|
bearingFromReference: gdjs.toDegrees(
|
|
Math.atan2(relativeY, relativeX)
|
|
),
|
|
});
|
|
}
|
|
nearby.sort((a, b) => a.distance - b.distance);
|
|
return nearby;
|
|
}
|
|
|
|
/**
|
|
* Check if the straight segment between the first instance of
|
|
* `referenceObjectName` and the first instance of `targetObjectName`
|
|
* is clear of the given blocker objects. 2D ONLY: the test uses the
|
|
* 2D hitboxes in the X/Y plane and ignores Z.
|
|
*/
|
|
has2dLineOfSight(
|
|
referenceObjectName: string,
|
|
targetObjectName: string,
|
|
blockerObjectNames: Array<string>
|
|
): {
|
|
clear: boolean;
|
|
blockedBy?: string;
|
|
blockedAt?: { x: float; y: float };
|
|
} {
|
|
const referenceInstances = this._getInstances(referenceObjectName);
|
|
const targetInstances = this._getInstances(targetObjectName);
|
|
if (referenceInstances.length === 0 || targetInstances.length === 0) {
|
|
return { clear: false, blockedBy: 'missing-object' };
|
|
}
|
|
const x0 = referenceInstances[0].getCenterXInScene();
|
|
const y0 = referenceInstances[0].getCenterYInScene();
|
|
const x1 = targetInstances[0].getCenterXInScene();
|
|
const y1 = targetInstances[0].getCenterYInScene();
|
|
|
|
let closestResult: {
|
|
blockedBy: string;
|
|
x: float;
|
|
y: float;
|
|
sqDistance: float;
|
|
} | null = null;
|
|
for (const blockerObjectName of blockerObjectNames) {
|
|
for (const blocker of this._getInstances(blockerObjectName)) {
|
|
const result = blocker.raycastTest(x0, y0, x1, y1, true);
|
|
if (result.collision) {
|
|
const sqDistance =
|
|
(result.closeX - x0) * (result.closeX - x0) +
|
|
(result.closeY - y0) * (result.closeY - y0);
|
|
if (!closestResult || sqDistance < closestResult.sqDistance) {
|
|
closestResult = {
|
|
blockedBy: blockerObjectName,
|
|
x: result.closeX,
|
|
y: result.closeY,
|
|
sqDistance,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (closestResult) {
|
|
return {
|
|
clear: false,
|
|
blockedBy: closestResult.blockedBy,
|
|
blockedAt: { x: closestResult.x, y: closestResult.y },
|
|
};
|
|
}
|
|
return { clear: true };
|
|
}
|
|
|
|
/**
|
|
* LAST RESORT: the raw `gdjs.RuntimeGame` being tested. Prefer the
|
|
* harness APIs (snapshots, inputs, stepping...) - direct mutations
|
|
* can invalidate what the test asserts.
|
|
*/
|
|
getRuntimeGame(): gdjs.RuntimeGame {
|
|
return this._runtimeGame;
|
|
}
|
|
|
|
/**
|
|
* LAST RESORT: the raw `gdjs.RuntimeScene` currently being played
|
|
* (throws if no scene is running). Prefer the harness APIs.
|
|
*/
|
|
getCurrentRuntimeScene(): gdjs.RuntimeScene {
|
|
return this._getCurrentScene();
|
|
}
|
|
|
|
/**
|
|
* The raw `gdjs.RuntimeLayer` of the current scene, or null if the
|
|
* layer does not exist. Useful to check the layer visibility
|
|
* (`isVisible()`) or read the camera (`getCameraX()`, `getCameraZoom()`...).
|
|
*/
|
|
getRuntimeLayer(layerName: string): gdjs.RuntimeLayer | null {
|
|
const currentScene = this._getCurrentScene();
|
|
if (!currentScene.hasLayer(layerName)) return null;
|
|
return currentScene.getLayer(layerName);
|
|
}
|
|
|
|
/**
|
|
* The camera of a layer (JSON-safe), or null if the layer does not
|
|
* exist. `angle` is the 2D rotation (the yaw of the view, same
|
|
* convention as object angles). `rotationX`/`rotationY` are the 3D
|
|
* camera rotations: `rotationX` is 0 looking straight down (the 2D
|
|
* default) and 90 at the horizon, so the pitch of a first-person
|
|
* view is `rotationX - 90` (positive = looking up).
|
|
*/
|
|
getCameraState(layerName: string = ''): {
|
|
x: float;
|
|
y: float;
|
|
z: float;
|
|
rotationX: float;
|
|
rotationY: float;
|
|
angle: float;
|
|
zoom: float;
|
|
} | null {
|
|
const currentScene = this._getCurrentScene();
|
|
if (!currentScene.hasLayer(layerName)) return null;
|
|
const layer = currentScene.getLayer(layerName);
|
|
return {
|
|
x: layer.getCameraX(),
|
|
y: layer.getCameraY(),
|
|
z: layer.getCameraZ(null),
|
|
rotationX: layer.getCameraRotationX(),
|
|
rotationY: layer.getCameraRotationY(),
|
|
angle: layer.getCameraRotation(),
|
|
zoom: layer.getCameraZoom(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The raw `gdjs.RuntimeObject` of an instance, or null if not found.
|
|
* Behaviors can be reached with `getBehavior(behaviorName)` (also null
|
|
* if not found). Prefer the harness APIs (snapshots, inputs...) -
|
|
* direct mutations can invalidate what the test asserts.
|
|
* @param objectIdOrName An instance id (from `getObjects`) or an object
|
|
* name (first instance).
|
|
*/
|
|
getRuntimeObject(
|
|
objectIdOrName: integer | string
|
|
): gdjs.RuntimeObject | null {
|
|
const currentScene = this._getCurrentScene();
|
|
if (typeof objectIdOrName === 'number') {
|
|
for (const candidate of currentScene.getAdhocListOfAllInstances()) {
|
|
if (candidate.id === objectIdOrName) {
|
|
return candidate;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
return currentScene.getObjects(objectIdOrName)?.[0] || null;
|
|
}
|
|
|
|
/**
|
|
* Get a scene variable, or undefined if it does not exist.
|
|
*/
|
|
getSceneVariable(
|
|
variableName: string
|
|
): VariableNetworkSyncData | undefined {
|
|
return this._getCurrentScene()
|
|
.getVariables()
|
|
.getNetworkSyncData({})
|
|
.find((variable) => variable.name === variableName);
|
|
}
|
|
|
|
/**
|
|
* Get a global variable, or undefined if it does not exist.
|
|
*/
|
|
getGlobalVariable(
|
|
variableName: string
|
|
): VariableNetworkSyncData | undefined {
|
|
return this._runtimeGame
|
|
.getVariables()
|
|
.getNetworkSyncData({})
|
|
.find((variable) => variable.name === variableName);
|
|
}
|
|
|
|
/**
|
|
* The notable events recorded so far (scene changes and resets with
|
|
* their cause, stuck detections...), as a JSON-safe copy: lets a
|
|
* test assert, for example, that the game restarted its scene.
|
|
*/
|
|
getEventLog(): Array<GameplayTestEvent> {
|
|
return this._eventLog.map((event) => ({ ...event }));
|
|
}
|
|
|
|
/**
|
|
* The sounds and musics played during the test so far, with the
|
|
* frame they were played at - a direct signal that a mechanic fired
|
|
* (a pickup, a shot, an explosion...).
|
|
*/
|
|
getPlayedSounds(): Array<{ sound: string; frame: integer }> {
|
|
this._drainPlayedSounds();
|
|
return this._playedSounds.slice();
|
|
}
|
|
|
|
_installSoundLog(): void {
|
|
this._runtimeGame.getSoundManager().setPlayedSoundsLogEnabled(true);
|
|
}
|
|
|
|
_uninstallSoundLog(): void {
|
|
this._runtimeGame.getSoundManager().setPlayedSoundsLogEnabled(false);
|
|
}
|
|
|
|
/** Copy the new entries of the sound manager log, stamped with the
|
|
* current frame. */
|
|
private _drainPlayedSounds(): void {
|
|
const log = this._runtimeGame.getSoundManager().getPlayedSoundsLog();
|
|
while (
|
|
this._playedSoundsReadCount < log.length &&
|
|
this._playedSounds.length < MAX_PLAYED_SOUNDS
|
|
) {
|
|
this._playedSounds.push({
|
|
sound: log[this._playedSoundsReadCount].soundName,
|
|
frame: this._framesExecuted,
|
|
});
|
|
this._playedSoundsReadCount++;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Include full snapshots of this object's instances in the final
|
|
* state of the test result.
|
|
*/
|
|
watch(objectName: string): void {
|
|
if (!this._watchedObjectNames.includes(objectName)) {
|
|
this._watchedObjectNames.push(objectName);
|
|
}
|
|
}
|
|
|
|
// NAVIGATION INTENT:
|
|
|
|
private _resolveNavigationTarget(
|
|
target:
|
|
| { name: string; id?: integer }
|
|
| { x: float; y: float; z?: float }
|
|
): { x: float; y: float; z: float | undefined } | null {
|
|
if ('name' in target) {
|
|
const instances = this._getInstances(target.name);
|
|
let instance =
|
|
target.id !== undefined
|
|
? instances.find((object) => object.id === target.id)
|
|
: instances[0];
|
|
if (!instance) return null;
|
|
const anyInstance = instance as any;
|
|
return {
|
|
x: instance.getCenterXInScene(),
|
|
y: instance.getCenterYInScene(),
|
|
z:
|
|
typeof anyInstance.getCenterZInScene === 'function'
|
|
? anyInstance.getCenterZInScene()
|
|
: undefined,
|
|
};
|
|
}
|
|
return { x: target.x, y: target.y, z: target.z };
|
|
}
|
|
|
|
/**
|
|
* Get the position of a target (an object or a position) relative to
|
|
* the first instance of `referenceObjectName` (2D and 3D). Deciding
|
|
* how to move toward the target with the game's actual controls is
|
|
* the job of the test script (use `resetSceneAndProbeControls` to
|
|
* discover the controls, `makeProgressTracker` to detect a lack of
|
|
* progress).
|
|
*/
|
|
getRelativePosition(
|
|
referenceObjectName: string,
|
|
target:
|
|
| { name: string; id?: integer }
|
|
| { x: float; y: float; z?: float },
|
|
options?: {
|
|
reachRadius?: float;
|
|
/** Measure from the camera of this layer (position, yaw and
|
|
* pitch) instead of the object: what a first-person view aims
|
|
* with. */
|
|
fromCamera?: string;
|
|
/** Measure from this Z instead of the object's center Z (an eye
|
|
* or muzzle height). */
|
|
fromZ?: float;
|
|
/** Compute `yawDiff` against this heading (in degrees) instead of
|
|
* the object's angle - for turrets and weapons with their own
|
|
* rotation. */
|
|
heading?: float;
|
|
}
|
|
): GameplayTestRelativePosition | null {
|
|
const referenceInstances = this._getInstances(referenceObjectName);
|
|
if (referenceInstances.length === 0) return null;
|
|
const reference = referenceInstances[0];
|
|
const anyReference = reference as any;
|
|
const resolvedTarget = this._resolveNavigationTarget(target);
|
|
if (!resolvedTarget) return null;
|
|
|
|
const reachRadius =
|
|
(options && options.reachRadius) || DEFAULT_REACH_RADIUS;
|
|
const camera =
|
|
options && options.fromCamera !== undefined
|
|
? this.getCameraState(options.fromCamera)
|
|
: null;
|
|
|
|
const referenceX =
|
|
camera !== null ? camera.x : reference.getCenterXInScene();
|
|
const referenceY =
|
|
camera !== null ? camera.y : reference.getCenterYInScene();
|
|
const referenceZ =
|
|
options && options.fromZ !== undefined
|
|
? options.fromZ
|
|
: camera !== null
|
|
? camera.z
|
|
: typeof anyReference.getCenterZInScene === 'function'
|
|
? anyReference.getCenterZInScene()
|
|
: undefined;
|
|
|
|
const relativeX = resolvedTarget.x - referenceX;
|
|
const relativeY = resolvedTarget.y - referenceY;
|
|
const relativeZ =
|
|
resolvedTarget.z !== undefined && referenceZ !== undefined
|
|
? resolvedTarget.z - referenceZ
|
|
: undefined;
|
|
|
|
const distance = Math.hypot(relativeX, relativeY, relativeZ || 0);
|
|
|
|
// The world direction a 3D camera looks at is its angle minus 90
|
|
// (with the ZYX rotation order, at the horizon the view direction
|
|
// is the former screen-up - games set the camera angle to
|
|
// "facing + 90", e.g. LookFromObjectEyes).
|
|
const currentYaw =
|
|
options && options.heading !== undefined
|
|
? options.heading
|
|
: camera !== null
|
|
? camera.angle - 90
|
|
: reference.getAngle();
|
|
const desiredAngle = gdjs.toDegrees(Math.atan2(relativeY, relativeX));
|
|
const yawDiff = normalizeAngleDifference(desiredAngle - currentYaw);
|
|
const horizontalDistance = Math.hypot(relativeX, relativeY);
|
|
const desiredPitch =
|
|
relativeZ === undefined
|
|
? 0
|
|
: gdjs.toDegrees(Math.atan2(relativeZ, horizontalDistance));
|
|
// The pitch of a camera view: rotationX - 90 (rotationX is 0
|
|
// looking straight down - a pitch of -90 - and 90 at the horizon).
|
|
const currentPitch =
|
|
camera !== null
|
|
? camera.rotationX - 90
|
|
: typeof anyReference.getRotationX === 'function'
|
|
? anyReference.getRotationX()
|
|
: 0;
|
|
const pitchDiff =
|
|
relativeZ === undefined
|
|
? 0
|
|
: normalizeAngleDifference(desiredPitch - currentPitch);
|
|
|
|
return {
|
|
relativeX,
|
|
relativeY,
|
|
relativeZ,
|
|
distance,
|
|
horizontalDistance,
|
|
yawDiff,
|
|
pitchDiff,
|
|
dominantAxis: Math.abs(relativeX) >= Math.abs(relativeY) ? 'x' : 'y',
|
|
reached: distance <= reachRadius,
|
|
targetX: resolvedTarget.x,
|
|
targetY: resolvedTarget.y,
|
|
targetZ: resolvedTarget.z,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Measure what each key actually does to the first instance of
|
|
* `objectName` (2D and 3D: dz is measured when the object has a Z
|
|
* coordinate): for the baseline (no key) and then each key, the
|
|
* scene is restarted, the key held for `frames` frames, and the
|
|
* displacement (net + extremes: a jump shows as a negative `minDy`
|
|
* even if the object lands back) and yaw change are measured.
|
|
* Compare each key's result to `baseline` (gravity or idle drift
|
|
* affects both). The scene is restarted again at the end, so call
|
|
* this BEFORE the scenario of the test. An entry is null if the
|
|
* instance disappeared during that probe.
|
|
*/
|
|
async resetSceneAndProbeControls(
|
|
objectName: string,
|
|
keyNames: Array<string>,
|
|
options?: { frames?: integer }
|
|
): Promise<{
|
|
baseline: GameplayTestControlProbeResult | null;
|
|
keys: { [keyName: string]: GameplayTestControlProbeResult | null };
|
|
}> {
|
|
const frames = (options && options.frames) || DEFAULT_PROBE_FRAMES;
|
|
const sceneName = this._getCurrentScene().getName();
|
|
this._isProbingControls = true;
|
|
try {
|
|
return await this._probeControls(sceneName, objectName, keyNames, {
|
|
frames,
|
|
});
|
|
} finally {
|
|
this._isProbingControls = false;
|
|
}
|
|
}
|
|
|
|
private async _probeControls(
|
|
sceneName: string,
|
|
objectName: string,
|
|
keyNames: Array<string>,
|
|
{ frames }: { frames: integer }
|
|
): Promise<{
|
|
baseline: GameplayTestControlProbeResult | null;
|
|
keys: { [keyName: string]: GameplayTestControlProbeResult | null };
|
|
}> {
|
|
const probe = async (
|
|
keyName: string | null
|
|
): Promise<GameplayTestControlProbeResult | null> => {
|
|
await this.goToScene(sceneName);
|
|
this.releaseAllInputs();
|
|
const getPosition = () => {
|
|
const instances = this._getInstances(objectName);
|
|
if (instances.length === 0) return null;
|
|
const instance = instances[0];
|
|
const anyInstance = instance as any;
|
|
return {
|
|
x: instance.getX(),
|
|
y: instance.getY(),
|
|
z:
|
|
typeof anyInstance.getZ === 'function'
|
|
? (anyInstance.getZ() as float)
|
|
: undefined,
|
|
angle: instance.getAngle(),
|
|
};
|
|
};
|
|
const start = getPosition();
|
|
if (!start) {
|
|
throw new Error(
|
|
`No instance of "${objectName}" found to probe controls on (after restarting the scene "${sceneName}").`
|
|
);
|
|
}
|
|
let minDx = 0;
|
|
let maxDx = 0;
|
|
let minDy = 0;
|
|
let maxDy = 0;
|
|
let minDz: float | undefined = start.z === undefined ? undefined : 0;
|
|
let maxDz: float | undefined = start.z === undefined ? undefined : 0;
|
|
if (keyName) this.setKeyPressed(keyName, true);
|
|
await this.stepFrames(frames, {
|
|
onFrame: () => {
|
|
const current = getPosition();
|
|
if (!current) return;
|
|
minDx = Math.min(minDx, current.x - start.x);
|
|
maxDx = Math.max(maxDx, current.x - start.x);
|
|
minDy = Math.min(minDy, current.y - start.y);
|
|
maxDy = Math.max(maxDy, current.y - start.y);
|
|
if (current.z !== undefined && start.z !== undefined) {
|
|
minDz = Math.min(minDz || 0, current.z - start.z);
|
|
maxDz = Math.max(maxDz || 0, current.z - start.z);
|
|
}
|
|
},
|
|
});
|
|
if (keyName) this.setKeyPressed(keyName, false);
|
|
const end = getPosition();
|
|
if (!end) return null;
|
|
return {
|
|
dx: end.x - start.x,
|
|
dy: end.y - start.y,
|
|
dz:
|
|
end.z !== undefined && start.z !== undefined
|
|
? end.z - start.z
|
|
: undefined,
|
|
minDx,
|
|
maxDx,
|
|
minDy,
|
|
maxDy,
|
|
minDz,
|
|
maxDz,
|
|
yawDelta: normalizeAngleDifference(end.angle - start.angle),
|
|
};
|
|
};
|
|
|
|
const baseline = await probe(null);
|
|
const keys: {
|
|
[keyName: string]: GameplayTestControlProbeResult | null;
|
|
} = {};
|
|
for (const keyName of keyNames) {
|
|
keys[keyName] = await probe(keyName);
|
|
}
|
|
// Leave a clean state for the actual test scenario.
|
|
await this.goToScene(sceneName);
|
|
this.releaseAllInputs();
|
|
return { baseline, keys };
|
|
}
|
|
|
|
/**
|
|
* Make a tracker measuring the progress of the first instance of
|
|
* `referenceObjectName` toward a target (the distance is 3D when the
|
|
* object has a Z coordinate). Call `update()` regularly (e.g. once
|
|
* per loop iteration): it reports the current `distance`, whether
|
|
* the target is `reached`, and whether progress `stalled` (distance
|
|
* shrank by less than `minProgress` over the last `windowFrames`
|
|
* frames - time to try an escape strategy). The first update of a
|
|
* stall also records a `stuck` event in the event log. Call
|
|
* `reset()` after switching to another target.
|
|
*/
|
|
makeProgressTracker(
|
|
referenceObjectName: string,
|
|
target:
|
|
| { name: string; id?: integer }
|
|
| { x: float; y: float; z?: float },
|
|
options?: {
|
|
windowFrames?: integer;
|
|
minProgress?: float;
|
|
reachRadius?: float;
|
|
}
|
|
): GameplayTestProgressTracker {
|
|
const windowFrames =
|
|
(options && options.windowFrames) || DEFAULT_PROGRESS_WINDOW_FRAMES;
|
|
const minProgress =
|
|
(options && options.minProgress) || DEFAULT_PROGRESS_MIN_PROGRESS;
|
|
const reachRadius =
|
|
(options && options.reachRadius) || DEFAULT_REACH_RADIUS;
|
|
|
|
let samples: Array<{ frame: integer; distance: float }> = [];
|
|
let wasStalled = false;
|
|
|
|
return {
|
|
update: () => {
|
|
const relativePosition = this.getRelativePosition(
|
|
referenceObjectName,
|
|
target,
|
|
{ reachRadius }
|
|
);
|
|
if (!relativePosition) return null;
|
|
const frame = this._framesExecuted;
|
|
const distance = relativePosition.distance;
|
|
samples.push({ frame, distance });
|
|
// Keep only the window (plus the sample right before it, to
|
|
// always have a reference point `windowFrames` old).
|
|
while (
|
|
samples.length > 1 &&
|
|
samples[1].frame <= frame - windowFrames
|
|
) {
|
|
samples.shift();
|
|
}
|
|
const oldest = samples[0];
|
|
const stalled =
|
|
frame - oldest.frame >= windowFrames &&
|
|
oldest.distance - distance < minProgress;
|
|
if (stalled && !wasStalled) {
|
|
this._recordEvent({
|
|
frame,
|
|
event: 'stuck',
|
|
object: referenceObjectName,
|
|
});
|
|
}
|
|
wasStalled = stalled;
|
|
return {
|
|
frame,
|
|
distance,
|
|
reached: relativePosition.reached,
|
|
stalled,
|
|
};
|
|
},
|
|
reset: () => {
|
|
samples = [];
|
|
wasStalled = false;
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Turn the first instance of `referenceObjectName` toward the target,
|
|
* by applying mouse movement deltas (FPS/pointer-lock style, yaw and
|
|
* pitch in 3D) until it is aiming at it. The aim is measured on the
|
|
* camera of the object's layer when it is a 3D one (the ground truth
|
|
* of a first-person view: right eye height, right rotations whatever
|
|
* the game drives to move it - see `measuredFrom` in the result),
|
|
* with a fallback to the object's own rotations. The mouse
|
|
* sensitivity and direction of the game are measured and adapted to
|
|
* live, per axis. When the vertical aim shows no measurable response,
|
|
* the vertical input is undone and the aim falls back to yaw-only,
|
|
* reported as `sawPitchResponse: false`. Returns null if the object
|
|
* or the target is missing.
|
|
*/
|
|
async lookTowardWithMouseDelta(
|
|
referenceObjectName: string,
|
|
target:
|
|
| { name: string; id?: integer }
|
|
| { x: float; y: float; z?: float },
|
|
options?: {
|
|
yawOnly?: boolean;
|
|
fromCamera?: string;
|
|
/** Consider the aim done when both angles are within this
|
|
* tolerance (default 3 degrees - lower it to hit a small or far
|
|
* target). */
|
|
toleranceDegrees?: float;
|
|
}
|
|
): Promise<GameplayTestAimResult | null> {
|
|
const maxAimFrames = 180;
|
|
const toleranceDegrees = (options && options.toleranceDegrees) || 3;
|
|
const responseThresholdDegrees = 0.1;
|
|
const maxPixelsPerDegree = 64;
|
|
// Frames tolerated with a demand on an axis, a maxed-out gain and
|
|
// no measured response, before reacting (giving up the vertical
|
|
// aim, or falling back from the camera to the object).
|
|
const maxUnresponsiveFrames = 10;
|
|
// Pixels of mouse movement per degree of desired rotation: adapted
|
|
// live (per axis) by measuring the actual rotation achieved.
|
|
let yawPixelsPerDegree = 2;
|
|
let pitchPixelsPerDegree = 2;
|
|
// Mouse direction giving a positive rotation on each axis: flipped
|
|
// when a measured response moves the aim away from the target.
|
|
let yawDirection = 1;
|
|
let pitchDirection = 1;
|
|
|
|
// Measure the aim on the camera of the object's layer (the ground
|
|
// truth of a first-person view: right eye height, right rotations
|
|
// whatever the game drives to move it) when it is a 3D camera, on
|
|
// the object's own rotations otherwise - falling back to the
|
|
// object if the camera turns out not to respond to the mouse.
|
|
const firstInstance = this._getInstances(referenceObjectName)[0];
|
|
if (!firstInstance) return null;
|
|
const objectLayerName =
|
|
options && options.fromCamera !== undefined
|
|
? options.fromCamera
|
|
: firstInstance.getLayer();
|
|
const layerCamera = this.getCameraState(objectLayerName);
|
|
let aimOptions: { fromCamera?: string } =
|
|
layerCamera && Math.abs(layerCamera.rotationX) > 0.5
|
|
? { fromCamera: objectLayerName }
|
|
: {};
|
|
|
|
let sawYawResponse = false;
|
|
let sawPitchResponse = false;
|
|
let pitchGivenUp = false;
|
|
let unresponsivePitchFrames = 0;
|
|
let unresponsiveYawFrames = 0;
|
|
let appliedPitchPixels = 0;
|
|
|
|
const clamp = (value: float, maximum: float) =>
|
|
Math.max(-maximum, Math.min(maximum, value));
|
|
const makeResult = (
|
|
aimed: boolean,
|
|
relativePosition: GameplayTestRelativePosition
|
|
): GameplayTestAimResult => ({
|
|
aimed,
|
|
yawDiff: relativePosition.yawDiff,
|
|
pitchDiff: relativePosition.pitchDiff,
|
|
sawYawResponse,
|
|
sawPitchResponse,
|
|
measuredFrom:
|
|
aimOptions.fromCamera !== undefined ? 'camera' : 'object',
|
|
});
|
|
|
|
// Undo the vertical input applied so far: when the measured pitch
|
|
// never responded, the actual view may still have been pitched
|
|
// (e.g. toward the ground) - restore it.
|
|
const unwindAppliedPitch = async () => {
|
|
while (Math.abs(appliedPitchPixels) > 1) {
|
|
const chunk = clamp(-appliedPitchPixels, 100);
|
|
this.setMouseDelta(0, chunk);
|
|
appliedPitchPixels += chunk;
|
|
await this.stepFrames(1);
|
|
}
|
|
};
|
|
|
|
let lastRelativePosition: GameplayTestRelativePosition | null = null;
|
|
for (let i = 0; i < maxAimFrames; i++) {
|
|
const relativePosition = this.getRelativePosition(
|
|
referenceObjectName,
|
|
target,
|
|
aimOptions
|
|
);
|
|
if (!relativePosition) return null;
|
|
lastRelativePosition = relativePosition;
|
|
const yawDiff = relativePosition.yawDiff;
|
|
const wantPitch = !(options && options.yawOnly) && !pitchGivenUp;
|
|
const pitchDiff = wantPitch ? relativePosition.pitchDiff : 0;
|
|
if (
|
|
Math.abs(yawDiff) <= toleranceDegrees &&
|
|
Math.abs(pitchDiff) <= toleranceDegrees
|
|
) {
|
|
return makeResult(true, relativePosition);
|
|
}
|
|
|
|
const pitchDeltaPixels =
|
|
clamp(pitchDiff * pitchPixelsPerDegree, 100) * pitchDirection;
|
|
this.setMouseDelta(
|
|
clamp(yawDiff * yawPixelsPerDegree, 100) * yawDirection,
|
|
pitchDeltaPixels
|
|
);
|
|
appliedPitchPixels += pitchDeltaPixels;
|
|
await this.stepFrames(1);
|
|
|
|
const newRelativePosition = this.getRelativePosition(
|
|
referenceObjectName,
|
|
target,
|
|
aimOptions
|
|
);
|
|
if (newRelativePosition) {
|
|
const achievedYawRotation = Math.abs(
|
|
yawDiff - newRelativePosition.yawDiff
|
|
);
|
|
if (achievedYawRotation >= responseThresholdDegrees) {
|
|
sawYawResponse = true;
|
|
unresponsiveYawFrames = 0;
|
|
// The mouse turned the view AWAY from the target: the game
|
|
// maps this axis in the other direction.
|
|
if (
|
|
Math.abs(newRelativePosition.yawDiff) >
|
|
Math.abs(yawDiff) + responseThresholdDegrees
|
|
) {
|
|
yawDirection = -yawDirection;
|
|
}
|
|
}
|
|
if (Math.abs(yawDiff) > toleranceDegrees) {
|
|
if (achievedYawRotation < responseThresholdDegrees) {
|
|
// No response to the mouse: increase the gain (the game may
|
|
// have a low mouse sensitivity).
|
|
if (yawPixelsPerDegree < maxPixelsPerDegree) {
|
|
yawPixelsPerDegree = Math.min(
|
|
yawPixelsPerDegree * 2,
|
|
maxPixelsPerDegree
|
|
);
|
|
} else {
|
|
unresponsiveYawFrames++;
|
|
if (
|
|
unresponsiveYawFrames >= maxUnresponsiveFrames &&
|
|
aimOptions.fromCamera !== undefined
|
|
) {
|
|
// The camera never responds to the mouse (it may not be
|
|
// driven by this object): fall back to measuring on the
|
|
// object's own rotations and start over.
|
|
aimOptions = {};
|
|
yawPixelsPerDegree = 2;
|
|
pitchPixelsPerDegree = 2;
|
|
sawYawResponse = false;
|
|
sawPitchResponse = false;
|
|
unresponsiveYawFrames = 0;
|
|
unresponsivePitchFrames = 0;
|
|
await unwindAppliedPitch();
|
|
}
|
|
}
|
|
} else {
|
|
const ratio = Math.abs(yawDiff) / achievedYawRotation;
|
|
yawPixelsPerDegree = Math.max(
|
|
0.25,
|
|
Math.min(
|
|
maxPixelsPerDegree,
|
|
yawPixelsPerDegree * Math.min(2, ratio)
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
if (wantPitch && Math.abs(pitchDiff) > toleranceDegrees) {
|
|
const achievedPitchRotation = Math.abs(
|
|
pitchDiff - newRelativePosition.pitchDiff
|
|
);
|
|
if (achievedPitchRotation >= responseThresholdDegrees) {
|
|
sawPitchResponse = true;
|
|
unresponsivePitchFrames = 0;
|
|
if (
|
|
Math.abs(newRelativePosition.pitchDiff) >
|
|
Math.abs(pitchDiff) + responseThresholdDegrees
|
|
) {
|
|
pitchDirection = -pitchDirection;
|
|
}
|
|
const ratio = Math.abs(pitchDiff) / achievedPitchRotation;
|
|
pitchPixelsPerDegree = Math.max(
|
|
0.25,
|
|
Math.min(
|
|
maxPixelsPerDegree,
|
|
pitchPixelsPerDegree * Math.min(2, ratio)
|
|
)
|
|
);
|
|
} else if (pitchPixelsPerDegree < maxPixelsPerDegree) {
|
|
pitchPixelsPerDegree = Math.min(
|
|
pitchPixelsPerDegree * 2,
|
|
maxPixelsPerDegree
|
|
);
|
|
} else {
|
|
unresponsivePitchFrames++;
|
|
if (unresponsivePitchFrames >= maxUnresponsiveFrames) {
|
|
// The measured pitch never responds (the game may map the
|
|
// vertical aim to another axis): stop driving it - and
|
|
// undo what was applied, in case the actual view WAS
|
|
// pitched without the measure seeing it.
|
|
pitchGivenUp = true;
|
|
await unwindAppliedPitch();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return lastRelativePosition
|
|
? makeResult(false, lastRelativePosition)
|
|
: null;
|
|
}
|
|
|
|
// SCENARIO SETUP:
|
|
|
|
/**
|
|
* Create a new instance of an object at the given position.
|
|
* Use for test setup only - do not use it to fake a game behavior
|
|
* you are supposed to test.
|
|
*/
|
|
spawn(
|
|
objectName: string,
|
|
x: float,
|
|
y: float,
|
|
z?: float,
|
|
layerName?: string
|
|
): GameplayTestObjectSnapshot {
|
|
const currentScene = this._getCurrentScene();
|
|
const object = currentScene.createObject(objectName);
|
|
if (!object) {
|
|
throw new Error(
|
|
`Could not create an instance of "${objectName}" - check the object exists in the scene (or as a global object).`
|
|
);
|
|
}
|
|
object.setX(x);
|
|
object.setY(y);
|
|
const anyObject = object as any;
|
|
if (z !== undefined && typeof anyObject.setZ === 'function') {
|
|
anyObject.setZ(z);
|
|
}
|
|
if (layerName !== undefined) {
|
|
object.setLayer(layerName);
|
|
}
|
|
return this._makeObjectSnapshot(object, false);
|
|
}
|
|
|
|
/**
|
|
* Remove the instance with the given id (from `getObjects`).
|
|
*/
|
|
removeObject(id: integer): void {
|
|
const currentScene = this._getCurrentScene();
|
|
for (const object of currentScene.getAdhocListOfAllInstances()) {
|
|
if (object.id === id) {
|
|
object.deleteFromScene();
|
|
return;
|
|
}
|
|
}
|
|
throw new Error(`No instance with id ${id} found.`);
|
|
}
|
|
|
|
/**
|
|
* Move the instance with the given id to a position.
|
|
* Use for test setup only. The move takes effect immediately (physics
|
|
* bodies included), but the game's logic keeps acting on the object
|
|
* each stepped frame (forces, AI...): to hold an object somewhere,
|
|
* re-apply the position every frame via `onFrame`.
|
|
*/
|
|
setObjectPosition(id: integer, x: float, y: float, z?: float): void {
|
|
const currentScene = this._getCurrentScene();
|
|
for (const object of currentScene.getAdhocListOfAllInstances()) {
|
|
if (object.id === id) {
|
|
object.setX(x);
|
|
object.setY(y);
|
|
const anyObject = object as any;
|
|
if (z !== undefined && typeof anyObject.setZ === 'function') {
|
|
anyObject.setZ(z);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
throw new Error(`No instance with id ${id} found.`);
|
|
}
|
|
|
|
private _setVariableFromValue(
|
|
variable: gdjs.Variable,
|
|
value: string | number | boolean
|
|
): void {
|
|
if (typeof value === 'number') variable.setNumber(value);
|
|
else if (typeof value === 'boolean') variable.setBoolean(value);
|
|
else variable.setString(value);
|
|
}
|
|
|
|
/**
|
|
* Set a scene variable (number, string or boolean).
|
|
* Use for test setup only.
|
|
*/
|
|
setSceneVariable(
|
|
variableName: string,
|
|
value: string | number | boolean
|
|
): void {
|
|
this._setVariableFromValue(
|
|
this._getCurrentScene().getVariables().get(variableName),
|
|
value
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get a variable of an object (same entry shape as `getSceneVariable`),
|
|
* or undefined if the object or the variable does not exist.
|
|
* @param objectIdOrName An instance id (from `getObjects`) or an object
|
|
* name (first instance).
|
|
*/
|
|
getObjectVariable(
|
|
objectIdOrName: integer | string,
|
|
variableName: string
|
|
): VariableNetworkSyncData | undefined {
|
|
const object = this.getRuntimeObject(objectIdOrName);
|
|
if (!object) return undefined;
|
|
return object
|
|
.getVariables()
|
|
.getNetworkSyncData({})
|
|
.find((variable) => variable.name === variableName);
|
|
}
|
|
|
|
/**
|
|
* Set a variable of an object (number, string or boolean).
|
|
* Use for test setup only. Throws if the object does not exist.
|
|
* @param objectIdOrName An instance id (from `getObjects`) or an object
|
|
* name (first instance).
|
|
*/
|
|
setObjectVariable(
|
|
objectIdOrName: integer | string,
|
|
variableName: string,
|
|
value: string | number | boolean
|
|
): void {
|
|
const object = this.getRuntimeObject(objectIdOrName);
|
|
if (!object) {
|
|
throw new Error(
|
|
`No instance "${objectIdOrName}" found to set the variable "${variableName}".`
|
|
);
|
|
}
|
|
this._setVariableFromValue(
|
|
object.getVariables().get(variableName),
|
|
value
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Set a global variable (number, string or boolean).
|
|
* Use for test setup only.
|
|
*/
|
|
setGlobalVariable(
|
|
variableName: string,
|
|
value: string | number | boolean
|
|
): void {
|
|
this._setVariableFromValue(
|
|
this._runtimeGame.getVariables().get(variableName),
|
|
value
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Create the instances of an external layout in the current scene.
|
|
*/
|
|
loadExternalLayout(
|
|
externalLayoutName: string,
|
|
x: float = 0,
|
|
y: float = 0,
|
|
z: float = 0
|
|
): void {
|
|
const currentScene = this._getCurrentScene();
|
|
const externalLayoutData =
|
|
this._runtimeGame.getExternalLayoutData(externalLayoutName);
|
|
if (!externalLayoutData) {
|
|
throw new Error(
|
|
`The external layout "${externalLayoutName}" does not exist.`
|
|
);
|
|
}
|
|
currentScene.createObjectsFrom(
|
|
externalLayoutData.instances,
|
|
x,
|
|
y,
|
|
z,
|
|
/*trackByPersistentUuid=*/ false
|
|
);
|
|
}
|
|
|
|
// VERDICTS AND EVIDENCE:
|
|
|
|
/**
|
|
* Record a named assertion. Throws immediately on failure, stopping
|
|
* the script (wrap in try/catch if the check is optional).
|
|
*/
|
|
assert(condition: boolean, message: string): void {
|
|
if (this._assertions.length >= MAX_ASSERTIONS) {
|
|
throw new GameplayTestAssertionError(
|
|
`Too many assertions (max ${MAX_ASSERTIONS}).`
|
|
);
|
|
}
|
|
this._assertions.push({ message, passed: !!condition });
|
|
if (!condition) {
|
|
throw new GameplayTestAssertionError(`Assertion failed: ${message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Unconditionally record a failure and throw immediately, stopping
|
|
* the script.
|
|
*/
|
|
fail(message: string): void {
|
|
this._assertions.push({ message, passed: false });
|
|
throw new GameplayTestAssertionError(message);
|
|
}
|
|
|
|
/**
|
|
* Take a screenshot of the game canvas (downscaled). It's returned
|
|
* in the test result.
|
|
*/
|
|
async takeScreenshot(label: string = ''): Promise<void> {
|
|
if (this._screenshots.length >= this._maxScreenshots) {
|
|
logger.warn(
|
|
`Ignoring screenshot "${label}": already ${this._maxScreenshots} screenshots taken.`
|
|
);
|
|
return;
|
|
}
|
|
// Let an animation frame happen so the canvas shows the current
|
|
// state of the game (fast runs only render a few times per second).
|
|
await this._renderOnce();
|
|
const canvas = this._runtimeGame.getRenderer().getCanvas();
|
|
if (!canvas) {
|
|
logger.warn('No canvas found: unable to take a screenshot.');
|
|
return;
|
|
}
|
|
try {
|
|
const scale = Math.min(
|
|
1,
|
|
SCREENSHOT_MAX_SIZE / Math.max(canvas.width, canvas.height, 1)
|
|
);
|
|
const targetWidth = Math.max(1, Math.round(canvas.width * scale));
|
|
const targetHeight = Math.max(1, Math.round(canvas.height * scale));
|
|
const downscaledCanvas = document.createElement('canvas');
|
|
downscaledCanvas.width = targetWidth;
|
|
downscaledCanvas.height = targetHeight;
|
|
const context = downscaledCanvas.getContext('2d');
|
|
if (!context) return;
|
|
context.drawImage(canvas, 0, 0, targetWidth, targetHeight);
|
|
const dataUrl = downscaledCanvas.toDataURL('image/jpeg', 0.7);
|
|
this._screenshots.push({
|
|
label,
|
|
frame: this._framesExecuted,
|
|
jpegBase64: dataUrl.replace(/^data:image\/jpeg;base64,/, ''),
|
|
});
|
|
} catch (error) {
|
|
logger.warn('Error while taking a screenshot: ' + error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start profiling the current scene (see `stopProfiling`).
|
|
*/
|
|
startProfiling(): void {
|
|
this._profilingStartFrame = this._framesExecuted;
|
|
this._runtimeGame.startCurrentSceneProfiler(() => {});
|
|
}
|
|
|
|
/**
|
|
* Stop profiling and return a flat, JSON-safe summary: average and
|
|
* worst-frame time per profiled section (events, physics,
|
|
* rendering... - nested sections are flattened as "parent > child",
|
|
* sorted by average time descending), the frame-by-frame timeline (to
|
|
* correlate a spike with the `eventLog` frames), the live object
|
|
* counts, and renderer/memory counters when available.
|
|
*/
|
|
stopProfiling(): GameplayTestProfilingResult | null {
|
|
const currentScene = this._runtimeGame
|
|
.getSceneStack()
|
|
.getCurrentScene();
|
|
const profiler = currentScene ? currentScene.getProfiler() : null;
|
|
if (!profiler) return null;
|
|
const framesAverageMeasures = profiler.getFramesAverageMeasures();
|
|
const framesMaxMeasures = profiler.getFramesMaxMeasures();
|
|
const frameTimes = profiler.getFrameTimes();
|
|
this._runtimeGame.stopCurrentSceneProfiler();
|
|
|
|
const roundMs = (timeMs: float) => Math.round(timeMs * 100) / 100;
|
|
const sections: Array<{
|
|
name: string;
|
|
avgTimeMs: number;
|
|
maxTimeMs: number;
|
|
}> = [];
|
|
const visitSubsections = (
|
|
averageMeasure: gdjs.FrameMeasureOutput,
|
|
maxMeasure: gdjs.FrameMeasureOutput | null,
|
|
path: string
|
|
) => {
|
|
const subsections = averageMeasure.subsections;
|
|
for (const name in subsections) {
|
|
const fullName = path ? path + ' > ' + name : name;
|
|
const maxSubsection =
|
|
(maxMeasure && maxMeasure.subsections[name]) || null;
|
|
sections.push({
|
|
name: fullName,
|
|
avgTimeMs: roundMs(subsections[name].time || 0),
|
|
maxTimeMs: roundMs((maxSubsection && maxSubsection.time) || 0),
|
|
});
|
|
visitSubsections(subsections[name], maxSubsection, fullName);
|
|
}
|
|
};
|
|
visitSubsections(framesAverageMeasures, framesMaxMeasures, '');
|
|
sections.sort((a, b) => b.avgTimeMs - a.avgTimeMs);
|
|
|
|
const threeRenderer = (this._runtimeGame.getRenderer() as any)
|
|
.getThreeRenderer
|
|
? (this._runtimeGame.getRenderer() as any).getThreeRenderer()
|
|
: null;
|
|
const threeInfo = threeRenderer ? threeRenderer.info : null;
|
|
|
|
const performanceMemory =
|
|
typeof performance !== 'undefined' && (performance as any).memory
|
|
? (performance as any).memory
|
|
: null;
|
|
|
|
// The frames profiled, in harness frame numbers (like `eventLog`):
|
|
// the frame times captured are for frames startFrame+1..endFrame.
|
|
const endFrame = this._framesExecuted;
|
|
const startFrame =
|
|
this._profilingStartFrame !== null
|
|
? this._profilingStartFrame
|
|
: Math.max(0, endFrame - frameTimes.length);
|
|
this._profilingStartFrame = null;
|
|
|
|
// The most expensive frames, worst first, with their harness frame
|
|
// numbers so they can be correlated with the `eventLog`.
|
|
const worstFrames = frameTimes
|
|
.map((timeMs, index) => ({
|
|
frame: startFrame + 1 + index,
|
|
timeMs: roundMs(timeMs),
|
|
}))
|
|
.sort((a, b) => b.timeMs - a.timeMs)
|
|
.slice(0, MAX_PROFILING_WORST_FRAMES);
|
|
|
|
// Keep the timeline compact: past 120 frames, downsample by buckets
|
|
// keeping the MAX of each bucket (spikes are preserved).
|
|
const frameTimesBucketSize = Math.max(
|
|
1,
|
|
Math.ceil(frameTimes.length / MAX_PROFILING_TIMELINE_ENTRIES)
|
|
);
|
|
const frameTimesMs: Array<number> = [];
|
|
for (let i = 0; i < frameTimes.length; i += frameTimesBucketSize) {
|
|
let bucketMax = 0;
|
|
for (
|
|
let j = i;
|
|
j < Math.min(i + frameTimesBucketSize, frameTimes.length);
|
|
j++
|
|
) {
|
|
bucketMax = Math.max(bucketMax, frameTimes[j]);
|
|
}
|
|
frameTimesMs.push(roundMs(bucketMax));
|
|
}
|
|
|
|
const profile: GameplayTestProfilingResult = {
|
|
startFrame,
|
|
endFrame,
|
|
avgStepTimeMs: roundMs(framesAverageMeasures.time || 0),
|
|
maxStepTimeMs: roundMs(framesMaxMeasures.time || 0),
|
|
sections: sections.slice(0, MAX_PROFILING_SECTIONS),
|
|
worstFrames,
|
|
frameTimesMs,
|
|
frameTimesBucketSize,
|
|
objectCounts: this._getObjectCounts(),
|
|
renderer: threeInfo
|
|
? {
|
|
drawCalls: threeInfo.render.calls,
|
|
triangles: threeInfo.render.triangles,
|
|
geometries: threeInfo.memory.geometries,
|
|
textures: threeInfo.memory.textures,
|
|
}
|
|
: null,
|
|
...(performanceMemory
|
|
? {
|
|
jsHeapUsedMb:
|
|
Math.round(
|
|
(performanceMemory.usedJSHeapSize / (1024 * 1024)) * 10
|
|
) / 10,
|
|
}
|
|
: {}),
|
|
};
|
|
// Also attach the profile to the test result (keeping the last
|
|
// ones), so it reaches the report even if the script does not log it.
|
|
this._profiles.push(profile);
|
|
if (this._profiles.length > MAX_PROFILES_PER_RESULT) {
|
|
this._profiles.shift();
|
|
}
|
|
return profile;
|
|
}
|
|
|
|
/**
|
|
* Record a console log in the test result (also shown in the
|
|
* browser console). Used by the `console` given to the script.
|
|
*/
|
|
_recordConsoleLog(
|
|
level: 'log' | 'warn' | 'error',
|
|
message: string
|
|
): void {
|
|
if (
|
|
this._consoleLogs.length >= MAX_CONSOLE_LOGS ||
|
|
this._consoleLogsTotalChars >= MAX_CONSOLE_LOGS_TOTAL_CHARS
|
|
) {
|
|
return;
|
|
}
|
|
const cappedMessage = message.slice(0, 1000);
|
|
this._consoleLogsTotalChars += cappedMessage.length;
|
|
this._consoleLogs.push({ level, message: cappedMessage });
|
|
}
|
|
|
|
/** Undo the pointer lock shim (see `_installPointerLockShim`). */
|
|
_uninstallPointerLockShim: () => void = () => {};
|
|
|
|
/**
|
|
* Patch pointer lock during the test: `requestPointerLock` never
|
|
* really locks the OS mouse, but as soon as the game requests it, a
|
|
* locked pointer is seen by the game AND by anything reading
|
|
* `document.pointerLockElement` directly or listening to canvas
|
|
* `pointermove` events (like mouse-look extensions) - which then
|
|
* receive the `setMouseDelta` deltas as real DOM events.
|
|
*/
|
|
_installPointerLockShim(): void {
|
|
const restorers: Array<() => void> = [];
|
|
const harness = this;
|
|
const setFakePointerLock = (locked: boolean) => {
|
|
if (harness._pointerLockRequestedByGame === locked) return;
|
|
harness._pointerLockRequestedByGame = locked;
|
|
try {
|
|
document.dispatchEvent(new Event('pointerlockchange'));
|
|
} catch (error) {
|
|
// Ignore: no DOM support.
|
|
}
|
|
};
|
|
|
|
const renderer = this._runtimeGame.getRenderer() as any;
|
|
if (typeof renderer.requestPointerLock === 'function') {
|
|
const original = {
|
|
requestPointerLock: renderer.requestPointerLock,
|
|
exitPointerLock: renderer.exitPointerLock,
|
|
isPointerLocked: renderer.isPointerLocked,
|
|
};
|
|
renderer.requestPointerLock = function () {
|
|
setFakePointerLock(true);
|
|
return true;
|
|
};
|
|
renderer.exitPointerLock = function () {
|
|
setFakePointerLock(false);
|
|
};
|
|
renderer.isPointerLocked = function () {
|
|
return harness._pointerLockRequestedByGame;
|
|
};
|
|
restorers.push(() => {
|
|
renderer.requestPointerLock = original.requestPointerLock;
|
|
renderer.exitPointerLock = original.exitPointerLock;
|
|
renderer.isPointerLocked = original.isPointerLocked;
|
|
});
|
|
}
|
|
|
|
const canvas =
|
|
typeof renderer.getCanvas === 'function'
|
|
? renderer.getCanvas()
|
|
: null;
|
|
if (canvas && typeof document !== 'undefined') {
|
|
const originalCanvasRequestPointerLock = canvas.requestPointerLock;
|
|
canvas.requestPointerLock = () => setFakePointerLock(true);
|
|
restorers.push(() => {
|
|
canvas.requestPointerLock = originalCanvasRequestPointerLock;
|
|
});
|
|
|
|
const originalDocumentExitPointerLock = document.exitPointerLock;
|
|
(document as any).exitPointerLock = () => setFakePointerLock(false);
|
|
restorers.push(() => {
|
|
(document as any).exitPointerLock = originalDocumentExitPointerLock;
|
|
});
|
|
|
|
try {
|
|
Object.defineProperty(document, 'pointerLockElement', {
|
|
get: () => (harness._pointerLockRequestedByGame ? canvas : null),
|
|
configurable: true,
|
|
});
|
|
restorers.push(() => {
|
|
delete (document as any).pointerLockElement;
|
|
});
|
|
} catch (error) {
|
|
// Ignore: `pointerLockElement` cannot be faked in this browser.
|
|
}
|
|
}
|
|
|
|
this._uninstallPointerLockShim = () => {
|
|
restorers.forEach((restore) => restore());
|
|
this._uninstallPointerLockShim = () => {};
|
|
};
|
|
}
|
|
|
|
_makeResult(
|
|
status: GameplayTestResult['status'],
|
|
errors: Array<string>
|
|
): GameplayTestResult {
|
|
const currentScene = this._runtimeGame
|
|
.getSceneStack()
|
|
.getCurrentScene();
|
|
const watchedObjects: {
|
|
[objectName: string]: Array<GameplayTestObjectSnapshot>;
|
|
} = {};
|
|
if (currentScene) {
|
|
for (const objectName of this._watchedObjectNames) {
|
|
try {
|
|
watchedObjects[objectName] = this.getObjects(objectName);
|
|
} catch (error) {
|
|
// Ignore snapshot errors when building the result.
|
|
}
|
|
}
|
|
}
|
|
return {
|
|
testName: this._payload.testName,
|
|
status,
|
|
framesExecuted: this._framesExecuted,
|
|
durationMs: this._startTimeMs ? Date.now() - this._startTimeMs : 0,
|
|
loadingMs: Math.round(this._loadingTimeMs),
|
|
timeoutMs: this._timeoutMs,
|
|
hiddenStallMs: Math.round(this._getHiddenStallTimeMs()),
|
|
gameTimeMs: Math.round(this._gameTimeMs),
|
|
assertions: this._assertions,
|
|
errors: errors.slice(0, MAX_ERRORS),
|
|
consoleLogs: this._consoleLogs,
|
|
eventLog: this._eventLog,
|
|
finalState: {
|
|
sceneName: currentScene ? currentScene.getName() : '',
|
|
objectCounts: this._getObjectCounts(),
|
|
watchedObjects,
|
|
sceneVariables: currentScene
|
|
? currentScene.getVariables().getNetworkSyncData({})
|
|
: [],
|
|
},
|
|
screenshots: this._screenshots,
|
|
profiles: this._profiles,
|
|
performance:
|
|
this._framesExecuted > 0
|
|
? {
|
|
avgStepMs:
|
|
Math.round(
|
|
(this._totalStepTimeMs / this._framesExecuted) * 100
|
|
) / 100,
|
|
worstStepMs: this._worstStepTimeMs,
|
|
}
|
|
: null,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The gameplay test being currently run, if any.
|
|
*/
|
|
let currentlyRunningHarness: GameplayTestHarness | null = null;
|
|
|
|
/**
|
|
* Request the running gameplay test (if any) to stop as soon as
|
|
* possible.
|
|
*/
|
|
export const stopCurrentGameplayTest = (): void => {
|
|
if (currentlyRunningHarness) {
|
|
currentlyRunningHarness.requestStop();
|
|
}
|
|
};
|
|
|
|
/**
|
|
* True while a gameplay test is running (the harness owns the game
|
|
* stepping: external state mutations must be avoided).
|
|
*/
|
|
export const isGameplayTestRunning = (): boolean =>
|
|
!!currentlyRunningHarness;
|
|
|
|
/**
|
|
* Run a gameplay test script against the game and return its result.
|
|
*
|
|
* The game main loop keeps rendering (paused) while the test steps the
|
|
* game logic deterministically at full speed, yielding to the browser
|
|
* regularly so the run stays visible and interruptible (see
|
|
* `_maybeYield`).
|
|
*/
|
|
export const runGameplayTest = async (
|
|
runtimeGame: gdjs.RuntimeGame,
|
|
payload: GameplayTestRunPayload,
|
|
onProgress?: (frame: integer) => void
|
|
): Promise<GameplayTestResult> => {
|
|
if (currentlyRunningHarness) {
|
|
const failedResult = new GameplayTestHarness(
|
|
runtimeGame,
|
|
payload
|
|
)._makeResult('error', [
|
|
'A gameplay test is already running. Wait for it to finish or stop it first.',
|
|
]);
|
|
return failedResult;
|
|
}
|
|
|
|
const harness = new GameplayTestHarness(runtimeGame, payload);
|
|
currentlyRunningHarness = harness;
|
|
harness._onProgress = onProgress || null;
|
|
|
|
// The source must be the BODY of `async (harness) => { ... }`, but
|
|
// AI models (and users pasting code) sometimes send the whole function
|
|
// instead. Evaluated as-is it would be a no-op expression: detect the
|
|
// wrapper and call it instead.
|
|
let source = payload.source;
|
|
if (
|
|
/^\s*(?:async\s*)?(?:\(\s*harness\s*(?:,\s*console\s*)?\)|harness)\s*=>/.test(
|
|
source
|
|
)
|
|
) {
|
|
source = 'return (\n' + source + '\n)(harness, console);';
|
|
}
|
|
|
|
// Compile the script first, so a syntax error is reported cleanly.
|
|
let scriptFunction: Function;
|
|
try {
|
|
// nosonar: typescript:S1523 -- new Function() is intentional; this test runner compiles user-provided gameplay scripts in a sandboxed environment.
|
|
scriptFunction = new Function(
|
|
'harness',
|
|
'console',
|
|
'"use strict"; return (async () => {\n' + source + '\n})();'
|
|
);
|
|
} catch (error) {
|
|
currentlyRunningHarness = null;
|
|
return harness._makeResult('error', [
|
|
'The test script could not be parsed: ' + error,
|
|
]);
|
|
}
|
|
|
|
// Wait for the game to be done starting up: a run request can arrive
|
|
// while the game is still starting. Without this, the test could
|
|
// create scenes before asynchronously loaded libraries (Jolt
|
|
// physics...) are ready, or the startup could push the game's first
|
|
// scene in the middle of the test.
|
|
// This wait is loading: excluded from the `timeoutMs` budget (counted
|
|
// in `loadingMs` instead), bounded by `loadingTimeoutMs`, with
|
|
// progress heartbeats so the editor knows the run is alive.
|
|
harness._startTimeMs = Date.now();
|
|
const bootWaitStartTimeMs = Date.now();
|
|
const bootDeadlineMs = bootWaitStartTimeMs + harness._loadingTimeoutMs;
|
|
let lastBootHeartbeatTimeMs = 0;
|
|
while (runtimeGame.isStartingUp()) {
|
|
if (harness._stopped) {
|
|
currentlyRunningHarness = null;
|
|
return harness._makeResult('stopped', ['The test was stopped.']);
|
|
}
|
|
if (Date.now() > bootDeadlineMs) {
|
|
currentlyRunningHarness = null;
|
|
harness._loadingTimeMs += Date.now() - bootWaitStartTimeMs;
|
|
return harness._makeResult('error', [
|
|
`The game did not finish starting within ` +
|
|
`${harness._loadingTimeoutMs}ms (the first scene was never ` +
|
|
'created).',
|
|
]);
|
|
}
|
|
if (
|
|
harness._onProgress &&
|
|
Date.now() - lastBootHeartbeatTimeMs > 1000
|
|
) {
|
|
lastBootHeartbeatTimeMs = Date.now();
|
|
harness._onProgress(0);
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
}
|
|
harness._loadingTimeMs += Date.now() - bootWaitStartTimeMs;
|
|
|
|
const inputManager = runtimeGame.getInputManager();
|
|
const wasPaused = runtimeGame.isPaused();
|
|
|
|
// Pause the game: the main loop keeps rendering (`renderWithoutStep`)
|
|
// but stops stepping the game logic - the harness owns stepping.
|
|
runtimeGame.pause(true);
|
|
|
|
// The paused main loop still calls `onFrameEnded` every animation
|
|
// frame, which would clear the inputs simulated by the test between
|
|
// two manually stepped frames. Neutralize it during the test: the
|
|
// harness calls the original after each stepped frame instead.
|
|
const originalOnFrameEnded = inputManager.onFrameEnded.bind(inputManager);
|
|
inputManager.onFrameEnded = () => {};
|
|
harness._callOnFrameEnded = originalOnFrameEnded;
|
|
|
|
harness._installPointerLockShim();
|
|
harness._installSoundLog();
|
|
// Only from now on: the boot wait above is already excluded from the
|
|
// `timeoutMs` budget, as loading time.
|
|
harness._lastFrameStepTimeMs = Date.now();
|
|
harness._installPageVisibilityTracking();
|
|
|
|
// Capture the logs of the game itself (in addition to the `console`
|
|
// passed to the script).
|
|
const existingLoggerOutput = gdjs.Logger.getLoggerOutput();
|
|
gdjs.Logger.setLoggerOutput({
|
|
log: (
|
|
group: string,
|
|
message: string,
|
|
type: 'info' | 'warning' | 'error' = 'info',
|
|
internal = true
|
|
) => {
|
|
existingLoggerOutput.log(group, message, type, internal);
|
|
harness._recordConsoleLog(
|
|
type === 'warning' ? 'warn' : type === 'error' ? 'error' : 'log',
|
|
`[${group}] ${message}`
|
|
);
|
|
},
|
|
});
|
|
|
|
const stringifyConsoleArguments = (args: Array<any>): string =>
|
|
args
|
|
.map((value) => {
|
|
if (typeof value === 'string') return value;
|
|
try {
|
|
return JSON.stringify(value);
|
|
} catch (error) {
|
|
return String(value);
|
|
}
|
|
})
|
|
.join(' ');
|
|
const scriptConsole = {
|
|
log: (...args: Array<any>) => {
|
|
console.log(...args);
|
|
harness._recordConsoleLog('log', stringifyConsoleArguments(args));
|
|
},
|
|
warn: (...args: Array<any>) => {
|
|
console.warn(...args);
|
|
harness._recordConsoleLog('warn', stringifyConsoleArguments(args));
|
|
},
|
|
error: (...args: Array<any>) => {
|
|
console.error(...args);
|
|
harness._recordConsoleLog('error', stringifyConsoleArguments(args));
|
|
},
|
|
};
|
|
|
|
let result: GameplayTestResult;
|
|
try {
|
|
// A wall-clock watchdog, in case the script awaits something that
|
|
// never resolves. Checked periodically (not a one-shot timer) so
|
|
// the time spent loading - which grows `_loadingTimeMs` - stays
|
|
// excluded from the budget. A synchronous infinite loop can NOT be
|
|
// interrupted (this is a limit of running in the same thread as
|
|
// the game).
|
|
let watchdogIntervalId: any = null;
|
|
const watchdog = new Promise<never>((_, reject) => {
|
|
watchdogIntervalId = setInterval(() => {
|
|
if (
|
|
Date.now() - harness._startTimeMs - harness._loadingTimeMs >
|
|
harness._timeoutMs + 1000
|
|
) {
|
|
reject(
|
|
new GameplayTestTimeoutError(
|
|
`The test timed out after ${harness._timeoutMs}ms ` +
|
|
'(wall-clock, loading time excluded).'
|
|
)
|
|
);
|
|
}
|
|
}, 250);
|
|
});
|
|
// A stop rejects this promise, interrupting the script even when
|
|
// it awaits something else than the harness (a timer, a fetch...).
|
|
const stopSignal = new Promise<never>((_, reject) => {
|
|
harness._rejectOnStop = reject;
|
|
});
|
|
// Report the test as started (frame 0): a test can legitimately
|
|
// spend time without stepping any frame.
|
|
if (harness._onProgress) harness._onProgress(0);
|
|
try {
|
|
await Promise.race([
|
|
scriptFunction(harness, scriptConsole),
|
|
watchdog,
|
|
stopSignal,
|
|
]);
|
|
} finally {
|
|
if (watchdogIntervalId) clearInterval(watchdogIntervalId);
|
|
harness._rejectOnStop = null;
|
|
}
|
|
|
|
const hasFailedAssertion = harness._assertions.some(
|
|
(assertion) => !assertion.passed
|
|
);
|
|
if (
|
|
!hasFailedAssertion &&
|
|
harness._framesExecuted === 0 &&
|
|
harness._assertions.length === 0
|
|
) {
|
|
// A "passed" run that stepped no frame and asserted nothing is a
|
|
// no-op script, not a passing test: never report a false green.
|
|
result = harness._makeResult('error', [
|
|
'The test completed without stepping a single frame nor making any assertion — it did nothing. ' +
|
|
'The test source must be the BODY of `async (harness) => { ... }` (statements starting with `await harness...`), not a function definition.',
|
|
]);
|
|
} else {
|
|
result = harness._makeResult(
|
|
hasFailedAssertion ? 'failed' : 'passed',
|
|
[]
|
|
);
|
|
}
|
|
} catch (error) {
|
|
// (No type annotation on `error`: this file must stay parseable by
|
|
// the older TypeScript bundled in the Monaco editor, which reads it
|
|
// to provide autocompletions in the test editor.)
|
|
if (error && error.isGameplayTestAssertionError) {
|
|
result = harness._makeResult('failed', [String(error.message)]);
|
|
} else if (error && error.isGameplayTestStoppedError) {
|
|
result = harness._makeResult('stopped', [
|
|
'The test was stopped before completing.',
|
|
]);
|
|
} else if (error && error.isGameplayTestTimeoutError) {
|
|
result = harness._makeResult('timeout', [String(error.message)]);
|
|
} else {
|
|
result = harness._makeResult('error', [
|
|
(error && error.stack ? String(error.stack) : String(error)).slice(
|
|
0,
|
|
2000
|
|
),
|
|
]);
|
|
}
|
|
} finally {
|
|
// Restore everything, whatever happened:
|
|
try {
|
|
harness.releaseAllInputs();
|
|
} catch (error) {
|
|
// Ignore errors during cleanup.
|
|
}
|
|
inputManager.onFrameEnded = originalOnFrameEnded;
|
|
harness._uninstallPageVisibilityTracking();
|
|
harness._uninstallPointerLockShim();
|
|
harness._uninstallSoundLog();
|
|
harness._uninstallGameWindowSizeOverride();
|
|
gdjs.Logger.setLoggerOutput(existingLoggerOutput);
|
|
if (payload.freezeWhenFinished) {
|
|
// Keep the game paused (the main loop keeps rendering the last
|
|
// frame) and muted, so it can stay visible without playing.
|
|
runtimeGame.pause(true);
|
|
try {
|
|
runtimeGame.getSoundManager().setGlobalVolume(0);
|
|
} catch (error) {
|
|
// Ignore errors while muting the game.
|
|
}
|
|
} else {
|
|
runtimeGame.pause(wasPaused);
|
|
}
|
|
currentlyRunningHarness = null;
|
|
}
|
|
|
|
return result;
|
|
};
|
|
}
|
|
}
|