diff --git a/apps/web/tf2/src/runtime.ts b/apps/web/tf2/src/runtime.ts index 794c3ad8e..2d207b2da 100644 --- a/apps/web/tf2/src/runtime.ts +++ b/apps/web/tf2/src/runtime.ts @@ -357,6 +357,7 @@ export class Tf2Application { #equipmentProfile?: Tf2EquipmentProfile #equipment?: Tf2EquipmentPresentation #equipmentRoot?: HTMLElement + #equipmentReturn?: () => void #equipmentPreview: Tf2EquipmentPreview | null = null #equipmentRenderTask?: Promise #equipmentPreviewReset = true @@ -759,7 +760,8 @@ export class Tf2Application { #beginLoadingPresentation(): void { this.#equipmentAdmissionEpoch += 1 - if (this.#equipment?.visible()) this.#equipment.hide() + if (this.#equipment?.visible()) this.#equipment.hide(false) + this.#equipmentReturn = undefined if (!this.#configuration) throw new Error("TF2 loading configuration is unavailable") const target = this.#loadingTarget ?? this.#activeTarget if (!target) throw new Error("TF2 loading target is unavailable") @@ -1541,6 +1543,7 @@ export class Tf2Application { this.#localMatch = undefined this.#equipment?.destroy() this.#equipment = undefined + this.#equipmentReturn = undefined this.#equipmentRoot?.remove() this.#equipmentRoot = undefined this.#uiResources?.destroy() @@ -2094,20 +2097,31 @@ export class Tf2Application { reducedMotion: matchMedia("(prefers-reduced-motion: reduce)").matches, clock: { nowSeconds: () => this.#frameClock.current }, random: this.#presentationRandom, modelSurface: this.#canvas, - onEquip: async (identity, slot, definition) => { - const loadout = this.#equipmentProfile!.state()!.classes[identity - 1]! + onEquip: async (identity, slot, definition, signal) => { + const operation = this.#operation, profile = this.#equipmentProfile! + const loadout = profile.state()!.classes[identity - 1]! const replacement = definition ?? loadout.baseItems.find(item => item.slot === slot)?.definitionIndex const definitions = [...new Set([...loadout.items.filter(item => item.slot !== slot).map(item => item.definitionIndex), ...(replacement === undefined ? [] : [replacement])])] - try { - await this.#admitEquipment(definitions, this.#loaded ? this.#generation : 0) - return await this.#equipmentProfile!.equip(identity, slot, definition) - } catch (error) { - if (error instanceof DOMException && error.name === "AbortError") return this.#equipmentProfile!.state()! - this.#set({ phase: "Failed", gameUi: "failure", detail: this.#failureDetail(error, "Equipment admission failed") }) - return this.#equipmentProfile!.state()! - } + const current = AbortSignal.any([signal, operation.signal]) + current.throwIfAborted() + await this.#admitEquipment(definitions, this.#loaded ? this.#generation : 0) + current.throwIfAborted() + const state = await profile.equip(identity, slot, definition, current) + if (this.#closed || !this.#operations.current(operation) || profile !== this.#equipmentProfile) throw new DOMException("Equipment owner was replaced", "AbortError") + return state + }, + onError: (error, current) => { + if (!current) { this.#output(`ERROR: ${this.#failureDetail(error, "Equipment admission failed")}`); return } + this.#equipment?.hide(false) + this.#equipmentReturn = undefined + this.#set({ phase: "Failed", gameUi: "failure", detail: this.#failureDetail(error, "Equipment admission failed") }) + }, + onClose: () => { + this.#neutral() + const restore = this.#equipmentReturn + this.#equipmentReturn = undefined + restore?.() }, - onClose: () => { this.#neutral() }, onPreview: preview => { const previous = this.#equipmentPreview this.#equipmentPreview = preview @@ -2121,6 +2135,19 @@ export class Tf2Application { } this.#neutral() if (document.pointerLockElement === this.#canvas) void document.exitPointerLock() + const selection = this.#classSelection?.state() + const focused = document.activeElement + this.#equipmentReturn = () => { + if (this.#closed || !this.#operations.current(operation)) return + if (selection?.visible && selection.team !== null && this.#view.gameUi === "in-game") { + this.#classSelection?.dispatch({ kind: "show", team: selection.team, current: selection.current }) + this.#classSelection?.dispatch({ kind: "hover", identity: selection.selected }) + } else if (this.#gameUi?.state().kind === "pause") { + this.#gameUi.dispatch({ kind: "escape" }) + return + } + if (focused instanceof HTMLElement && focused.isConnected) focused.focus() + } this.#classSelection?.dispatch({ kind: "hide" }) this.#teamSelection?.dispatch({ kind: "hide" }) this.#equipment.show(state, playerClass) @@ -2155,8 +2182,11 @@ export class Tf2Application { const priorTask = this.#equipmentAdmissionTask const epoch = this.#equipmentAdmissionEpoch, operation = this.#operation const task = (async () => { - await priorTask?.catch(error => { if (error?.name !== "AbortError") throw error }) + // Each admission reports its own failure. A cancelled selection's failed + // resource request must not poison the next independently owned request. + await priorTask?.catch(() => {}) if (this.#closed || epoch !== this.#equipmentAdmissionEpoch || !this.#operations.current(operation)) throw new DOMException("Equipment admission was replaced", "AbortError") + const client = this.#client! const previous = this.#equipmentAdmissions.get(generation) const wanted = [...new Set(definitions)] if (previous && wanted.every(definition => previous.definitions.has(definition)) && (generation !== 0 || wanted.length === previous.definitions.size)) return @@ -2164,14 +2194,23 @@ export class Tf2Application { const resources = new Map() const configuration = await this.#decodeResourceSet(this.#resourceGraph!, requested.map(definition => `equipment-${definition}`), resources, operation.signal) if (!configuration) throw new Error("Equipment admission did not create a bounded resource generation") - let retained = false + let retained = false, replacedPanel = false try { const profile = this.#renderLevel === 2 ? 1 : 0 - const bytes = await this.#client!.admitEquipmentModels(generation, requested, configuration, profile) + if (this.#closed || epoch !== this.#equipmentAdmissionEpoch || !this.#operations.current(operation)) throw new DOMException("Equipment admission was replaced", "AbortError") + const bytes = await client.admitEquipmentModels(generation, requested, configuration, profile) + if (generation === 0) { + replacedPanel = true + if (this.#equipmentAdmissions.get(0) === previous) { + this.#equipmentAdmissions.delete(0) + this.#equipmentPanelArtifacts = undefined + } + } const artifacts = parseEquipmentModelArtifacts(bytes, resources) if (epoch !== this.#equipmentAdmissionEpoch || !this.#operations.current(operation)) throw new DOMException("Equipment admission was replaced", "AbortError") this.#equipmentPreparing = true await Promise.all([this.#displayTask, this.#equipmentRenderTask, this.#classSelectionRenderTask, this.#teamSelectionRenderTask]) + if (this.#closed || epoch !== this.#equipmentAdmissionEpoch || !this.#operations.current(operation)) throw new DOMException("Equipment admission was replaced", "AbortError") if (!this.#renderer) { this.#renderer = await createRenderer({ canvas: this.#canvas, serviceAudio: this.#serviceAudio, @@ -2183,26 +2222,13 @@ export class Tf2Application { if (epoch !== this.#equipmentAdmissionEpoch) throw new DOMException("Equipment admission was replaced", "AbortError") await this.#renderer.admitModels({ models: artifacts.geometry, modelMaterials: artifacts.modelMaterials, authoredTextures: artifacts.authoredTextures, materialStates: artifacts.materialStates, modelFacing: this.#modelFacing(artifacts), particleTextures: artifacts.particleTextures }) - if (generation === 0) { - this.#equipmentPanelArtifacts = artifacts - if (previous) for (const source of previous.sources) await this.#client!.releaseResources(source) - this.#equipmentAdmissions.set(0, { definitions: new Set(wanted), sources: new Set([configuration.generation]) }) - } else { - if (!this.#artifacts || generation !== this.#generation) throw new DOMException("Equipment map was replaced", "AbortError") - this.#artifacts = Object.freeze({ ...this.#artifacts, models: new Map([...this.#artifacts.models, ...artifacts.models]), - materialStates: new Map([...this.#artifacts.materialStates, ...artifacts.materialStates]), modelMaterials: new Map([...this.#artifacts.modelMaterials, ...artifacts.modelMaterials]), - authoredTextures: new Map([...this.#artifacts.authoredTextures, ...artifacts.authoredTextures]) }) - this.#viewmodels?.updateArtifacts(this.#artifacts) - this.#equipmentAdmissions.set(generation, { definitions: new Set([...(previous?.definitions ?? []), ...wanted]), sources: new Set([...(previous?.sources ?? []), configuration.generation]) }) - } - retained = true const camera: Camera = { position: [0, 0, 0], yawDegrees: 0, pitchDegrees: 0, verticalFovDegrees: 30, near: 1, far: 16384 * Math.sqrt(3) } const viewport = this.#viewport() const requests = equipmentPipelinePoseRequests(artifacts, viewport.width / viewport.height) const passes = generation === 0 ? ["panel"] as const : ["panel", "view", "world"] as const for (let offset = 0; offset < requests.length; offset += 32) { const batch = requests.slice(offset, offset + 32) - const poses = await this.#client!.models(generation, encodeModelPoseBatch(batch)) + const poses = await client.models(generation, encodeModelPoseBatch(batch)) if (epoch !== this.#equipmentAdmissionEpoch) throw new DOMException("Equipment admission was replaced", "AbortError") const byIdentity = new Map(batch.map(request => [request.identity, request])) await this.#renderer.prepareModelPipelines(poses.flatMap(pose => { @@ -2214,9 +2240,23 @@ export class Tf2Application { }), camera, generation === 0 ? undefined : this.#mainFog(this.#artifacts!)) } if (artifacts.particleTextures.length) await this.#renderer.prepareParticlePipelines(camera) + if (this.#closed || epoch !== this.#equipmentAdmissionEpoch || !this.#operations.current(operation)) throw new DOMException("Equipment admission was replaced", "AbortError") + if (generation === 0) { + this.#equipmentPanelArtifacts = artifacts + this.#equipmentAdmissions.set(0, { definitions: new Set(wanted), sources: new Set([configuration.generation]) }) + } else { + if (!this.#artifacts || generation !== this.#generation) throw new DOMException("Equipment map was replaced", "AbortError") + this.#artifacts = Object.freeze({ ...this.#artifacts, models: new Map([...this.#artifacts.models, ...artifacts.models]), + materialStates: new Map([...this.#artifacts.materialStates, ...artifacts.materialStates]), modelMaterials: new Map([...this.#artifacts.modelMaterials, ...artifacts.modelMaterials]), + authoredTextures: new Map([...this.#artifacts.authoredTextures, ...artifacts.authoredTextures]) }) + this.#viewmodels?.updateArtifacts(this.#artifacts) + this.#equipmentAdmissions.set(generation, { definitions: new Set([...(previous?.definitions ?? []), ...wanted]), sources: new Set([...(previous?.sources ?? []), configuration.generation]) }) + } + retained = true } finally { this.#equipmentPreparing = false - if (!retained) await this.#client?.releaseResources(configuration.generation).catch(() => {}) + const release = [...(!retained ? [configuration.generation] : []), ...(replacedPanel && previous ? previous.sources : [])] + await Promise.all(release.map(source => client.releaseResources(source))) } })().catch(error => { if (this.#closed || epoch !== this.#equipmentAdmissionEpoch || !this.#operations.current(operation)) throw new DOMException("Equipment admission was replaced", "AbortError") @@ -6021,7 +6061,7 @@ export class Tf2Application { return } if (this.#localMatch?.handleKey(event)) return - if (!this.#view.consoleVisible && this.#equipment?.handleKey(event)) return + if (!this.#view.consoleVisible && !this.#view.optionsVisible && this.#equipment?.handleKey(event)) return if (!this.#view.consoleVisible && this.#classSelection?.handleKey(event, this.#keyboardAction(event) === "changeclass")) return if (!this.#view.consoleVisible && this.#teamSelection?.handleKey(event, this.#keyboardAction(event) === "changeteam")) return if (event.code === "Escape" && this.#view.optionsVisible && this.#options?.handleKey(event)) return @@ -6354,7 +6394,8 @@ export class Tf2Application { async #teardownGameplay(): Promise { this.#equipmentAdmissionEpoch += 1 - if (this.#equipment?.visible()) this.#equipment.hide() + if (this.#equipment?.visible()) this.#equipment.hide(false) + this.#equipmentReturn = undefined this.#paused = true this.#neutral() this.#generation += 1 diff --git a/games/tf2/browser/src/equipment/presentation.ts b/games/tf2/browser/src/equipment/presentation.ts index 8e595b663..903e1b7e3 100644 --- a/games/tf2/browser/src/equipment/presentation.ts +++ b/games/tf2/browser/src/equipment/presentation.ts @@ -11,7 +11,8 @@ export type Tf2EquipmentPresentationRequest = Readonly<{ root: HTMLElement; resources: Tf2VguiResources; viewport: VguiViewport; reducedMotion: boolean clock: Readonly<{ nowSeconds(): number }>; random: Readonly<{ nextUnit(): number }> modelSurface?: HTMLCanvasElement - onEquip(playerClass: Tf2Class, slot: number, definitionIndex: number | null): Promise + onEquip(playerClass: Tf2Class, slot: number, definitionIndex: number | null, signal: AbortSignal): Promise + onError(error: unknown, current: boolean): void onClose(): void onPreview(preview: Tf2EquipmentPreview | null): void }> @@ -32,14 +33,19 @@ export class Tf2EquipmentPresentation { #viewport: VguiViewport #state: Tf2EquipmentState | undefined #class: Tf2Class = 1 + #closeClass: Tf2Class | undefined #page: "classes" | "loadout" | "backpack" | "slot" = "classes" #slot = 0 #visible = false - #busy = false + #pending: AbortController | undefined + #destroyed = false #pageNumber = 0 #content: VguiPanelId | undefined #releaseSurface: (() => void) | undefined #selected: number | null = null + #cell = -1 + readonly #cells: VguiPanelId[] = [] + readonly #navigation = new Map>() readonly #itemPanels = new Map() #hover: { panel: VguiPanelId; item: Tf2SupportedItem; since: number } | undefined #tooltip: VguiPanelId | undefined @@ -61,9 +67,7 @@ export class Tf2EquipmentPresentation { this.#runtime = initialized.runtime this.#apply({ kind: "set-panel-state", panel: 1, visible: false, proportional: true }) request.root.addEventListener("pointerover", this.#enterItem) - request.root.addEventListener("focusin", this.#enterItem) request.root.addEventListener("pointerout", this.#leaveItem) - request.root.addEventListener("focusout", this.#leaveItem) } #apply(operation: VguiOperation): VguiPanelId | undefined { @@ -81,7 +85,11 @@ export class Tf2EquipmentPresentation { } show(state: Tf2EquipmentState, playerClass?: Tf2Class): void { + this.#cancelEquip() this.#state = state; this.#visible = true; this.#selected = null; this.#pageNumber = 0 + this.#closeClass = playerClass + this.#cell = -1 + this.#slot = playerClass === 8 ? 1 : 0 this.#page = playerClass ? "loadout" : "classes"; if (playerClass) this.#class = playerClass this.#request.root.style.display = "block" this.#apply({ kind: "set-panel-state", panel: 1, visible: true }) @@ -89,16 +97,20 @@ export class Tf2EquipmentPresentation { } visible(): boolean { return this.#visible } snapshot() { return { visible: this.#visible, page: this.#page, class: this.#class, vgui: this.#runtime.snapshot() } } - hide(): void { + hide(notify = true): void { + this.#cancelEquip() this.#clearTooltip() this.#visible = false; this.#releaseSurface?.(); this.#releaseSurface = undefined this.#apply({ kind: "set-panel-state", panel: 1, visible: false }); this.#request.root.style.display = "none" - this.#request.onPreview(null); this.#request.onClose() + this.#request.onPreview(null) + if (notify) this.#request.onClose() } - #render(): void { + #render(restoreFocus?: string): void { if (!this.#visible || !this.#state) return this.#clearTooltip() this.#itemPanels.clear() + this.#cells.length = 0 + this.#navigation.clear() this.#releaseSurface?.(); this.#releaseSurface = undefined if (this.#page !== "loadout") this.#request.onPreview(null) this.#runtime.deferPresentation(() => { @@ -134,7 +146,7 @@ export class Tf2EquipmentPresentation { this.#item(root, supported, `slot ${slot}`, `c${right ? scalar(authored, "item_xpos_offcenter_b")! : scalar(authored, "item_xpos_offcenter_a")!}`, String(Number(scalar(authored, "item_ypos")) + row * Number(scalar(authored, "item_ydelta"))), block(authored, "modelpanels_kv"), true) }) const model = block(document, "classmodelpanel") - const surface = this.#create(root, "CTFPlayerModelPanel", "EquipmentPlayer", Object.fromEntries(["xpos", "ypos", "wide", "tall"].map(key => [key, scalar(model, key)!]))) + const surface = this.#create(root, "CTFPlayerModelPanel", "EquipmentPlayer", Object.fromEntries(["xpos", "ypos", "wide", "tall", "zpos"].map(key => [key, scalar(model, key)!]))) const bounds = this.#runtime.snapshot().panels.find(panel => panel.id === surface)!.bounds if (this.#request.modelSurface) this.#releaseSurface = attachEquipmentSurface(this.#runtime, surface, this.#request.modelSurface, this.#viewport, bounds) const settings = block(model, "model") @@ -148,7 +160,7 @@ export class Tf2EquipmentPresentation { for (let cell = 0; cell < 50; cell++) { const item = inventory[this.#pageNumber * 50 + cell] this.#item(root, item, item ? `item ${item.item.definitionIndex}` : "", `c${Number(scalar(authored, "item_backpack_offcenter_x")) + (cell % 10) * (width + Number(scalar(authored, "item_backpack_xdelta")))}`, - String(Number(scalar(authored, "item_ypos")) + Math.floor(cell / 10) * (height + Number(scalar(authored, "item_backpack_ydelta")))), template, false) + String(Number(scalar(authored, "item_ypos")) + Math.floor(cell / 10) * (height + Number(scalar(authored, "item_backpack_ydelta")))), template, false, cell === this.#cell) } if (page === "slot") this.#button(root, "UnequipButton", "UNEQUIP", "unequip", "c-50", "r40", "120") const pages = Math.ceil(inventory.length / 50) @@ -157,14 +169,21 @@ export class Tf2EquipmentPresentation { this.#button(root, "NextPage", ">", "next", "c260", "288", "25", "20") } }) + if (this.#pending) this.#setEquipEnabled(false) + const panels = this.#runtime.snapshot().panels + if (this.#page === "loadout") this.#linkLoadoutNavigation(panels.filter(panel => panel.name.startsWith("Itemslot-"))) + const focusName = restoreFocus ?? (this.#page === "classes" ? `Class${this.#class}` : this.#page === "loadout" ? `Itemslot-${this.#slot}` : `Itemitem-${this.#selected}`) + const focus = panels.find(panel => panel.name === focusName) ?? panels.find(panel => panel.name === "BackButton") + if (focus) this.#apply({ kind: "request-focus", panel: focus.id }) } - #item(parent: VguiPanelId, item: Tf2SupportedItem | undefined, command: string, x: string, y: string, template: Tf2UiResourceNode, name: boolean): void { - const selected = item?.item.definitionIndex === this.#selected + #item(parent: VguiPanelId, item: Tf2SupportedItem | undefined, command: string, x: string, y: string, template: Tf2UiResourceNode, name: boolean, cellSelected = false): void { + const selected = cellSelected || item?.item.definitionIndex === this.#selected const button = this.#button(parent, `Item${command.replaceAll(" ", "-") || `${x}-${y}`}`, "", command, x, y, scalar(template, "wide")!, scalar(template, "tall")!, { border: selected ? "BackpackItemSelectedBorder" : `BackpackItemBorder${QUALITY_SUFFIX[item?.item.quality ?? 0]}`, tooltiptext: "", }) + if (!name) this.#cells.push(button) if (!item) { this.#apply({ kind: "set-panel-state", panel: button, enabled: command !== "" }); return } this.#itemPanels.set(button, item) this.#apply({ kind: "mutate-control", panel: button, mutation: { accessibleName: item.displayName, description: item.description.map(line => line.text).join("\n") } }) @@ -284,45 +303,172 @@ export class Tf2EquipmentPresentation { const inventory = this.#state!.inventory return this.#page === "slot" ? inventory.filter(item => item.classSlots.some(slot => slot.class === this.#class && slot.slot === this.#slot)) : inventory } - #command(command: string): void { - if (!this.#visible || this.#busy) return + #command(command: string, keyboard = false): void { + if (!this.#visible) return if (command === "back") { - if (this.#page === "classes") { this.hide(); return } + if (this.#page === "classes" || this.#page === "loadout" && this.#class === this.#closeClass) { this.hide(); return } this.#page = this.#page === "slot" ? "loadout" : "classes" - } else if (command === "backpack") { this.#page = "backpack"; this.#pageNumber = 0 } - else if (command.startsWith("class ")) { this.#class = Number(command.slice(6)) as Tf2Class; this.#page = "loadout" } - else if (command.startsWith("slot ")) { this.#slot = Number(command.slice(5)); this.#page = "slot"; this.#pageNumber = 0 } - else if (command === "prev") this.#pageNumber = Math.max(0, this.#pageNumber - 1) - else if (command === "next") this.#pageNumber = Math.min(Math.max(0, Math.ceil(this.#pageItems().length / 50) - 1), this.#pageNumber + 1) + } else if (command === "backpack") { this.#page = "backpack"; this.#pageNumber = 0; this.#selected = null; this.#cell = -1 } + else if (command.startsWith("class ")) { this.#class = Number(command.slice(6)) as Tf2Class; this.#slot = this.#class === 8 ? 1 : 0; this.#page = "loadout" } + else if (command.startsWith("slot ")) { + this.#slot = Number(command.slice(5)); this.#page = "slot"; this.#pageNumber = 0 + this.#selected = this.#state!.classes[this.#class - 1]!.items.find(item => item.slot === this.#slot)?.definitionIndex ?? null + this.#cell = this.#pageItems().findIndex(item => item.item.definitionIndex === this.#selected) + } + else if (command === "prev" || command === "next") { + const pages = Math.max(1, Math.ceil(this.#pageItems().length / 50)), requested = this.#pageNumber + (command === "prev" ? -1 : 1) + // Backpack command buttons wrap and deselect; the base panel's page keys + // stop at the ends and keep the selected cell. + const wrap = this.#page === "backpack" && !keyboard + const next = wrap ? (requested + pages) % pages : Math.max(0, Math.min(pages - 1, requested)) + if (next === this.#pageNumber) return + this.#pageNumber = next + if (wrap) this.#cell = -1 + this.#selected = this.#cell < 0 ? null : this.#pageItems()[next * 50 + this.#cell]?.item.definitionIndex ?? null + } else if (command === "unequip" || command.startsWith("item ")) { const definition = command === "unequip" ? null : Number(command.slice(5)) if (this.#page === "backpack" && definition !== null) { const item = this.#state!.inventory.find(item => item.item.definitionIndex === definition)! const slot = item.classSlots.find(slot => slot.class === this.#class) ?? item.classSlots[0]! this.#class = slot.class; this.#slot = slot.slot; this.#selected = definition; this.#page = "slot"; this.#pageNumber = 0 + this.#cell = this.#pageItems().findIndex(item => item.item.definitionIndex === definition) } else { - this.#busy = true - void this.#request.onEquip(this.#class, this.#slot, definition).then(state => { this.#state = state; this.#page = "loadout"; this.#selected = null }) - .finally(() => { this.#busy = false; this.#render() }) + if (this.#pending) return + const pending = new AbortController() + this.#pending = pending + const playerClass = this.#class, slot = this.#slot + this.#setEquipEnabled(false) + void (async () => { + try { + pending.signal.throwIfAborted() + const state = await this.#request.onEquip(playerClass, slot, definition, pending.signal) + if (this.#destroyed) return + // A dispatched native mutation can finish after Back. Retain its + // authoritative state, but never replay its old navigation. + const previousItems = this.#state?.classes[this.#class - 1]?.items, nextItems = state.classes[this.#class - 1]!.items + const loadoutChanged = !previousItems || previousItems.length !== nextItems.length || previousItems.some((item, index) => item.itemId !== nextItems[index]!.itemId || item.slot !== nextItems[index]!.slot) + const changed = !this.#state || ((state.revision - this.#state.revision) | 0) > 0 + if (changed) this.#state = state + if (this.#pending === pending) { + this.#page = "loadout"; this.#selected = null + this.#render() + if (this.#pending === pending) this.#pending = undefined + } else if (changed && this.#visible && (this.#page === "backpack" || this.#page === "slot" || this.#page === "loadout" && loadoutChanged)) { + const snapshot = this.#runtime.snapshot() + this.#render(snapshot.panels.find(panel => panel.id === snapshot.input.keyFocus)?.name) + } + } catch (error) { + if (this.#destroyed) return + const current = this.#pending === pending + if (current) { this.#pending = undefined; this.#setEquipEnabled(true) } + if (!(error instanceof DOMException && error.name === "AbortError")) this.#request.onError(error, current) + } + })() return } } else return + this.#cancelEquip() this.#render() } - handleKey(event: Pick): boolean { - if (!this.#visible || event.code !== "Escape") return false - this.#command("back"); event.preventDefault(); event.stopImmediatePropagation(); return true + #cancelEquip(): void { + this.#pending?.abort() + this.#pending = undefined + } + #setEquipEnabled(enabled: boolean): void { + this.#runtime.deferPresentation(() => { + for (const panel of this.#runtime.snapshot().panels) { + if (panel.name.startsWith("Itemitem-") || panel.name === "UnequipButton") this.#apply({ kind: "set-panel-state", panel: panel.id, enabled }) + } + }) + } + #linkLoadoutNavigation(panels: readonly Readonly<{ id: VguiPanelId; bounds: Readonly<{ x: number; y: number; width: number; height: number }> }>[]): void { + // CBaseLoadoutPanel links the closest directional score, including the + // reciprocal edge, in model-panel order. + for (const panel of panels) this.#navigation.set(panel.id, new Map()) + const directions = [["ArrowUp", "ArrowDown", 0, -1], ["ArrowDown", "ArrowUp", 0, 1], ["ArrowLeft", "ArrowRight", -1, 0], ["ArrowRight", "ArrowLeft", 1, 0]] as const + for (const panel of panels) for (const [key, reverse, dx, dy] of directions) { + const links = this.#navigation.get(panel.id)! + if (links.has(key)) continue + let score = (this.#viewport.width + this.#viewport.height) * 2.5, best: VguiPanelId | undefined + for (const candidate of panels) { + if (candidate === panel) continue + const x = candidate.bounds.x + Math.trunc(candidate.bounds.width / 2) - panel.bounds.x - Math.trunc(panel.bounds.width / 2) + const y = candidate.bounds.y + Math.trunc(candidate.bounds.height / 2) - panel.bounds.y - Math.trunc(panel.bounds.height / 2) + const distance = Math.hypot(x, y), dot = distance ? (x * dx + y * dy) / distance : 0 + const value = distance * (1.5 - dot) + if (dot > 0 && value < score) { score = value; best = candidate.id } + } + if (best !== undefined) { links.set(key, best); this.#navigation.get(best)!.set(reverse, panel.id) } + } + } + #navigate(key: string): void { + if (this.#pending) return + const snapshot = this.#runtime.snapshot(), focused = snapshot.input.calculatedKeyFocus + let target: VguiPanelId | null | undefined + if (this.#page === "loadout") target = focused === null ? undefined : this.#navigation.get(focused)?.get(key) + else if (this.#page === "classes") { + const classes = snapshot.panels.filter(panel => /^Class[1-9]$/.test(panel.name)) + const index = classes.findIndex(panel => panel.id === focused) + const name = key === "ArrowDown" ? "BackpackButton" : key === "ArrowUp" && index < 0 ? classes[0]?.name + : index >= 0 && (key === "ArrowLeft" || key === "ArrowRight") ? classes[(index + (key === "ArrowLeft" ? -1 : 1) + classes.length) % classes.length]?.name : undefined + target = snapshot.panels.find(panel => panel.name === name)?.id + } else { + let cell = this.#cell, page = this.#pageNumber + if (cell < 0) cell = 0 + else { + const row = Math.floor(cell / 10) + (key === "ArrowUp" ? -1 : key === "ArrowDown" ? 1 : 0) + let column = cell % 10 + (key === "ArrowLeft" ? -1 : key === "ArrowRight" ? 1 : 0) + if (row < 0 || row >= 5) return + if (column < 0 && page > 0) { page--; column += 10 } + if (column >= 10 && page < Math.ceil(this.#pageItems().length / 50) - 1) { page++; column -= 10 } + if (column < 0 || column >= 10) return + cell = row * 10 + column + } + const previous = this.#cell + this.#cell = cell; this.#selected = this.#pageItems()[page * 50 + cell]?.item.definitionIndex ?? null + if (page !== this.#pageNumber) { this.#pageNumber = page; this.#render() } + else this.#runtime.deferPresentation(() => { + if (previous >= 0) this.#apply({ kind: "mutate-control", panel: this.#cells[previous]!, mutation: { border: `BackpackItemBorder${QUALITY_SUFFIX[this.#pageItems()[page * 50 + previous]?.item.quality ?? 0]}` } }) + this.#apply({ kind: "mutate-control", panel: this.#cells[cell]!, mutation: { border: "BackpackItemSelectedBorder" } }) + }) + const panel = this.#runtime.snapshot().panels.find(panel => panel.id === this.#cells[cell]) + target = panel?.enabled ? panel.id : null + } + if (target !== undefined) { + this.#apply({ kind: "request-focus", panel: target }) + this.#apply({ kind: "frame", timeSeconds: this.#request.clock.nowSeconds() }) + } + } + handleKey(event: Pick & Partial>): boolean { + if (!this.#visible || event.isComposing) return false + if ((this.#page === "slot" || this.#page === "backpack") && this.#cell >= 0 && this.#selected === null && (event.code === "Enter" || event.code === "NumpadEnter")) { + event.preventDefault(); event.stopImmediatePropagation(); return true + } + if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(event.code)) { + event.preventDefault(); event.stopImmediatePropagation(); this.#navigate(event.code); return true + } + const command = event.code === "Escape" ? "back" : this.#page === "backpack" || this.#page === "slot" + ? event.code === "PageUp" ? "prev" : event.code === "PageDown" ? "next" : undefined : undefined + if (!command) return false + event.preventDefault(); event.stopImmediatePropagation() + if (!event.repeat || command !== "back") this.#command(command, true) + return true } frame(timeSeconds: number): void { if (!this.#visible) return this.#apply({ kind: "frame", timeSeconds }) if (this.#hover && timeSeconds >= this.#hover.since + 0.1) this.#showTooltip() } - setViewport(viewport: VguiViewport): void { this.#viewport = viewport; this.#apply({ kind: "set-viewport", viewport }); this.#render() } + setViewport(viewport: VguiViewport): void { + const snapshot = this.#runtime.snapshot(), focused = snapshot.panels.find(panel => panel.id === snapshot.input.keyFocus)?.name + this.#viewport = viewport; this.#apply({ kind: "set-viewport", viewport }); this.#render(focused) + } destroy(): void { + this.#destroyed = true; this.#visible = false; this.#cancelEquip() this.#releaseSurface?.(); this.#request.onPreview(null) - this.#request.root.removeEventListener("pointerover", this.#enterItem); this.#request.root.removeEventListener("focusin", this.#enterItem) - this.#request.root.removeEventListener("pointerout", this.#leaveItem); this.#request.root.removeEventListener("focusout", this.#leaveItem) + this.#request.root.removeEventListener("pointerover", this.#enterItem) + this.#request.root.removeEventListener("pointerout", this.#leaveItem) this.#itemPanels.clear(); this.#hover = undefined; this.#apply({ kind: "destroy" }) } } diff --git a/games/tf2/browser/src/equipment/profile.ts b/games/tf2/browser/src/equipment/profile.ts index d03372b11..ef902ca6d 100644 --- a/games/tf2/browser/src/equipment/profile.ts +++ b/games/tf2/browser/src/equipment/profile.ts @@ -41,8 +41,9 @@ export class Tf2EquipmentProfile { return state } - equip(playerClass: Tf2Class, slot: number, definitionIndex: number | null): Promise { + equip(playerClass: Tf2Class, slot: number, definitionIndex: number | null, signal?: AbortSignal): Promise { const task = this.#tail.then(async () => { + signal?.throwIfAborted() if (this.#closed || !this.#state) throw new Error("Equipment profile is unavailable") const mutation = new Uint8Array(7) mutation.set([1, playerClass, slot]) diff --git a/games/tf2/browser/tests/equipment-presentation.test.ts b/games/tf2/browser/tests/equipment-presentation.test.ts index eb0c71eea..c27e520d0 100644 --- a/games/tf2/browser/tests/equipment-presentation.test.ts +++ b/games/tf2/browser/tests/equipment-presentation.test.ts @@ -1,10 +1,310 @@ import { expect, test } from "bun:test" -import { createRoot, byName, pointer } from "../../../../packages/presentation/vgui/tests/fake-dom" +import { createRoot, byName, pointer, FakeEvent } from "../../../../packages/presentation/vgui/tests/fake-dom" import { createTf2GameUiTransitionFixture } from "./gameui-integration/fixture" import { Tf2EquipmentPresentation } from "../src/equipment/presentation" import type { Tf2EquipmentState } from "../src/equipment/types" import { nativeEquipment } from "./fixtures/equipment" +function navigationFixture(onEquip: (playerClass: number, slot: number, definition: number | null, signal: AbortSignal) => Promise = async () => nativeEquipment) { + const fixture = createTf2GameUiTransitionFixture(), root = createRoot(fixture.document) + const resources = { ...fixture.resources, clientScheme: { ...fixture.resources.clientScheme, borders: ["Econ.Button.Border.Default", "BackpackItemBorder", "BackpackItemBorder_Unique", "BackpackItemBorder_4", "BackpackItemSelectedBorder"].map(name => ({ + kind: "line" as const, name, inset: { left: 0, top: 0, right: 0, bottom: 0 }, backgroundType: 0 as const, paintFirst: false, sides: { left: [], top: [], right: [], bottom: [] }, + })) } } + const errors: unknown[] = [], errorCurrent: boolean[] = [] + let closes = 0 + const ui = new Tf2EquipmentPresentation({ root: root as unknown as HTMLElement, resources, + viewport: { width: 1280, height: 800, devicePixelRatio: 1 }, reducedMotion: true, + clock: { nowSeconds: () => 0 }, random: { nextUnit: () => 0 }, onClose() { closes++ }, onPreview() {}, onError(error, current) { errors.push(error); errorCurrent.push(current) }, onEquip }) + const activate = (name: string, fraction = 0.5) => { + const bounds = ui.snapshot().vgui.panels.find(panel => panel.name === name)!.bounds + // Dispatch at the root: VGUI, not the DOM fixture, must find the target. + const viewport = byName(root, "EquipmentViewport") + pointer(viewport, "pointerdown", bounds.x + bounds.width * fraction, bounds.y + bounds.height * fraction) + pointer(viewport, "pointerup", bounds.x + bounds.width * fraction, bounds.y + bounds.height * fraction) + ui.frame(0) + } + const key = (code: string, repeat = false) => ui.handleKey({ code, repeat, preventDefault() {}, stopImmediatePropagation() {} }) + return { ui, root, activate, key, escape: (repeat = false) => key("Escape", repeat), errors, errorCurrent, closes: () => closes } +} + +test("loadout Back is hit across its full button", () => { + const { ui, activate } = navigationFixture() + try { + for (const fraction of [0.05, 0.5, 0.95]) { + ui.show(nativeEquipment); activate("Class3") + activate("BackButton", fraction) + expect(ui.snapshot().page).toBe("classes") + } + } finally { ui.destroy() } +}) + +test("pending equip cannot swallow Back or pull a reopened page into its completion", async () => { + const pending = Promise.withResolvers() + const { ui, activate, escape } = navigationFixture(() => pending.promise) + try { + ui.show(nativeEquipment); activate("Class3") + activate("Itemslot-0"); activate("Itemitem-18") + expect(ui.snapshot().page).toBe("slot") + escape() + expect(ui.snapshot().page).toBe("loadout") + ui.hide(); ui.show(nativeEquipment) + pending.resolve(nativeEquipment) + await Promise.resolve(); await Promise.resolve(); await Promise.resolve() + expect(ui.snapshot().page).toBe("classes") + } finally { ui.destroy() } +}) + +test("held Escape leaves only the intended equipment layer", () => { + const { ui, activate, escape } = navigationFixture() + try { + ui.show(nativeEquipment); activate("Class3") + escape(); escape(true) + expect(ui.visible()).toBe(true) + expect(ui.snapshot().page).toBe("classes") + } finally { ui.destroy() } +}) + +test("class-menu loadout closes directly, while slot cancellation restores its focused slot", () => { + const { ui, activate, escape } = navigationFixture() + try { + ui.show(nativeEquipment, 3) + activate("Itemslot-1"); escape(); ui.frame(0) + const snapshot = ui.snapshot().vgui + expect(snapshot.panels.find(panel => panel.id === snapshot.input.keyFocus)?.name).toBe("Itemslot-1") + escape() + expect(ui.visible()).toBe(false) + } finally { ui.destroy() } +}) + +test("pending equip disables item mutations, not the actual Back hit target", async () => { + const pending = Promise.withResolvers(), calls: AbortSignal[] = [] + const { ui, activate } = navigationFixture(async (_playerClass, _slot, _definition, signal) => { calls.push(signal); return pending.promise }) + try { + ui.show(nativeEquipment, 3); activate("Itemslot-0"); activate("Itemitem-18"); activate("Itemitem-18") + expect(calls).toHaveLength(1) + expect(ui.snapshot().vgui.panels.find(panel => panel.name === "Itemitem-18")!.enabled).toBe(false) + activate("BackButton", 0.95) + expect(ui.snapshot().page).toBe("loadout") + expect(calls[0]!.aborted).toBe(true) + pending.resolve(nativeEquipment) + await Promise.resolve(); await Promise.resolve(); await Promise.resolve() + expect(ui.snapshot().page).toBe("loadout") + } finally { ui.destroy() } +}) + +test("failed equip reports the real error, preserves selection and permits retry", async () => { + const pending = Promise.withResolvers() + const { ui, activate, errors } = navigationFixture(() => pending.promise) + try { + ui.show(nativeEquipment, 3); activate("Itemslot-0"); activate("Itemitem-18") + const failure = new Error("model preparation failed") + pending.reject(failure) + await Promise.resolve(); await Promise.resolve() + expect(errors).toEqual([failure]) + expect(ui.snapshot().page).toBe("slot") + expect(ui.snapshot().vgui.panels.find(panel => panel.name === "Itemitem-18")!.enabled).toBe(true) + } finally { ui.destroy() } +}) + +test("destroyed presentation cannot be revived by an equip completion", async () => { + const pending = Promise.withResolvers() + const { ui, activate, errors } = navigationFixture(() => pending.promise) + ui.show(nativeEquipment, 3); activate("Itemslot-0"); activate("Itemitem-18"); ui.destroy() + pending.resolve({ ...nativeEquipment, revision: nativeEquipment.revision + 1 }) + await Promise.resolve(); await Promise.resolve() + expect(ui.visible()).toBe(false) + expect(errors).toEqual([]) +}) + +test("late failures are reported without taking over a reopened page", async () => { + const pending = Promise.withResolvers() + const { ui, activate, errors, errorCurrent } = navigationFixture(() => pending.promise) + try { + ui.show(nativeEquipment, 3); activate("Itemslot-0"); activate("Itemitem-18") + ui.hide(); ui.show(nativeEquipment) + const error = new Error("resource preparation rejected") + pending.reject(error) + await Promise.resolve(); await Promise.resolve() + expect(errors).toEqual([error]); expect(errorCurrent).toEqual([false]) + expect(ui.snapshot().page).toBe("classes") + } finally { ui.destroy() } +}) + +test("the native wrapping revision does not discard a committed equipment change", async () => { + const weapon = nativeEquipment.inventory.find(item => item.item.definitionIndex === 127)! + const next = { ...nativeEquipment, revision: 0, classes: nativeEquipment.classes.map(player => player.class === 3 + ? { ...player, items: player.items.map(item => item.slot === 0 ? { ...weapon.item, slot: 0 } : item) } : player) } + const { ui, root, activate } = navigationFixture(async () => next) + try { + ui.show({ ...nativeEquipment, revision: 0xffff_ffff }, 3) + activate("Itemslot-0"); activate("Itemitem-127") + await Promise.resolve(); await Promise.resolve() + expect(byName(root, "Itemslot-0").textContent).toContain(weapon.displayName) + } finally { ui.destroy() } +}) + +test("a late commit for another class does not rebuild or refocus the active loadout", async () => { + const pending = Promise.withResolvers() + const { ui, activate, key } = navigationFixture(() => pending.promise) + try { + ui.show(nativeEquipment, 3); activate("Itemslot-0"); activate("Itemitem-18") + ui.show(nativeEquipment, 8); ui.frame(0) + key("ArrowDown") + const before = ui.snapshot().vgui + const focused = before.input.keyFocus + expect(before.panels.find(panel => panel.id === focused)?.name).toBe("Itemslot-2") + pending.resolve({ ...nativeEquipment, revision: nativeEquipment.revision + 1 }) + await Promise.resolve(); await Promise.resolve(); ui.frame(0) + expect(ui.snapshot().vgui.input.keyFocus).toBe(focused) + } finally { ui.destroy() } +}) + +test("equipment leaves composing keys to their text owner", () => { + const { ui } = navigationFixture() + try { + ui.show(nativeEquipment, 3) + expect(ui.handleKey({ code: "Escape", isComposing: true, preventDefault() { throw new Error("IME default was cancelled") }, stopImmediatePropagation() { throw new Error("IME propagation was stopped") } })).toBe(false) + expect(ui.visible()).toBe(true) + } finally { ui.destroy() } +}) + +test("owner-driven hiding cancels work without restoring a replaced page's focus", async () => { + const pending = Promise.withResolvers(), signals: AbortSignal[] = [] + const { ui, activate, closes } = navigationFixture((_playerClass, _slot, _definition, signal) => { signals.push(signal); return pending.promise }) + try { + ui.show(nativeEquipment, 3); activate("Itemslot-0"); activate("Itemitem-18") + ui.hide(false) + expect(signals[0]!.aborted).toBe(true) + pending.resolve(nativeEquipment) + await Promise.resolve(); await Promise.resolve() + expect(closes()).toBe(0) + expect(ui.visible()).toBe(false) + } finally { ui.destroy() } +}) + +test("backpack page keys use the same bounded command path and do not rebuild at an edge", () => { + const { ui, activate, key } = navigationFixture() + try { + ui.show(nativeEquipment); activate("BackpackButton") + expect(ui.snapshot().vgui.panels.find(panel => panel.name === "PrevPage")!.enabled).toBe(true) + expect(ui.snapshot().vgui.panels.find(panel => panel.name === "NextPage")!.enabled).toBe(true) + const before = ui.snapshot().vgui.panels.find(panel => panel.name === "LocalEquipment")!.id + expect(key("PageUp")).toBe(true) + expect(ui.snapshot().vgui.panels.find(panel => panel.name === "LocalEquipment")!.id).toBe(before) + key("PageDown") + expect(ui.snapshot().vgui.panels.find(panel => panel.name === "PrevPage")!.enabled).toBe(true) + expect(ui.snapshot().vgui.panels.find(panel => panel.name === "NextPage")!.enabled).toBe(true) + expect(ui.snapshot().vgui.panels.find(panel => panel.name === "LocalEquipment")!.id).not.toBe(before) + key("PageUp") + activate("PrevPage") + expect(ui.snapshot().vgui.panels.some(panel => panel.name === `Itemitem-${nativeEquipment.inventory[50]!.item.definitionIndex}`)).toBe(true) + activate("NextPage") + expect(ui.snapshot().vgui.panels.some(panel => panel.name === `Itemitem-${nativeEquipment.inventory[0]!.item.definitionIndex}`)).toBe(true) + for (const fraction of [0.05, 0.5, 0.95]) { + activate("BackButton", fraction) + expect(ui.snapshot().page).toBe("classes") + activate("BackpackButton") + } + } finally { ui.destroy() } +}) + +test("class and loadout directional focus activates the real button and survives resize", () => { + const { ui, root, key, escape } = navigationFixture() + const focus = () => { + const snapshot = ui.snapshot().vgui + return snapshot.panels.find(panel => panel.id === snapshot.input.keyFocus)!.name + } + const enter = () => { + const target = byName(root, focus()) + for (const type of ["keydown", "keyup"]) { + const event = new FakeEvent(type, { key: "Enter", code: "Enter", bubbles: true }) + event.target = target + root.ownerDocument.dispatchEvent(event) + } + ui.frame(0) + } + try { + ui.show(nativeEquipment); ui.frame(0) + expect(focus()).toBe("Class1") + key("ArrowRight") + expect(focus()).toBe("Class3") + enter() + expect(ui.snapshot().page).toBe("loadout") + key("ArrowDown") + expect(focus()).toBe("Itemslot-1") + ui.setViewport({ width: 1440, height: 900, devicePixelRatio: 1 }); ui.frame(0) + expect(focus()).toBe("Itemslot-1") + enter() + expect(ui.snapshot().page).toBe("slot") + escape(); ui.frame(0) + expect(focus()).toBe("Itemslot-1") + } finally { ui.destroy() } +}) + +test("a held equipment Enter presses the button but changes only one page on release", () => { + const { ui, root } = navigationFixture() + const send = (type: string, repeat = false) => { + const snapshot = ui.snapshot().vgui + const name = snapshot.panels.find(panel => panel.id === snapshot.input.keyFocus)!.name + const event = new FakeEvent(type, { key: "Enter", code: "Enter", repeat }) + event.target = byName(root, name) + root.ownerDocument.dispatchEvent(event) + } + try { + ui.show(nativeEquipment); ui.frame(0) + send("keydown") + expect(ui.snapshot().page).toBe("classes") + expect(ui.snapshot().vgui.panels.find(panel => panel.name === "Class1")!.state.depressed).toBe(true) + send("keydown", true) + expect(ui.snapshot().page).toBe("classes") + send("keyup") + expect(ui.snapshot().page).toBe("loadout") + ui.frame(0) + send("keydown") + root.ownerDocument.defaultView.dispatchEvent(new FakeEvent("blur")) + expect(ui.snapshot().vgui.panels.find(panel => panel.name === "Itemslot-0")!.state.depressed).toBe(false) + expect(ui.snapshot().page).toBe("loadout") + } finally { ui.destroy() } +}) + +test("page replacement retains browser keyboard ownership until the next Source focus frame", () => { + const { ui, root } = navigationFixture() + try { + ui.show(nativeEquipment); ui.frame(0) + const focused = root.ownerDocument.activeElement! + for (const type of ["keydown", "keyup"]) { + const event = new FakeEvent(type, { key: "Enter", code: "Enter" }) + event.target = focused; root.ownerDocument.dispatchEvent(event) + } + expect(ui.snapshot().page).toBe("loadout") + expect(root.contains(root.ownerDocument.activeElement)).toBe(true) + const tab = new FakeEvent("keydown", { key: "Tab", code: "Tab", repeat: true }) + tab.target = root.ownerDocument.activeElement + root.ownerDocument.dispatchEvent(tab) + expect(tab.defaultPrevented).toBe(true) + } finally { ui.destroy() } +}) + +test("grid arrows preserve row boundaries, cross pages horizontally and select empty cells without equipping", () => { + const calls: unknown[] = [] + const { ui, activate, key } = navigationFixture(async (...args) => { calls.push(args); return nativeEquipment }) + try { + ui.show(nativeEquipment); activate("BackpackButton") + key("ArrowRight") + const first = ui.snapshot().vgui.input.keyFocus + key("ArrowUp") + expect(ui.snapshot().vgui.input.keyFocus).toBe(first) + for (let index = 0; index < 10; index++) key("ArrowRight") + expect(ui.snapshot().vgui.panels.some(panel => panel.name === `Itemitem-${nativeEquipment.inventory[50]!.item.definitionIndex}`)).toBe(true) + key("ArrowDown"); key("ArrowDown") + expect(calls).toEqual([]) + const snapshot = ui.snapshot().vgui + expect(snapshot.input.keyFocus).toBeNull() + expect(key("Enter")).toBe(true) + expect(calls).toEqual([]) + } finally { ui.destroy() } +}) + test("item tooltip has visible bounds after the authored hover delay", () => { const fixture = createTf2GameUiTransitionFixture(), root = createRoot(fixture.document) const resources = { ...fixture.resources, clientScheme: { ...fixture.resources.clientScheme, borders: ["Econ.Button.Border.Default", "BackpackItemBorder", "BackpackItemMouseOverBorder"].map(name => ({ @@ -14,12 +314,15 @@ test("item tooltip has visible bounds after the authored hover delay", () => { let now = 0 const ui = new Tf2EquipmentPresentation({ root: root as unknown as HTMLElement, resources, viewport: { width: 1280, height: 800, devicePixelRatio: 1 }, reducedMotion: true, - clock: { nowSeconds: () => now }, random: { nextUnit: () => 0 }, onClose() {}, onPreview() {}, onEquip: async () => nativeEquipment }) + clock: { nowSeconds: () => now }, random: { nextUnit: () => 0 }, onClose() {}, onPreview() {}, onError(error) { throw error }, onEquip: async () => nativeEquipment }) ui.show(nativeEquipment, 3) byName(root, "Itemslot-0").focus() - now = 0.099; ui.frame(now) - expect(ui.snapshot().vgui.panels.some(panel => panel.name === "ItemTooltip")).toBe(false) now = 0.1; ui.frame(now) + expect(ui.snapshot().vgui.panels.some(panel => panel.name === "ItemTooltip")).toBe(false) + byName(root, "Itemslot-0").dispatchEvent(new FakeEvent("pointerover", { bubbles: true })) + now = 0.199; ui.frame(now) + expect(ui.snapshot().vgui.panels.some(panel => panel.name === "ItemTooltip")).toBe(false) + now = 0.2; ui.frame(now) const tooltip = ui.snapshot().vgui.panels.find(panel => panel.name === "ItemTooltip")! expect(tooltip).toBeDefined() expect(tooltip.visible).toBe(true) @@ -40,7 +343,7 @@ test("authored equipment roots construct class controls and class-slot panels fr const state = { revision: 0, inventory: [], classes: Array.from({ length: 9 }, (_, index) => ({ class: index + 1, items: [], baseItems: [] })), persistence: new Uint8Array(692) } as Tf2EquipmentState const ui = new Tf2EquipmentPresentation({ root: createRoot(fixture.document) as unknown as HTMLElement, resources, viewport: { width: 1280, height: 720, devicePixelRatio: 1 }, reducedMotion: true, - clock: { nowSeconds: () => 0 }, random: { nextUnit: () => 0 }, onClose() {}, onPreview() {}, onEquip: async () => state }) + clock: { nowSeconds: () => 0 }, random: { nextUnit: () => 0 }, onClose() {}, onPreview() {}, onError(error) { throw error }, onEquip: async () => state }) ui.show(state) expect(ui.snapshot().vgui.panels.filter(panel => /^Class[1-9]$/.test(panel.name))).toHaveLength(9) ui.show(state, 8) @@ -63,7 +366,7 @@ test("a second backpack page enters the selected class-slot list at its first pa const equipped: unknown[] = [] const ui = new Tf2EquipmentPresentation({ root: root as unknown as HTMLElement, resources, viewport: { width: 1280, height: 800, devicePixelRatio: 1 }, reducedMotion: true, clock: { nowSeconds: () => 0 }, random: { nextUnit: () => 0 }, - onClose() {}, onPreview() {}, onEquip: async (...args) => { equipped.push(args); return state } }) + onClose() {}, onPreview() {}, onError(error) { throw error }, onEquip: async (playerClass, slot, definition) => { equipped.push([playerClass, slot, definition]); return state } }) ui.show(state) const activate = (name: string) => { const element = byName(root, name), bounds = ui.snapshot().vgui.panels.find(panel => panel.name === name)!.bounds diff --git a/games/tf2/browser/tests/equipment-profile.test.ts b/games/tf2/browser/tests/equipment-profile.test.ts new file mode 100644 index 000000000..eb83b7047 --- /dev/null +++ b/games/tf2/browser/tests/equipment-profile.test.ts @@ -0,0 +1,67 @@ +import { expect, test } from "bun:test" +import { Tf2EquipmentProfile } from "../src/equipment/profile" +import { nativeEquipment } from "./fixtures/equipment" +import type { Tf2EquipmentState } from "../src/equipment/types" + +test("cancelled queued equipment never reaches the native mutation owner", async () => { + const pending = Promise.withResolvers(), writes: string[] = [] + let calls = 0 + const profile = new Tf2EquipmentProfile({ equipment: async (_generation, mutation) => { + if (!mutation) return nativeEquipment + calls++ + return pending.promise + } }, { getItem: () => null, setItem: (_key, value) => writes.push(value) }) + await profile.initialize() + const first = profile.equip(3, 0, 18) + const cancelled = new AbortController(), second = profile.equip(3, 0, 127, cancelled.signal) + const rejected = second.catch(error => error) + cancelled.abort() + const state = { ...nativeEquipment, revision: nativeEquipment.revision + 1 } + pending.resolve(state) + expect(await first).toBe(state) + expect(await rejected).toMatchObject({ name: "AbortError" }) + expect(calls).toBe(1) + expect(writes).toHaveLength(1) + expect(profile.state()).toBe(state) +}) + +test("a dispatched mutation remains authoritative after navigation cancellation", async () => { + const pending = Promise.withResolvers(), signal = new AbortController() + const profile = new Tf2EquipmentProfile({ equipment: async (_generation, mutation) => mutation ? pending.promise : nativeEquipment }) + await profile.initialize() + const mutation = profile.equip(3, 0, 127, signal.signal) + await Promise.resolve() + signal.abort() + const state = { ...nativeEquipment, revision: nativeEquipment.revision + 1 } + pending.resolve(state) + expect(await mutation).toBe(state) + expect(profile.state()).toBe(state) +}) + +test("native failure preserves state and does not poison the next equipment transaction", async () => { + let fail = true + const error = new Error("native equip rejected") + const profile = new Tf2EquipmentProfile({ equipment: async (_generation, mutation) => { + if (mutation && fail) throw error + return nativeEquipment + } }) + await profile.initialize() + await expect(profile.equip(3, 0, 127)).rejects.toBe(error) + expect(profile.state()).toBe(nativeEquipment) + fail = false + expect(await profile.equip(3, 0, 18)).toBe(nativeEquipment) +}) + +test("closing the profile prevents a late native result from persisting into its replacement", async () => { + const pending = Promise.withResolvers(), writes: string[] = [] + const profile = new Tf2EquipmentProfile({ equipment: async (_generation, mutation) => mutation ? pending.promise : nativeEquipment }, + { getItem: () => null, setItem: (_key, value) => writes.push(value) }) + await profile.initialize() + const mutation = profile.equip(3, 0, 127) + const rejected = mutation.catch(error => error) + await Promise.resolve() + profile.close(); pending.resolve(nativeEquipment) + expect((await rejected).message).toContain("replaced") + expect(profile.state()).toBeUndefined() + expect(writes).toEqual([]) +}) diff --git a/packages/presentation/vgui/scripts/verify-retained-raster-browser.ts b/packages/presentation/vgui/scripts/verify-retained-raster-browser.ts index a86957a16..520fa1cd8 100644 --- a/packages/presentation/vgui/scripts/verify-retained-raster-browser.ts +++ b/packages/presentation/vgui/scripts/verify-retained-raster-browser.ts @@ -1,81 +1,3 @@ -import { mkdir, writeFile } from "node:fs/promises" -import path from "node:path" -import { chromium } from "@playwright/test" -import { loadLocalConfig } from "../../../../tools/playsrc/src/config" -import { acquireHeadedProfileLock, releaseHeadedProfileLock } from "../../../../tools/playsrc/src/profile-lock" -import { decodeScreenshot } from "../../../../tools/playsrc/profile/screenshot-pixels" +import { runHeadedProfile } from "../../../../tools/playsrc/src/profile-runner" -const config = await loadLocalConfig() -const directory = path.join(config.sourceCacheDir, "evidence", "tf2-browser-performance") -await mkdir(directory, { recursive: true }) -const lockPath = path.join(directory, "chromium-profile.lock") -const lock = await acquireHeadedProfileLock(lockPath, "retained-raster-parity", 100_000) -let browser: Awaited> | undefined -let server: ReturnType | undefined -const deadline = setTimeout(() => { void browser?.close(); server?.stop(true) }, 70_000) -try { - const bundle = await Bun.build({ entrypoints: [new URL("../tests/retained-raster-browser-fixture.ts", import.meta.url).pathname], target: "browser" }) - if (!bundle.success) throw new Error("Retained raster parity fixture failed to build") - const script = await bundle.outputs[0]!.text() - server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch(request) { - if (new URL(request.url).pathname === "/fixture.js") return new Response(script, { headers: { "content-type": "text/javascript" } }) - return new Response(`Visible retained VGUI raster parity
`, { headers: { "content-type": "text/html" } }) - } }) - browser = await chromium.launch({ headless: false, channel: "msedge" }) - const evidence: unknown[] = [] - for (const deviceScaleFactor of [1, 1.25, 1.5, 2]) { - const context = await browser.newContext({ viewport: { width: 1280, height: 720 }, deviceScaleFactor }) - const page = await context.newPage() - await page.goto(server.url.href) - await page.bringToFront() - await page.waitForFunction(() => document.body.dataset.ready === "true") - for (const viewport of [{ width: 1280, height: 720 }, { width: 1920, height: 1080 }]) { - await page.setViewportSize(viewport) - const button = page.getByRole("button", { name: "Retained foreground" }) - await button.click() - await page.keyboard.press("Enter") - await button.blur() - const images = await page.screenshot() - await page.evaluate(() => { - document.querySelector("#retained")!.style.display = "none" - document.querySelector("#baseline")!.style.display = "block" - }) - const canvases = await page.screenshot() - const before = decodeScreenshot(canvases) - const after = decodeScreenshot(images) - let differentChannels = 0 - if (before.width !== after.width || before.height !== after.height || before.channels !== after.channels) throw new Error("Retained raster screenshot extent differs") - for (let index = 0; index < before.pixels.length; index += 1) if (before.pixels[index] !== after.pixels[index]) differentChannels += 1 - const state = await page.evaluate(() => ({ visible: document.visibilityState === "visible", focused: document.hasFocus(), dpr: devicePixelRatio, activations: document.body.dataset.activations, unchangedRasterWrites: Number(document.body.dataset.unchangedRasterWrites), changedRasterWrites: Number(document.body.dataset.changedRasterWrites), rasterMutations: Number(document.body.dataset.rasterMutations ?? 0), - clip: getComputedStyle(document.querySelector("#panel")!).clipPath, - opacity: getComputedStyle(document.querySelector("#panel")!).opacity, - transform: getComputedStyle(document.querySelector("#panel")!).transform, - rasterBounds: document.querySelector("#baseline")!.getBoundingClientRect().toJSON(), - })) - if (!state.visible || !state.focused || state.dpr !== deviceScaleFactor || Number(state.activations) < 2) throw new Error("Retained raster input/visibility parity failed") - if (state.unchangedRasterWrites !== 0 || state.rasterMutations !== 0) throw new Error("Unchanged raster reset or repainted its retained canvas") - if (state.changedRasterWrites !== 2) throw new Error("Changed authored raster pixels were not published exactly once") - evidence.push({ viewport, deviceScaleFactor, physical: { width: after.width, height: after.height }, differentChannels, ...state }) - await writeFile(path.join(directory, `retained-raster-${viewport.width}-${deviceScaleFactor}.png`), images) - if (differentChannels !== 0) await writeFile(path.join(directory, `retained-raster-${viewport.width}-${deviceScaleFactor}-baseline.png`), canvases) - if (differentChannels !== 0) throw new Error(`Retained raster alpha/depth pixels differ at DPR ${deviceScaleFactor}: ${differentChannels} channels`) - await page.evaluate(() => { - document.querySelector("#retained")!.style.display = "block" - document.querySelector("#baseline")!.style.display = "none" - }) - } - await context.close() - } - await writeFile(path.join(directory, "retained-raster-parity.json"), `${JSON.stringify(evidence, null, 2)}\n`) - console.log(JSON.stringify(evidence)) -} finally { - clearTimeout(deadline) - await browser?.close() - server?.stop(true) - await releaseHeadedProfileLock(lockPath, lock.token) -} +process.exitCode = await runHeadedProfile(["vgui-raster-parity"]) diff --git a/packages/presentation/vgui/src/image-renderer.ts b/packages/presentation/vgui/src/image-renderer.ts index e3842d298..ba5337f35 100644 --- a/packages/presentation/vgui/src/image-renderer.ts +++ b/packages/presentation/vgui/src/image-renderer.ts @@ -269,7 +269,7 @@ export class VguiRasterCache { readonly #maximumEntries: number #bytes = 0 - constructor(maximumBytes = 64 * 1024 * 1024, maximumEntries = 512) { + constructor(maximumBytes = 64 * 1024 * 1024, maximumEntries = 512, private readonly dispose?: (value: Value) => void) { if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 1 || !Number.isSafeInteger(maximumEntries) || maximumEntries < 1) { throw new Error("VGUI raster cache bounds are invalid") } @@ -287,8 +287,12 @@ export class VguiRasterCache { set(identity: string, value: Value, bytes: number): void { if (!identity || !Number.isSafeInteger(bytes) || bytes < 0) throw new Error("VGUI raster cache entry is invalid") - this.delete(identity) - if (bytes > this.#maximumBytes) return + const previous = this.#entries.get(identity) + if (previous?.value === value) { + this.#entries.delete(identity) + this.#bytes -= previous.bytes + } else this.delete(identity) + if (bytes > this.#maximumBytes) { this.dispose?.(value); return } this.#entries.set(identity, Object.freeze({ value, bytes })) this.#bytes += bytes while (this.#bytes > this.#maximumBytes || this.#entries.size > this.#maximumEntries) { @@ -301,11 +305,11 @@ export class VguiRasterCache { if (!entry) return this.#entries.delete(identity) this.#bytes -= entry.bytes + this.dispose?.(entry.value) } clear(): void { - this.#entries.clear() - this.#bytes = 0 + for (const identity of this.#entries.keys()) this.delete(identity) } snapshot(): Readonly<{ entries: number; bytes: number }> { @@ -316,7 +320,10 @@ export class VguiRasterCache { export class VguiImageRasterizer { readonly #document: Document readonly #textures = new VguiRasterCache>() - readonly #renders = new VguiRasterCache>() + readonly #renders = new VguiRasterCache<{ ready: Promise; bitmap?: ImageBitmap; users: number; retained: boolean }>(64 * 1024 * 1024, 512, entry => { + entry.retained = false + if (entry.users === 0) entry.bitmap?.close() + }) readonly #presented = new WeakMap() readonly #pending = new WeakMap() #destroyed = false @@ -362,28 +369,49 @@ export class VguiImageRasterizer { const publication = {} this.#pending.set(canvas, publication) if (this.#presented.get(canvas) === signature && canvas.width === request.width && canvas.height === request.height) return - let rendering = this.#renders.get(signature) - if (!rendering) { - rendering = (async () => { + let entry = this.#renders.get(signature) + if (!entry) { + entry = { ready: Promise.resolve(), users: 0, retained: true } + const created = entry + created.ready = (async () => { const sources = [request.material.base, request.material.second, request.material.detail].filter((value): value is VguiImageMaterialTexture => value !== null) const loaded = await Promise.all(sources.map((texture) => this.#load(texture))) - return shadeVguiImageIncrementally( + const pixels = await shadeVguiImageIncrementally( request, new Map(sources.map((texture, index) => [texture.logicalIdentity, loaded[index]!])), () => new Promise(resolve => setTimeout(resolve, 0)), ) + if (this.#destroyed) return + // Share the exact painted bitmap, not repeated CPU pixel uploads or a + // PNG round trip (which changes low-alpha composited pixels). + const painted = this.#document.createElement("canvas") + painted.width = request.width; painted.height = request.height + try { + const context = painted.getContext("2d") + if (!context) throw new Error("VGUI presentation canvas is unavailable") + context.putImageData(new ImageData(pixels, request.width, request.height), 0, 0) + created.bitmap = await createImageBitmap(painted, { colorSpaceConversion: "none" }) + } finally { painted.width = 0; painted.height = 0 } })() - this.#renders.set(signature, rendering, request.width * request.height * 4) - void rendering.catch(() => this.#renders.delete(signature)) + this.#renders.set(signature, created, request.width * request.height * 4) + void created.ready.catch(() => { if (this.#renders.get(signature) === created) this.#renders.delete(signature) }) + } + entry.users++ + try { + await entry.ready + if (this.#destroyed || !canvas.isConnected || this.#pending.get(canvas) !== publication) return + if (canvas.width !== request.width) canvas.width = request.width + if (canvas.height !== request.height) canvas.height = request.height + const context = canvas.getContext("2d") + if (!context) throw new Error("VGUI presentation canvas is unavailable") + context.globalCompositeOperation = "copy" + try { context.drawImage(entry.bitmap!, 0, 0) } + finally { context.globalCompositeOperation = "source-over" } + this.#presented.set(canvas, signature) + } finally { + entry.users-- + if (!entry.retained && entry.users === 0) entry.bitmap?.close() } - const pixels = await rendering - if (this.#destroyed || !canvas.isConnected || this.#pending.get(canvas) !== publication) return - if (canvas.width !== request.width) canvas.width = request.width - if (canvas.height !== request.height) canvas.height = request.height - const context = canvas.getContext("2d") - if (!context) throw new Error("VGUI presentation canvas is unavailable") - context.putImageData(new ImageData(pixels, request.width, request.height), 0, 0) - this.#presented.set(canvas, signature) } destroy(): void { diff --git a/packages/presentation/vgui/src/runtime.ts b/packages/presentation/vgui/src/runtime.ts index de04e4667..1f970bc5d 100644 --- a/packages/presentation/vgui/src/runtime.ts +++ b/packages/presentation/vgui/src/runtime.ts @@ -809,6 +809,8 @@ class SourceVguiRuntime implements VguiRuntime { this.style.textContent = VGUI_CSS this.host = this.document.createElement("div") this.host.className = "playsrc-vgui-root playsrc-vgui-runtime" + this.host.tabIndex = -1 + this.host.style.outline = "none" this.host.dataset.vguiOwner = "playsrc" this.host.dataset.vguiRuntime = this.runtimeIdentity this.host.style.width = `${this.viewport.width}px` @@ -1958,6 +1960,9 @@ class SourceVguiRuntime implements VguiRuntime { this.releaseApplicationModal(panel.id) if (this.modalSubtree === panel.id) this.modalSubtree = null if (this.outsideClickListener === panel.id) this.outsideClickListener = null + // A replacement transaction must keep browser events in this owner until + // its next Source focus frame. Ordinary close and foreign focus stay alone. + if (!this.destroyed && this.publicationDepth > 0 && this.document.activeElement && panel.element.contains(this.document.activeElement)) this.host.focus({ preventScroll: true }) panel.element.remove() for (const element of panel.itemElements.values()) { element.remove() @@ -3757,7 +3762,7 @@ class SourceVguiRuntime implements VguiRuntime { return } if (sameName(message.name, "KeyCodePressed")) { - this.controlKeyPressed(panel, String(message.fields.code ?? ""), Boolean(message.fields.shift), Boolean(message.fields.control || message.fields.meta)) + this.controlKeyPressed(panel, String(message.fields.code ?? ""), Boolean(message.fields.shift), Boolean(message.fields.control || message.fields.meta), Boolean(message.fields.repeat)) return } if (sameName(message.name, "KeyCodeTyped")) { @@ -4015,7 +4020,7 @@ class SourceVguiRuntime implements VguiRuntime { this.reapplyPanelPresentation(panel) } - private controlKeyPressed(panel: PanelState, key: string, shift: boolean, control: boolean): void { + private controlKeyPressed(panel: PanelState, key: string, shift: boolean, control: boolean, repeat: boolean): void { const sourceControl = panel.sourceControl if (sameName(sourceControl, "SectionedListPanel") && panel.sectionedItems.length > 0) { const prior = panel.activeIndex ?? 0 @@ -4037,9 +4042,17 @@ class SourceVguiRuntime implements VguiRuntime { return } if (["Button", "CheckButton", "RadioButton", "FrameSystemButton", "MenuItem"].some((name) => sameName(sourceControl, name)) && (key === "Enter" || key === "Space")) { + if (repeat) return panel.armed = true panel.properties.set("keyDown", "1") - if (panel.activation !== 1) this.clickControl(panel) + if (panel.activation === 0) this.clickControl(panel) + else if (panel.activation === 2) { + this.setSelected(panel, true, false) + panel.depressed = true + const sound = panel.properties.get("sound_depressed") + if (sound) this.pendingRequests.push(Object.freeze({ kind: "sound", panel: panel.id, logicalIdentity: sound })) + } + this.reapplyPanelPresentation(panel) return } if (["TextEntry", "ComboBox"].some((name) => sameName(sourceControl, name))) this.editKey(panel, key, shift, control) @@ -4049,9 +4062,11 @@ class SourceVguiRuntime implements VguiRuntime { const sourceControl = panel.sourceControl if (["Button", "CheckButton", "RadioButton", "FrameSystemButton", "MenuItem"].some((name) => sameName(sourceControl, name)) && panel.properties.get("keyDown") === "1" && (key === "Enter" || key === "Space")) { - if (panel.activation !== 0) this.clickControl(panel) + if (panel.enabled && panel.activation !== 0 && (panel.activation !== 2 || panel.selected)) this.clickControl(panel) panel.properties.delete("keyDown") panel.armed = false + panel.depressed = false + this.reapplyPanelPresentation(panel) } } @@ -4067,6 +4082,7 @@ class SourceVguiRuntime implements VguiRuntime { return } if (key === "Enter") { + if (["Button", "CheckButton", "RadioButton", "FrameSystemButton", "MenuItem"].some(name => sameName(sourceControl, name))) return if (sameName(sourceControl, "QueryBox") || sameName(sourceControl, "MessageBox")) { this.closeQuery(panel, true) return @@ -5883,12 +5899,14 @@ class SourceVguiRuntime implements VguiRuntime { }) this.listen(this.document, "visibilitychange", () => { if (!this.document.hidden) return + this.cancelBrowserButtonPresses() if (this.capture !== null) this.apply({ kind: "set-pointer-capture", panel: null, initiatingButton: null, pointerId: null }) this.apply({ kind: "request-focus", panel: null }) this.browserFrame() }) if (window) { this.listen(window, "blur", () => { + this.cancelBrowserButtonPresses() if (this.capture !== null) this.apply({ kind: "set-pointer-capture", panel: null, initiatingButton: null, pointerId: null }) this.apply({ kind: "request-focus", panel: null }) this.browserFrame() @@ -5896,6 +5914,17 @@ class SourceVguiRuntime implements VguiRuntime { } } + private cancelBrowserButtonPresses(): void { + for (const panel of this.panels.values()) { + if (panel.properties.get("keyDown") !== "1") continue + panel.properties.delete("keyDown") + panel.armed = false + panel.depressed = false + if (panel.activation === 2) this.setSelected(panel, false, false) + this.reapplyPanelPresentation(panel) + } + } + private listen(target: EventTarget, type: string, listener: EventListener, options?: AddEventListenerOptions | boolean): void { if (this.listeners.length >= this.limits.maxListeners) throw new RuntimeFault("ListenerLimit", type) target.addEventListener(type, listener, options) diff --git a/packages/presentation/vgui/tests/image-renderer.test.ts b/packages/presentation/vgui/tests/image-renderer.test.ts index c0538da3b..300de55f2 100644 --- a/packages/presentation/vgui/tests/image-renderer.test.ts +++ b/packages/presentation/vgui/tests/image-renderer.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { isDirectVguiImageMaterial, shadeVguiImage, shadeVguiImageIncrementally, VguiRasterCache, type VguiImageRasterRequest, type VguiImageRasterTexturePixels } from "../src/image-renderer" +import { isDirectVguiImageMaterial, shadeVguiImage, shadeVguiImageIncrementally, VguiImageRasterizer, VguiRasterCache, type VguiImageRasterRequest, type VguiImageRasterTexturePixels } from "../src/image-renderer" import type { VguiImageMaterialPresentation, VguiImageMaterialTexture } from "../src" const texture = (identity: string): VguiImageMaterialTexture => Object.freeze({ @@ -14,6 +14,17 @@ const texture = (identity: string): VguiImageMaterialTexture => Object.freeze({ }) describe("bounded authored VGUI raster ownership", () => { + test("resizing a retained resource does not dispose it, while eviction and clear do", () => { + const disposed: object[] = [], value = {} + const cache = new VguiRasterCache(12, 2, entry => disposed.push(entry)) + cache.set("same", value, 4); cache.set("same", value, 8) + expect(disposed).toEqual([]) + cache.set("next", {}, 8) + expect(disposed).toEqual([value]) + cache.clear(); cache.clear() + expect(disposed).toHaveLength(2) + expect(cache.snapshot()).toEqual({ entries: 0, bytes: 0 }) + }) test("evicts least-recently-used decoded buffers by exact retained bytes", () => { const cache = new VguiRasterCache(12, 3) const first = new Uint8Array(4) @@ -99,6 +110,52 @@ const request = (material: VguiImageMaterialPresentation): VguiImageRasterReques material, }) +test("matching raster consumers share one bitmap and close it after pending creation", async () => { + // Storage/lifecycle unit only; the headed parity fixture checks real pixels. + const names = ["fetch", "createImageBitmap", "ImageData"] as const + const descriptors = names.map(name => Object.getOwnPropertyDescriptor(globalThis, name)) + let bitmaps = 0, pause = false + const closed: number[] = [], drawn: number[] = [] + const pending = Promise.withResolvers(), entered = Promise.withResolvers() + Object.assign(globalThis, { + fetch: async () => new Response(new Uint8Array([0])), + createImageBitmap: async (source: unknown) => { + if (source instanceof Blob) return { close() {} } + const id = ++bitmaps + if (pause) { entered.resolve(); await pending.promise } + return { id, close() { closed.push(id) } } + }, + ImageData: class { constructor(readonly data: Uint8ClampedArray, readonly width: number, readonly height: number) {} }, + }) + const document = { createElement: () => ({ width: 0, height: 0, + getContext: () => ({ clearRect() {}, drawImage() {}, putImageData() {}, getImageData: () => ({ data: new Uint8ClampedArray([255, 128, 0, 127]) }) }), + }) } as unknown as Document + const rasterizer = new VguiImageRasterizer(document) + const canvas = () => ({ width: 0, height: 0, isConnected: true, + getContext: () => ({ drawImage(bitmap: { id: number }) { drawn.push(bitmap.id) } }) }) as unknown as HTMLCanvasElement + try { + const targets = Array.from({ length: 50 }, canvas), input = request(baseMaterial()) + await Promise.all(targets.map(target => rasterizer.render(target, input))) + expect(bitmaps).toBe(1) + expect(drawn).toEqual(Array(50).fill(1)) + await rasterizer.render(targets[0]!, input) + expect(drawn).toHaveLength(50) + await Promise.all([rasterizer.render(targets[0]!, { ...input, tint: [100, 100, 100, 100] }), rasterizer.render(targets[0]!, input)]) + expect(drawn).toHaveLength(50) + pause = true + const rendering = rasterizer.render(canvas(), { ...input, tint: [50, 50, 50, 50] }) + await entered.promise + rasterizer.destroy() + expect(closed).toEqual([1, 2]) + pending.resolve(); await rendering + expect(closed).toEqual([1, 2, 3]) + expect(drawn).toHaveLength(50) + } finally { + rasterizer.destroy() + names.forEach((name, index) => { const descriptor = descriptors[index]; if (descriptor) Object.defineProperty(globalThis, name, descriptor); else delete (globalThis as any)[name] }) + } +}) + describe("configured VGUI image material raster", () => { test("clamped masks never bleed the opposite texture edge when proportionally enlarged", () => { const material = baseMaterial({ base: { ...texture("base"), width: 2, clampS: true, clampT: true } }) diff --git a/packages/presentation/vgui/tests/retained-raster-browser-fixture.ts b/packages/presentation/vgui/tests/retained-raster-browser-fixture.ts index 7440287ba..136186c6c 100644 --- a/packages/presentation/vgui/tests/retained-raster-browser-fixture.ts +++ b/packages/presentation/vgui/tests/retained-raster-browser-fixture.ts @@ -38,8 +38,8 @@ const rasterizer = new VguiImageRasterizer(document) await rasterizer.render(retained, request) let writes = 0 const retainedContext = retained.getContext("2d")! -const putImageData = retainedContext.putImageData.bind(retainedContext) -retainedContext.putImageData = (...parameters: Parameters) => { writes += 1; putImageData(...parameters) } +const drawImage = retainedContext.drawImage.bind(retainedContext) +retainedContext.drawImage = (...parameters: Parameters) => { writes++; drawImage(...parameters) } const observer = new MutationObserver(records => { document.body.dataset.rasterMutations = String(Number(document.body.dataset.rasterMutations ?? 0) + records.length) }) observer.observe(retained, { attributes: true }) for (let index = 0; index < 20; index += 1) await rasterizer.render(retained, request) @@ -50,10 +50,10 @@ await Promise.all([ rasterizer.render(retained, request), ]) document.body.dataset.unchangedRasterWrites = String(writes) +observer.disconnect() await rasterizer.render(retained, { ...request, tint: [180, 200, 90, 100] }) await rasterizer.render(retained, request) document.body.dataset.changedRasterWrites = String(writes) -observer.disconnect() document.body.dataset.ready = "true" document.querySelector("button")!.addEventListener("click", () => { document.body.dataset.activations = String(Number(document.body.dataset.activations ?? 0) + 1) diff --git a/packages/presentation/vgui/tests/retained-raster.html b/packages/presentation/vgui/tests/retained-raster.html new file mode 100644 index 000000000..ffc42c237 --- /dev/null +++ b/packages/presentation/vgui/tests/retained-raster.html @@ -0,0 +1,6 @@ +VGUI raster parity
diff --git a/packages/presentation/vgui/tests/runtime.test.ts b/packages/presentation/vgui/tests/runtime.test.ts index d3ed8757c..e929a829f 100644 --- a/packages/presentation/vgui/tests/runtime.test.ts +++ b/packages/presentation/vgui/tests/runtime.test.ts @@ -191,6 +191,24 @@ describe("generic Source VGUI runtime", () => { runtime.destroy() }) + test("focused buttons own Enter through release instead of also firing the dialog default", () => { + const { runtime, requests } = setup() + const button = (name: string) => operation(runtime, { kind: "create-panel", parent: 1, control: "Button", name, + properties: [{ name: "command", value: name }, { name: "wide", value: "100" }, { name: "tall", value: "25" }] }).panel! + const defaultButton = button("Default"), focused = button("Focused") + operation(runtime, { kind: "set-default-button", group: 1, panel: defaultButton }) + operation(runtime, { kind: "request-focus", panel: focused }) + operation(runtime, { kind: "frame", timeSeconds: 0 }) + const modifiers = { key: "Enter", shift: false, control: false, alt: false, meta: false } + operation(runtime, { kind: "key-press", ...modifiers, repeat: false }) + operation(runtime, { kind: "key-typed", ...modifiers }) + operation(runtime, { kind: "frame", timeSeconds: 0 }) + expect(requests.filter(request => request.kind === "command")).toEqual([]) + operation(runtime, { kind: "key-release", ...modifiers }) + operation(runtime, { kind: "frame", timeSeconds: 0 }) + expect(requests.filter(request => request.kind === "command").map(request => request.command)).toEqual(["Focused"]) + operation(runtime, { kind: "destroy" }) + }) test("CExButton borders use disabled, selected, armed, default precedence without changing Button colors", () => { const controls = ["CExButton", "CExImageButton"].map(name => ({ name, baseControl: "Button" as const, element: "button" as const, role: "button", focusable: true, diff --git a/playwright.equipment-navigation.config.ts b/playwright.equipment-navigation.config.ts new file mode 100644 index 000000000..c58eda299 --- /dev/null +++ b/playwright.equipment-navigation.config.ts @@ -0,0 +1,6 @@ +import path from "node:path" +import { headedProfileConfiguration } from "./tools/playsrc/profile/profile-config" +import { loadLocalConfig } from "./tools/playsrc/src/config" + +const local = await loadLocalConfig() +export default headedProfileConfiguration({ match: "equipment-navigation.profile.ts", output: path.join(local.sourceCacheDir, "profiles/equipment-navigation/playwright-results") }) diff --git a/playwright.vgui-raster-parity.config.ts b/playwright.vgui-raster-parity.config.ts new file mode 100644 index 000000000..573249dc8 --- /dev/null +++ b/playwright.vgui-raster-parity.config.ts @@ -0,0 +1,6 @@ +import path from "node:path" +import { headedProfileConfiguration } from "./tools/playsrc/profile/profile-config" +import { loadLocalConfig } from "./tools/playsrc/src/config" + +const local = await loadLocalConfig() +export default headedProfileConfiguration({ match: "vgui-raster-parity.profile.ts", output: path.join(local.sourceCacheDir, "profiles/vgui-raster-parity/playwright-results") }) diff --git a/tools/playsrc/profile/equipment-navigation-analysis.ts b/tools/playsrc/profile/equipment-navigation-analysis.ts new file mode 100644 index 000000000..6f48e9302 --- /dev/null +++ b/tools/playsrc/profile/equipment-navigation-analysis.ts @@ -0,0 +1,39 @@ +import { readFile, writeFile } from "node:fs/promises" +import path from "node:path" +import { createHash } from "node:crypto" +import { decodeScreenshot } from "./screenshot-pixels" +import { nativeSelectionRect } from "./selection-transition-analysis" +import { selectionVisibleLatency } from "./selection-visible-latency" + +export async function analyzeEquipmentNavigation(directory: string) { + const { captures, references } = JSON.parse(await readFile(path.join(directory, "equipment-native.json"), "utf8")) + const images = await Promise.all(captures.map(async (capture: any) => { + const bytes = await readFile(path.join(directory, capture.file)) + if (bytes.length !== capture.byteLength || createHash("sha256").update(bytes).digest("hex") !== capture.sha256) throw new Error("Equipment pixel provenance differs") + return decodeScreenshot(bytes) + })) + const latencies = references.map((reference: any) => { + const image = images[reference.index]! + const rects = captures.map((capture: any, index: number) => nativeSelectionRect(images[index]!, capture.admission.pixels.bounds, capture.nativeRecords[0].facts.bounds, reference.facts)) + const expected = rects[reference.index]! + let distinct = 0 + for (let y = 0; y < expected.height; y++) for (let x = 0; x < expected.width; x++) { + const at = ((expected.y + y) * image.width + expected.x + x) * image.channels + if (image.pixels[at]! > 100) distinct++ + } + if (distinct <= 16) throw new Error(`${reference.name} lacks authored native pixels`) + const samples = captures.map((capture: any, index: number) => { + const sampled = images[index]!, rect = rects[index]! + let different = 0 + for (let y = 0; y < expected.height; y++) for (let x = 0; x < expected.width; x++) { + const a = ((expected.y + y) * image.width + expected.x + x) * image.channels, b = ((rect.y + y) * sampled.width + rect.x + x) * sampled.channels + if ([0, 1, 2].some(channel => Math.abs(image.pixels[a + channel]! - sampled.pixels[b + channel]!) > 2)) different++ + } + return { ...capture.admission.pixels, matches: different < expected.width * expected.height * 0.01 } + }) + return { name: reference.name, input: reference.facts.input, distinctPixels: distinct, + ...selectionVisibleLatency(reference.facts.input.inputEpoch, samples.at(-1)!.endedEpoch, samples) } + }) + await writeFile(path.join(directory, "equipment-latency.json"), JSON.stringify(latencies, null, 2)) + return latencies +} diff --git a/tools/playsrc/profile/equipment-navigation.profile.ts b/tools/playsrc/profile/equipment-navigation.profile.ts new file mode 100644 index 000000000..a003cefaa --- /dev/null +++ b/tools/playsrc/profile/equipment-navigation.profile.ts @@ -0,0 +1,348 @@ +import { createHash } from "node:crypto" +import { readFile, writeFile } from "node:fs/promises" +import path from "node:path" +import { test, expect, guardStartupInput } from "./application-test" +import { loadLocalConfig } from "../src/config" +import { startupConsoleIdle, startupNativeReader } from "./native-startup" +import { requireStartupNative } from "./static-startup-gate" +import { installBrowserFrameProfiler } from "./browser-frame-profiler" +import { captureProcessMemory } from "./process-memory" +import { analyzeEquipmentNavigation } from "./equipment-navigation-analysis" +import { nativeEquipment } from "../../../games/tf2/browser/tests/fixtures/equipment" +import { profileArtifact } from "./profile-artifacts" + +async function mediaRecord(file: string) { + const bytes = await readFile(file) + return { path: file, byteLength: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex") } +} + +test.use({ viewport: { width: 1280, height: 800 }, deviceScaleFactor: 1 }) + +test("equipment trusted input to native visible pages", async ({ page, context }) => { + test.setTimeout(100_000) + if (process.platform !== "win32") throw new Error("Matched equipment navigation requires the configured native Windows browser") + const directory = process.env.PLAYSRC_PROFILE_RUN_DIRECTORY!, { sourceCacheDir } = await loadLocalConfig() + if (!directory) throw new Error("Use the checked equipment-navigation runner") + const reader = await startupNativeReader(page, sourceCacheDir) + const cdp = await context.newCDPSession(page), browser = await context.browser()!.newBrowserCDPSession() + const captures: any[] = [], references: any[] = [], hits: unknown[] = [], errors: string[] = [] + let sampling = false, complete = false, nextCapture: Promise | undefined, loop: Promise | undefined + let failure: string | undefined + let cpuActive = false + const cpu = process.env.PLAYSRC_EQUIPMENT_CPU === "1" + guardStartupInput(page, async () => { requireStartupNative(await reader.read()) }) + page.on("pageerror", error => errors.push(error.message)) + await page.addInitScript(installBrowserFrameProfiler) + await page.addInitScript(() => { + const profile = (globalThis as any).__playsrcProfile = { inputs: [], captureEquipment: false, equipmentFrames: [] } + const input = (event: Event) => { + if (!profile.captureEquipment || !event.isTrusted) return + profile.inputs.push({ type: event.type, key: (event as KeyboardEvent).code, trusted: event.isTrusted, + inputEpoch: performance.timeOrigin + event.timeStamp, processing: performance.now() }) + } + window.addEventListener("pointerup", input, true) + window.addEventListener("keydown", input, true) + }) + const capture = async () => { + const file = `equipment-${captures.length}.desktop.png`, startedEpoch = Date.now() + const admission = await reader.read(path.join(directory, file), "window"), admissionAfter = await reader.read() + requireStartupNative(admission); requireStartupNative(admissionAfter) + if (!admission.pixels) throw new Error("Native equipment capture is absent") + const bytes = await readFile(path.join(directory, file)) + captures.push({ file, startedEpoch, endedEpoch: Date.now(), admission, admissionAfter, nativeRecords: reader.records.slice(-2), + byteLength: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex"), privacy: "private-desktop-never-upload" }) + return captures.length - 1 + } + const selector = (name: string) => `.equipment-layer [data-vgui-name='${name}']` + const click = async (name: string, fraction = 0.5) => { + const hit = await page.locator(name === "CharacterSetupButton" ? `[data-vgui-name='${name}']` : selector(name)).evaluate((node, fraction) => { + const bounds = node.getBoundingClientRect(), x = bounds.x + bounds.width * fraction, y = bounds.y + bounds.height * fraction + const target = document.elementFromPoint(x, y) + return { x, y, name: (node as HTMLElement).dataset.vguiName, hit: (target as HTMLElement)?.dataset.vguiName, inside: !!target && node.contains(target) } + }, fraction) + hits.push(hit); expect(hit.inside, `${name} must own the actual hit pixel`).toBe(true) + await page.mouse.click(hit.x, hit.y) + } + const reference = async (name: string) => { + await expect(page.locator(selector(name))).toBeVisible({ timeout: 2500 }) + const facts = await page.locator(selector(name)).evaluate(node => ({ bounds: node.getBoundingClientRect().toJSON(), screenX, screenY, outerWidth, outerHeight, + innerWidth, innerHeight, epoch: performance.timeOrigin + performance.now(), input: (globalThis as any).__playsrcProfile.inputs.at(-1) })) + let index = await nextCapture! + while (captures[index].startedEpoch < facts.epoch) { + if (!sampling) throw new Error(`No native reference for ${name} within the bounded sample`) + index = await nextCapture! + } + const first = index + while (index <= first) { + if (!sampling) throw new Error(`No settled native reference for ${name} within the bounded sample`) + index = await nextCapture! + } + references.push({ name, index, facts }) + } + try { + await page.goto("/", { waitUntil: "domcontentloaded" }) + await expect(page.locator("main")).toHaveAttribute("data-phase", "MainMenu") + requireStartupNative(await reader.read()) + await click("CharacterSetupButton") + await expect(page.locator(selector("Class3"))).toBeVisible() + await click("Class3") + await expect(page.locator(".equipment-layer")).toHaveAttribute("data-preview-model", "models/player/soldier.mdl", { timeout: 20_000 }) + await click("BackButton") + await expect(page.locator(selector("Class3"))).toBeVisible() + await page.waitForTimeout(2100) + if (await startupConsoleIdle(sourceCacheDir) < 2000) throw new Error("Equipment input requires two seconds of genuine native idle") + await capture() + const heapBefore = await cdp.send("Runtime.getHeapUsage") + const residentBefore = await captureProcessMemory((await browser.send("SystemInfo.getProcessInfo")).processInfo) + if (cpu) { await cdp.send("Profiler.enable"); await cdp.send("Profiler.start"); cpuActive = true } + await page.evaluate(() => { (globalThis as any).__playsrcProfile.captureEquipment = true; (globalThis as any).__playsrcFrameProfiler.active = true }) + const startedEpoch = Date.now() + sampling = true + loop = (async () => { + while (sampling && Date.now() - startedEpoch < 9000 && (!complete || Date.now() - startedEpoch < 5000)) { nextCapture = capture(); await nextCapture } + sampling = false + })() + void loop.catch(() => {}) + await click("Class3"); await reference("Itemslot-0") + await click("Itemslot-0"); await reference("UnequipButton") + await page.keyboard.press("Escape"); await reference("Itemslot-0") + await click("BackpackButton"); await reference("Itemitem-18") + await page.keyboard.press("PageDown") + await reference(`Itemitem-${nativeEquipment.inventory[50]!.item.definitionIndex}`) + await page.keyboard.press("Escape"); await reference("Class3") + complete = true + await loop + const endedEpoch = Date.now() + if (cpu) { await writeFile(path.join(directory, "equipment.cpuprofile"), JSON.stringify((await cdp.send("Profiler.stop")).profile)); cpuActive = false } + const evidence = await page.evaluate(() => { + const p = (globalThis as any).__playsrcProfile, f = (globalThis as any).__playsrcFrameProfiler + p.captureEquipment = false; f.active = false + return { inputs: p.inputs, equipmentFrames: p.equipmentFrames, worker: f.worker, counters: f.counters, modelPreparation: f.modelPreparation, + longTasks: f.longTasks, longAnimationFrames: f.longAnimationFrames, completedFrames: f.completedFrames } + }) + const heapAfter = await cdp.send("Runtime.getHeapUsage"), residentAfter = await captureProcessMemory((await browser.send("SystemInfo.getProcessInfo")).processInfo) + const retention: unknown[] = [] + for (let cycle = 0; cycle < 3; cycle++) { + await click("Class3"); await click("BackpackButton"); await page.keyboard.press("Escape") + await expect(page.locator(selector("Class3"))).toBeVisible() + retention.push({ cycle, heap: await cdp.send("Runtime.getHeapUsage"), dom: await cdp.send("Memory.getDOMCounters") }) + } + requireStartupNative(await reader.read()) + await page.screenshot({ path: path.join(directory, "equipment.page.png") }) + expect(errors).toEqual([]) + await profileArtifact(() => writeFile(path.join(directory, "equipment-measurement.json"), JSON.stringify({ cpu, startedEpoch, endedEpoch, evidence, references, heapBefore, heapAfter, residentBefore, residentAfter, retention }, null, 2))) + } catch (error) { failure = String(error); throw error } + finally { + sampling = false; await loop?.catch(() => {}) + if (cpuActive) await writeFile(path.join(directory, "equipment.cpuprofile"), JSON.stringify((await cdp.send("Profiler.stop")).profile)) + if (failure) { + const evidence = await page.evaluate(() => { + const p = (globalThis as any).__playsrcProfile, f = (globalThis as any).__playsrcFrameProfiler + if (p) p.captureEquipment = false + if (f) f.active = false + return { inputs: p?.inputs, equipmentFrames: p?.equipmentFrames, worker: f?.worker, counters: f?.counters, modelPreparation: f?.modelPreparation, + longTasks: f?.longTasks, longAnimationFrames: f?.longAnimationFrames, endedEpoch: Date.now() } + }).catch(error => ({ unavailable: String(error) })) + await profileArtifact(() => writeFile(path.join(directory, "equipment-failure.json"), JSON.stringify({ failure, evidence }, null, 2))) + } + await profileArtifact(async () => { + const pageMedia = await mediaRecord(path.join(directory, "equipment.page.png")).catch(error => ({ unavailable: String(error) })) + await writeFile(path.join(directory, "equipment-native.json"), JSON.stringify({ captures, pageMedia, references, hits, errors, failure }, null, 2)) + await analyzeEquipmentNavigation(directory) + }) + await reader.close() + } +}) + +test.describe("equipment transaction faults", () => { + test.use({ allowRecoverableApplicationFailure: true }) + test("equipment pending Back, retry, resource failure and reopen", async ({ page, context }) => { + test.setTimeout(100_000) + if (process.platform !== "win32") throw new Error("Equipment transaction acceptance requires native Windows") + const directory = process.env.PLAYSRC_PROFILE_RUN_DIRECTORY!, { sourceCacheDir } = await loadLocalConfig() + const reader = await startupNativeReader(page, sourceCacheDir), records: unknown[] = [], media: string[] = [] + guardStartupInput(page, async () => { requireStartupNative(await reader.read()) }) + await page.addInitScript(() => { + const inputs: unknown[] = (globalThis as any).__equipmentTransactionInputs = [] + for (const kind of ["pointerup", "keydown"]) window.addEventListener(kind, event => { + if (event.isTrusted) inputs.push({ kind, code: (event as KeyboardEvent).code, inputEpoch: performance.timeOrigin + event.timeStamp }) + }, true) + }) + const equipment = page.locator(".equipment-layer"), main = page.locator("main") + const control = (name: string) => equipment.locator(`[data-vgui-name='${name}']`) + const gate = Promise.withResolvers(), intercepted = Promise.withResolvers() + let fault: "none" | "delay" | "reject" = "none" + await context.route(url => url.hostname === "127.0.0.1" && /^\/objects\/sha256\/[a-f0-9]{64}$/.test(url.pathname), async route => { + const selected = fault + if (selected === "none") { await route.continue(); return } + fault = "none" + records.push({ kind: "local-resource-fault", fault: selected, url: route.request().url(), at: Date.now() }) + if (selected === "reject") { await route.abort("failed"); return } + intercepted.resolve(route.request().url()) + await gate.promise + await route.abort("failed") + }) + const capture = async (name: string) => { + requireStartupNative(await reader.read()) + await page.screenshot({ path: path.join(directory, `${name}.page.png`) }) + const native = await reader.read(path.join(directory, `${name}.desktop.png`), "window") + requireStartupNative(native) + media.push(path.join(directory, `${name}.desktop.png`), path.join(directory, `${name}.page.png`)) + requireStartupNative(await reader.read()) + records.push({ kind: "capture", name, native, inputs: await page.evaluate(() => (globalThis as any).__equipmentTransactionInputs) }) + } + try { + await page.goto("/", { waitUntil: "domcontentloaded" }) + await expect(main).toHaveAttribute("data-phase", "MainMenu") + await page.locator("[data-vgui-name='CharacterSetupButton']").click() + await control("Class3").click() + await expect(equipment).toHaveAttribute("data-preview-model", "models/player/soldier.mdl", { timeout: 20_000 }) + await page.keyboard.press("Backquote") + const entry = page.locator("[aria-label='Console command']") + await entry.fill("echo equipment input ownership") + await page.keyboard.press("ArrowLeft") + await expect(entry).toHaveValue("echo equipment input ownership") + await page.keyboard.press("Escape") + await expect(main).toHaveAttribute("data-console-visible", "true") + await expect(entry).toHaveValue("echo equipment input ownership") + await expect(control("Itemslot-0")).toBeVisible() + await page.keyboard.press("Backquote") + await expect(main).toHaveAttribute("data-console-visible", "false") + await page.waitForTimeout(2100) + if (await startupConsoleIdle(sourceCacheDir) < 2000) throw new Error("Transaction test requires genuine native idle") + await capture("loadout") + await control("Itemslot-0").hover() + await expect(control("ItemTooltip")).toBeVisible() + await capture("tooltip") + await control("Itemslot-0").click() + const before = await page.evaluate(() => localStorage.getItem("playsrc.tf2.local-equipment.v1")) + fault = "delay" + await control("Itemitem-127").click() + await Promise.race([intercepted.promise, new Promise((_, reject) => setTimeout(() => reject(new Error("Cold equipment resource was not intercepted")), 5000))]) + const back = await control("BackButton").boundingBox() + const point = { x: back!.x + back!.width * 0.95, y: back!.y + back!.height * 0.5 } + expect(await page.evaluate(point => !!document.elementFromPoint(point.x, point.y)?.closest("[data-vgui-name='BackButton']"), point)).toBe(true) + await page.mouse.click(point.x, point.y) + await expect(control("Itemslot-0")).toBeVisible({ timeout: 1500 }) + await capture("back-cancelled-pending") + await control("Itemslot-0").click(); await control("Itemitem-127").click() + await page.keyboard.press("Escape") + await expect(control("Itemslot-0")).toBeVisible({ timeout: 1500 }) + expect(await page.evaluate(() => localStorage.getItem("playsrc.tf2.local-equipment.v1"))).toBe(before) + await capture("cancelled-pending") + await control("Itemslot-0").click(); await control("Itemitem-127").click() + gate.resolve() + await expect(control("Itemslot-0")).toBeVisible({ timeout: 20_000 }) + await expect.poll(() => page.evaluate(() => localStorage.getItem("playsrc.tf2.local-equipment.v1"))).not.toBe(before) + const saved = await page.evaluate(() => localStorage.getItem("playsrc.tf2.local-equipment.v1")) + await expect(equipment).toHaveAttribute("data-preview-model", "models/player/soldier.mdl") + await capture("equipped-retry") + await control("BackButton").click(); await control("BackButton").click() + await expect(equipment).toBeHidden() + await page.locator("[data-vgui-name='CharacterSetupButton']").click() + await page.keyboard.press("ArrowLeft"); await page.keyboard.press("ArrowRight") + await page.keyboard.down("Enter"); await page.keyboard.down("Enter") + await expect(control("Class3")).toBeVisible() + await expect(control("Itemslot-0")).toHaveCount(0) + await capture("held-enter") + await page.keyboard.up("Enter") + await expect(control("Itemslot-0")).toBeVisible({ timeout: 20_000 }) + await page.keyboard.down("Tab"); await page.keyboard.down("Tab"); await page.keyboard.up("Tab") + expect(await page.evaluate(() => !!document.activeElement?.closest(".equipment-layer"))).toBe(true) + await page.keyboard.press("Enter") + await expect(control("Itemitem-6")).toBeVisible() + await capture("tab-slot") + await page.keyboard.press("Escape") + await expect(control("Itemslot-0")).toBeVisible() + expect(await page.evaluate(() => localStorage.getItem("playsrc.tf2.local-equipment.v1"))).toBe(saved) + await control("Itemslot-0").click() + fault = "reject" + await control("Itemitem-228").click() + await expect(main).toHaveAttribute("data-phase", "Failed", { timeout: 15_000 }) + await expect(equipment).toBeHidden() + expect(await page.evaluate(() => localStorage.getItem("playsrc.tf2.local-equipment.v1"))).toBe(saved) + await capture("resource-failure") + await page.reload({ waitUntil: "domcontentloaded" }) + await expect(main).toHaveAttribute("data-phase", "MainMenu") + expect(await page.evaluate(() => localStorage.getItem("playsrc.tf2.local-equipment.v1"))).toBe(saved) + } catch (error) { + records.push({ kind: "failure", error: String(error) }) + await capture("failed-transaction").catch(error => records.push({ kind: "capture-unavailable", error: String(error) })) + throw error + } finally { + gate.resolve() + await reader.close() + await profileArtifact(async () => writeFile(path.join(directory, "equipment-transactions.json"), JSON.stringify({ kind: "local-resource-fault-injection-not-a-performance-sample", records, + media: await Promise.all(media.map(mediaRecord)) }, null, 2))) + } + }) +}) + +test("equipment map replacement and disconnect cancel uncommitted selection", async ({ page, context }) => { + test.setTimeout(120_000) + const directory = process.env.PLAYSRC_PROFILE_RUN_DIRECTORY!, { sourceCacheDir } = await loadLocalConfig() + const reader = await startupNativeReader(page, sourceCacheDir), records: unknown[] = [], media: string[] = [] + guardStartupInput(page, async () => { requireStartupNative(await reader.read()) }) + await page.addInitScript(() => { (globalThis as any).__playsrcProfile = { captureWeaponPoses: true } }) + let gate = Promise.withResolvers(), entered = Promise.withResolvers(), armed = false + await context.route(url => url.hostname === "127.0.0.1" && /^\/objects\/sha256\/[a-f0-9]{64}$/.test(url.pathname), async route => { + if (!armed) { await route.continue(); return } + armed = false + const held = gate + records.push({ kind: "held-resource", url: route.request().url(), at: Date.now() }) + entered.resolve(); await held.promise + if (!route.request().failure()) await route.continue() + }) + const equipment = page.locator(".equipment-layer"), main = page.locator("main") + const control = (name: string) => equipment.locator(`[data-vgui-name='${name}']`) + const command = async (text: string) => { + if (await main.getAttribute("data-console-visible") !== "true") await page.keyboard.press("Backquote") + await page.locator("[aria-label='Console command']").fill(text); await page.keyboard.press("Enter") + } + const holdEquip = async (slot = 0, definition = 127) => { + await expect(equipment).toHaveAttribute("data-preview-model", "models/player/soldier.mdl", { timeout: 20_000 }) + await control(`Itemslot-${slot}`).click() + gate = Promise.withResolvers(); entered = Promise.withResolvers(); armed = true + await control(`Itemitem-${definition}`).click() + await Promise.race([entered.promise, new Promise((_, reject) => setTimeout(() => reject(new Error("Equipment lifecycle resource gate was not reached")), 5000))]) + } + const capture = async (name: string) => { + const native = await reader.read(path.join(directory, `${name}.desktop.png`), "window") + requireStartupNative(native); requireStartupNative(await reader.read()) + await page.screenshot({ path: path.join(directory, `${name}.page.png`) }) + media.push(path.join(directory, `${name}.desktop.png`), path.join(directory, `${name}.page.png`)) + records.push({ name, native }) + } + try { + await page.goto("/", { waitUntil: "domcontentloaded" }) + await expect(main).toHaveAttribute("data-phase", "MainMenu") + await page.waitForTimeout(2100) + if (await startupConsoleIdle(sourceCacheDir) < 2000) throw new Error("Equipment lifecycle requires genuine native idle") + await page.locator("[data-vgui-name='CharacterSetupButton']").click(); await control("Class3").click() + const saved = await page.evaluate(() => localStorage.getItem("playsrc.tf2.local-equipment.v1")) + await holdEquip() + await command("map pl_upward") + await expect(equipment).toBeHidden({ timeout: 1500 }) + gate.resolve() + await expect(main).toHaveAttribute("data-team-selection-visible", "true", { timeout: 55_000 }) + if (await main.getAttribute("data-console-visible") === "true") await page.keyboard.press("Backquote") + await page.locator(".team-selection-layer [data-vgui-name='teambutton1']").click() + await page.locator(".class-selection-layer [data-vgui-name='soldier']").click() + await expect(main).toHaveAttribute("data-phase", "Ready") + await expect.poll(() => page.evaluate(() => (globalThis as any).__playsrcProfile.weaponPose?.definition)).toBe(18) + expect(await page.evaluate(() => localStorage.getItem("playsrc.tf2.local-equipment.v1"))).toBe(saved) + await capture("map-cancelled-equip") + await page.keyboard.press("Comma") + await page.locator(".class-selection-layer [data-vgui-name='EditLoadoutButton']").click() + await holdEquip(7, 378) + await command("disconnect") + await expect(equipment).toBeHidden({ timeout: 1500 }) + gate.resolve() + await expect(main).toHaveAttribute("data-phase", "MainMenu", { timeout: 15_000 }) + expect(await page.evaluate(() => localStorage.getItem("playsrc.tf2.local-equipment.v1"))).toBe(saved) + await capture("disconnect-cancelled-equip") + } finally { + gate.resolve(); await reader.close() + await profileArtifact(async () => writeFile(path.join(directory, "equipment-lifecycle.json"), JSON.stringify({ records, media: await Promise.all(media.map(mediaRecord)) }, null, 2))) + } +}) diff --git a/tools/playsrc/profile/equipment.profile.ts b/tools/playsrc/profile/equipment.profile.ts index 30a7adae7..dc367c38d 100644 --- a/tools/playsrc/profile/equipment.profile.ts +++ b/tools/playsrc/profile/equipment.profile.ts @@ -44,9 +44,9 @@ test("authored backpack native equip and browser restart persistence", async ({ const firstPage = await visibleItems() await equipment.locator("[data-vgui-name='NextPage']").click() const inventoryDefinitions = [...firstPage, ...await visibleItems()] - expect(new Set(inventoryDefinitions).size).toBe(52) + expect(new Set(inventoryDefinitions).size).toBe(nativeEquipment.inventory.length) expect(inventoryDefinitions.sort((a, b) => a - b)).toEqual(nativeEquipment.inventory.map(entry => entry.item.definitionIndex).sort((a, b) => a - b)) - for (const unavailable of [19, 20, 735, 513]) expect(inventoryDefinitions).not.toContain(unavailable) + for (const unavailable of [19, 20, 735]) expect(inventoryDefinitions).not.toContain(unavailable) await equipment.locator("[data-vgui-name='PrevPage']").click() await expect(equipment.locator("[data-vgui-name='Itemitem-378']")).toBeVisible() const capture = await page.screenshot({ path: path.join(directory, "backpack.png") }) @@ -212,8 +212,10 @@ test("twelve hitscan items admit their models, native firing and authored audio" await equipment.locator(`[data-vgui-name='Itemitem-${definition}']`).click() await expect(equipment.locator("[data-vgui-name='EquipmentPlayer']")).toBeVisible({ timeout: 20_000 }) await equipment.locator("[data-vgui-name='BackButton']").click() - await equipment.locator("[data-vgui-name='BackButton']").click() await expect(equipment).toBeHidden() + await expect(main).toHaveAttribute("data-class-selection-visible", "true") + await page.keyboard.press("Escape") + await expect(main).toHaveAttribute("data-class-selection-visible", "false") // Switching class is a real respawn path even when the current map's room // does not admit an immediate loadout refresh. if ((await actual())?.definition !== definition) { @@ -455,8 +457,11 @@ test("projectile unlocks equip through the authored loadout and fire their nativ await equipment.locator(`[data-vgui-name='Itemitem-${definition}']`).click() await expect(equipment.locator("[data-vgui-name='EquipmentPlayer']")).toBeVisible() await page.screenshot({ path: path.join(directory, `${definition}-equipped.png`) }) - await equipment.locator("[data-vgui-name='BackButton']").click(); await equipment.locator("[data-vgui-name='BackButton']").click() + await equipment.locator("[data-vgui-name='BackButton']").click() await expect(equipment).toBeHidden() + await expect(main).toHaveAttribute("data-class-selection-visible", "true") + await page.keyboard.press("Escape") + await expect(main).toHaveAttribute("data-class-selection-visible", "false") await command("joinclass scout"); await expect.poll(async () => (await actual())?.class).toBe(1) await command(`joinclass ${className}`); await expect.poll(async () => (await actual())?.class).toBe(playerClass) const selection = metadata.classSlots.find(value => value.class === playerClass)!.selectionSlot diff --git a/tools/playsrc/profile/vgui-raster-parity.profile.ts b/tools/playsrc/profile/vgui-raster-parity.profile.ts new file mode 100644 index 000000000..c15823827 --- /dev/null +++ b/tools/playsrc/profile/vgui-raster-parity.profile.ts @@ -0,0 +1,54 @@ +import { readFile, writeFile } from "node:fs/promises" +import { createHash } from "node:crypto" +import path from "node:path" +import { test, expect } from "./application-test" +import { loadLocalConfig } from "../src/config" +import { startupConsoleIdle, startupNativeReader } from "./native-startup" +import { requireStartupNative } from "./static-startup-gate" +import { profileArtifact } from "./profile-artifacts" +import { decodeScreenshot } from "./screenshot-pixels" + +test.use({ viewport: { width: 1280, height: 800 }, deviceScaleFactor: 1 }) +test("shared raster preserves native alpha, clipping, order and input", async ({ page, baseURL }) => { + test.setTimeout(45_000) + const directory = process.env.PLAYSRC_PROFILE_RUN_DIRECTORY!, { sourceCacheDir } = await loadLocalConfig() + if (!directory || !baseURL) throw new Error("Use the checked native raster profile") + const reader = await startupNativeReader(page, sourceCacheDir), records: unknown[] = [], media: string[] = [] + try { + await page.goto(new URL(`/@fs/${process.cwd().replaceAll("\\", "/")}/packages/presentation/vgui/tests/retained-raster.html`, baseURL).href) + await page.waitForFunction(() => document.body.dataset.ready === "true") + await page.waitForTimeout(2100) + if (await startupConsoleIdle(sourceCacheDir) < 2000) throw new Error("Raster parity requires genuine native idle") + requireStartupNative(await reader.read()) + const button = page.getByRole("button", { name: "Retained foreground" }) + await button.click(); await page.keyboard.press("Enter"); await button.blur() + const state = await page.evaluate(() => ({ activations: Number(document.body.dataset.activations), unchangedWrites: Number(document.body.dataset.unchangedRasterWrites), changedWrites: Number(document.body.dataset.changedRasterWrites), mutations: Number(document.body.dataset.rasterMutations ?? 0) })) + expect(state).toEqual({ activations: 2, unchangedWrites: 0, changedWrites: 2, mutations: 0 }) + for (const name of ["shared", "canvas"]) { + if (name === "canvas") await page.evaluate(() => { + document.querySelector("#retained")!.style.display = "none" + document.querySelector("#baseline")!.style.display = "block" + }) + await page.screenshot({ path: path.join(directory, `${name}.page.png`) }) + const native = await reader.read(path.join(directory, `${name}.desktop.png`), "window") + requireStartupNative(native); requireStartupNative(await reader.read()) + records.push({ name, native }) + media.push(`${name}.page.png`, `${name}.desktop.png`) + } + await profileArtifact(async () => { + const files = await Promise.all(media.map(async file => { + const bytes = await readFile(path.join(directory, file)) + return { file, path: path.join(directory, file), byteLength: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex"), image: decodeScreenshot(bytes) } + })) + const comparisons = ["page", "desktop"].map(kind => { + const a = files.find(file => file.file === `shared.${kind}.png`)!.image, b = files.find(file => file.file === `canvas.${kind}.png`)!.image + expect([a.width, a.height, a.channels]).toEqual([b.width, b.height, b.channels]) + let differentChannels = 0 + for (let i = 0; i < a.pixels.length; i++) if (a.pixels[i] !== b.pixels[i]) differentChannels++ + return { kind, differentChannels } + }) + await writeFile(path.join(directory, "raster-parity.json"), JSON.stringify({ state, records, comparisons, media: files.map(({ image, ...record }) => record) }, null, 2)) + for (const comparison of comparisons) expect(comparison.differentChannels).toBe(0) + }) + } finally { await reader.close() } +}) diff --git a/tools/playsrc/src/profile-runner.ts b/tools/playsrc/src/profile-runner.ts index 5a26d1177..4b522ad9b 100644 --- a/tools/playsrc/src/profile-runner.ts +++ b/tools/playsrc/src/profile-runner.ts @@ -127,6 +127,11 @@ const PROFILES = Object.freeze({ "team-door-disabled-blue": { config: "playwright.team-door-lifecycle.config.ts", target: "pl_upward", environment: { PLAYSRC_TEAM_DOOR_DISABLED: "blue" }, minimumRemainingMilliseconds: 90_000 }, "selection-transition-cpu": { config: "playwright.selection-transition.config.ts", target: "pl_upward", environment: { PLAYSRC_SELECTION_CPU: "1" }, minimumRemainingMilliseconds: 60_000 }, equipment: { config: "playwright.equipment-profile.config.ts", target: "pl_upward" }, + "equipment-navigation": { config: "playwright.equipment-navigation.config.ts", target: "pl_upward", arguments: ["--grep", "equipment trusted input"], minimumRemainingMilliseconds: 60_000 }, + "equipment-navigation-cpu": { config: "playwright.equipment-navigation.config.ts", target: "pl_upward", arguments: ["--grep", "equipment trusted input"], environment: { PLAYSRC_EQUIPMENT_CPU: "1" }, minimumRemainingMilliseconds: 60_000 }, + "equipment-navigation-transactions": { config: "playwright.equipment-navigation.config.ts", target: "pl_upward", arguments: ["--grep", "equipment pending"], minimumRemainingMilliseconds: 90_000 }, + "equipment-navigation-lifecycle": { config: "playwright.equipment-navigation.config.ts", target: "pl_upward", arguments: ["--grep", "equipment map replacement"], minimumRemainingMilliseconds: 110_000 }, + "vgui-raster-parity": { config: "playwright.vgui-raster-parity.config.ts", target: "pl_upward", minimumRemainingMilliseconds: 45_000 }, "melee-unlocks": { config: "playwright.melee-profile.config.ts", target: "pl_upward" }, "shared-lighting": { config: "playwright.shared-lighting-profile.config.ts", target: "jump_beef" }, "application-lifecycle": { config: "playwright.profile.config.ts", target: "jump_beef", arguments: ["--grep", "TF2 application generation lifecycle"] },