diff --git a/Documentation/Brand/Assets/PlaygroundMascot/back-v1.png b/Documentation/Brand/Assets/PlaygroundMascot/back-v1.png
new file mode 100644
index 000000000..3d28cec7c
Binary files /dev/null and b/Documentation/Brand/Assets/PlaygroundMascot/back-v1.png differ
diff --git a/Documentation/Brand/Assets/PlaygroundMascot/back.prompt.txt b/Documentation/Brand/Assets/PlaygroundMascot/back.prompt.txt
new file mode 100644
index 000000000..5fd04c95a
--- /dev/null
+++ b/Documentation/Brand/Assets/PlaygroundMascot/back.prompt.txt
@@ -0,0 +1,3 @@
+Use case: identity-preserve. Asset type: reusable full-body mascot character render with a genuinely transparent background. The attached four-view model sheet is the AUTHORITATIVE IDENTITY REFERENCE: all four figures are views of ONE SAME ROBOT, not four separate characters. Render exactly ONE instance of that same tall Ada Playground explorer robot. Preserve its exact helmet shape, large rounded rectangular near-black face visor, two cyan smiling eye arcs (an inverted U each) when the face is visible, warm ivory ceramic shell, golden amber circular ear modules with black centers and thin illuminated rings, ONE short yellow-tipped paddle antenna on the robot's OWN LEFT REAR crown (viewer-right in the frontal reference), a black accordion neck, amber rounded horizontal chest lamp, brown explorer backpack with two dark brown shoulder straps and two backpack buckles, graphite mechanical joint gaps, gray knee caps, rounded cream boots with charcoal rubber soles and small amber ankle details. Head about 42 percent of total standing height excluding antenna; short sturdy legs; four digits per hand, three fingers and a thumb. The physical side of the antenna never changes when the camera changes. Do not mirror or redesign the reference. Keep restrained subtle edge wear, satin ceramic, warm metal hardware, soft neutral studio lighting, polished stylized 3D game art. Entire character including antenna, hands and feet inside frame with about 10 percent clear margin, standing on an implied common ground level, camera at torso height, orthographic-like 85 mm product-view perspective, no environment. True alpha transparency around the subject, no opaque background, no checkerboard drawn into the image, no white rectangle, no floor plane. No text, no labels, no logos, no watermark, no additional robots.
+
+VIEW: exact BACK elevation, matching the THIRD panel in the supplied model sheet. Robot faces directly away from the viewer, body square to camera, relaxed neutral A-pose with arms slightly away from sides. The broad brown backpack, flap, two dark fastening straps, two metal buckles, boots and rear helmet panels are clearly visible. There must be NO eyes, NO visor and NO front chest lamp visible from this rear camera. The physical left-side antenna appears on viewer-left.
diff --git a/Documentation/Brand/Assets/PlaygroundMascot/front-v1.png b/Documentation/Brand/Assets/PlaygroundMascot/front-v1.png
new file mode 100644
index 000000000..02fa70c7e
Binary files /dev/null and b/Documentation/Brand/Assets/PlaygroundMascot/front-v1.png differ
diff --git a/Documentation/Brand/Assets/PlaygroundMascot/front.prompt.txt b/Documentation/Brand/Assets/PlaygroundMascot/front.prompt.txt
new file mode 100644
index 000000000..f170b2599
--- /dev/null
+++ b/Documentation/Brand/Assets/PlaygroundMascot/front.prompt.txt
@@ -0,0 +1,3 @@
+Use case: identity-preserve. Asset type: reusable full-body mascot character render with a genuinely transparent background. The attached four-view model sheet is the AUTHORITATIVE IDENTITY REFERENCE: all four figures are views of ONE SAME ROBOT, not four separate characters. Render exactly ONE instance of that same tall Ada Playground explorer robot. Preserve its exact helmet shape, large rounded rectangular near-black face visor, two cyan smiling eye arcs (an inverted U each) when the face is visible, warm ivory ceramic shell, golden amber circular ear modules with black centers and thin illuminated rings, ONE short yellow-tipped paddle antenna on the robot's OWN LEFT REAR crown (viewer-right in the frontal reference), a black accordion neck, amber rounded horizontal chest lamp, brown explorer backpack with two dark brown shoulder straps and two backpack buckles, graphite mechanical joint gaps, gray knee caps, rounded cream boots with charcoal rubber soles and small amber ankle details. Head about 42 percent of total standing height excluding antenna; short sturdy legs; four digits per hand, three fingers and a thumb. The physical side of the antenna never changes when the camera changes. Do not mirror or redesign the reference. Keep restrained subtle edge wear, satin ceramic, warm metal hardware, soft neutral studio lighting, polished stylized 3D game art. Entire character including antenna, hands and feet inside frame with about 10 percent clear margin, standing on an implied common ground level, camera at torso height, orthographic-like 85 mm product-view perspective, no environment. True alpha transparency around the subject, no opaque background, no checkerboard drawn into the image, no white rectangle, no floor plane. No text, no labels, no logos, no watermark, no additional robots.
+
+VIEW: exact FRONT elevation. Robot looks directly toward the viewer, body square to camera, symmetrical relaxed A-pose with arms slightly away from sides, both hands relaxed. Both cyan eye arcs clearly visible and equal in size. Antenna is on viewer-right. Backpack mostly occluded, only side edges and front shoulder straps visible.
diff --git a/Documentation/Brand/Assets/PlaygroundMascot/manifest.json b/Documentation/Brand/Assets/PlaygroundMascot/manifest.json
new file mode 100644
index 000000000..84de312f6
--- /dev/null
+++ b/Documentation/Brand/Assets/PlaygroundMascot/manifest.json
@@ -0,0 +1,136 @@
+{
+ "design": "Ada Playground guide robot",
+ "designVersion": 1,
+ "date": "2026-10-19",
+ "selectedCharacter": "Tall standing robot pointing forward from the original header",
+ "tool": "built-in image_gen.imagegen",
+ "canonicalReference": "turnaround-v1.png",
+ "documentation": "../../PlaygroundMascot.md",
+ "files": [
+ {
+ "file": "turnaround-v1.png",
+ "role": "authoritative-model-sheet",
+ "width": 2172,
+ "height": 724,
+ "mode": "RGB",
+ "alphaRange": null,
+ "background": "opaque neutral studio background",
+ "sha256": "c711a400924120f8c097bc3b8fc5c8727bc4a6fedfd219b24aba8dd7217b2832",
+ "prompt": "turnaround.prompt.txt",
+ "references": [
+ "source-header-v1.png"
+ ],
+ "pixelsEdited": false
+ },
+ {
+ "file": "front-v1.png",
+ "role": "front-reference",
+ "width": 1024,
+ "height": 1536,
+ "mode": "RGBA",
+ "alphaRange": [
+ 0,
+ 254
+ ],
+ "background": "soft translucent studio halo",
+ "sha256": "aec2db95ad28a0e9a6209f55f97206f0dabe5a91621991840da543fb5db98fac",
+ "prompt": "front.prompt.txt",
+ "references": [
+ "turnaround-v1.png"
+ ],
+ "pixelsEdited": false
+ },
+ {
+ "file": "profile-left-v1.png",
+ "role": "left-facing-profile-reference",
+ "width": 1024,
+ "height": 1536,
+ "mode": "RGBA",
+ "alphaRange": [
+ 0,
+ 254
+ ],
+ "background": "soft translucent studio halo",
+ "sha256": "eee55f4aa2d7451b9c2c41ee3d2595c075553b4471f81532d3d2f27f6c6218dc",
+ "prompt": "profile-left.prompt.txt",
+ "references": [
+ "turnaround-v1.png"
+ ],
+ "pixelsEdited": false
+ },
+ {
+ "file": "back-v1.png",
+ "role": "back-reference",
+ "width": 1287,
+ "height": 1222,
+ "mode": "RGBA",
+ "alphaRange": [
+ 0,
+ 255
+ ],
+ "background": "isolated alpha cutout",
+ "sha256": "e2123d713989cd9c363f3553bae912b9f8c69b0cc239ed858a2167dc8dbc551f",
+ "prompt": "back.prompt.txt",
+ "references": [
+ "turnaround-v1.png"
+ ],
+ "pixelsEdited": false
+ },
+ {
+ "file": "profile-right-v1.png",
+ "role": "right-facing-profile-reference",
+ "width": 1024,
+ "height": 1536,
+ "mode": "RGBA",
+ "alphaRange": [
+ 0,
+ 254
+ ],
+ "background": "soft translucent studio halo",
+ "sha256": "634335cc9ec6390a788ab0b5f3f1762f63e5a28c5e0170ceb24b0f629e679979",
+ "prompt": "profile-right.prompt.txt",
+ "references": [
+ "turnaround-v1.png"
+ ],
+ "pixelsEdited": false
+ },
+ {
+ "file": "three-quarter-pointing-v1.png",
+ "role": "three-quarter-gesture",
+ "width": 1145,
+ "height": 1374,
+ "mode": "RGBA",
+ "alphaRange": [
+ 0,
+ 255
+ ],
+ "background": "isolated alpha cutout",
+ "sha256": "99303bfd1633bf2ae2b89d3811d34620404dbeb417ca79a6249ed042fcfb3c45",
+ "prompt": "three-quarter-pointing.prompt.txt",
+ "references": [
+ "turnaround-v1.png"
+ ],
+ "pixelsEdited": false
+ },
+ {
+ "file": "source-header-v1.png",
+ "role": "original-design-reference",
+ "width": 2172,
+ "height": 724,
+ "mode": "RGB",
+ "alphaRange": null,
+ "background": "opaque environment",
+ "sha256": "598246848cee2e5425042f278a14485047b21a19ea47a1c86d59b36ae224e158",
+ "prompt": null,
+ "references": [],
+ "pixelsEdited": false
+ }
+ ],
+ "notes": [
+ "No ready-to-use 3D mesh or runtime animation is included.",
+ "Feet, backpack construction and rear panels were resolved from the partially occluded source illustration.",
+ "Individual generated views may differ in minor seams; the canonical turnaround sheet takes precedence.",
+ "Front and side portraits retain translucent studio halos and are references, not clean UI sprites."
+ ],
+ "promptTemplate": "master.prompt.txt"
+}
diff --git a/Documentation/Brand/Assets/PlaygroundMascot/master.prompt.txt b/Documentation/Brand/Assets/PlaygroundMascot/master.prompt.txt
new file mode 100644
index 000000000..b637d10b5
--- /dev/null
+++ b/Documentation/Brand/Assets/PlaygroundMascot/master.prompt.txt
@@ -0,0 +1,61 @@
+Use case: identity-preserve.
+Asset type: a polished stylized 3D brand mascot illustration for Ada Playground.
+
+REFERENCE PRIORITY
+The supplied turnaround-v1.png is the authoritative identity and construction
+reference. All four figures on that sheet are views of ONE SAME ROBOT.
+If a scene reference is also attached, use it for atmosphere only.
+Select the tall standing explorer robot, never the small crouching companion.
+
+IDENTITY LOCK
+Render the same friendly compact explorer robot with an oversized rounded
+warm-ivory ceramic helmet, slightly wider than its compact torso.
+Its head occupies about 42 percent of standing height, excluding the antenna.
+The face is one large glossy near-black rounded-rectangular inset visor.
+Exactly two luminous cyan eyes, each a thick inverted-U smiling arc with
+rounded ends. Keep the eye shapes crisp with restrained bloom.
+No human nose, lips, teeth, eyelashes or separate drawn mouth.
+
+Two warm golden circular side modules with dark centers and thin amber
+light rings. ONE short flattened paddle antenna with a yellow tip on the
+robot's OWN LEFT REAR crown. In front view it appears on viewer-right;
+in back view it appears on viewer-left. In the opposite side view it may
+be hidden by the head. Never mirror the robot or move the antenna.
+
+A short graphite accordion neck. Rounded cream torso armor with one small
+horizontal amber status lamp high at the center of the chest.
+Dark graphite joint gaps, rounded cream shoulder and forearm armor,
+gray rounded knee caps, short sturdy legs, broad cream boots with dark
+rubber soles and small amber ankle details.
+Each hand has exactly four digits: three fingers and one thumb.
+
+One compact cocoa-brown explorer backpack, two dark brown shoulder straps,
+a top flap and two dark fastening straps with metal buckles.
+From the front, show the shoulder straps, not the rear backpack face.
+From behind, do not show the visor, eyes or front chest lamp.
+
+MATERIAL AND RENDER
+High-quality stylized 3D game art with tactile satin ivory ceramic,
+warm metal hardware, dark rubber and restrained subtle edge wear.
+Clean and cared for, not a battle-damaged robot.
+Soft readable lighting, controlled reflections, moderate contrast.
+Preserve the reference silhouette, panel layout, backpack and proportions.
+
+VARIABLE SHOT
+[Specify ONE camera angle and ONE pose here.]
+[Specify full-body, portrait or scene framing here.]
+[Specify transparent background or a particular environment here.]
+
+COMPOSITION
+Keep the antenna, hands and feet inside the requested framing.
+For a full-body reference render, leave roughly ten percent clear margin.
+Use an orthographic-like 85 mm product-view perspective for turnarounds.
+For a UI header, place the robot to the right and leave calm negative space
+for live HTML text on the left. Do not generate text into the illustration.
+
+AVOID
+No extra robots, no extra antennae, no mirrored asymmetry, no human face,
+no extra fingers, no long thin adult-human limbs, no oversized combat armor,
+no guns or weapons, no chrome body, no purple eyes, no neon wash,
+no excessive bloom, no random lettering, no invented logo, no watermark.
+For transparent output, no painted checkerboard or opaque floor rectangle.
diff --git a/Documentation/Brand/Assets/PlaygroundMascot/profile-left-v1.png b/Documentation/Brand/Assets/PlaygroundMascot/profile-left-v1.png
new file mode 100644
index 000000000..d66a1ccf5
Binary files /dev/null and b/Documentation/Brand/Assets/PlaygroundMascot/profile-left-v1.png differ
diff --git a/Documentation/Brand/Assets/PlaygroundMascot/profile-left.prompt.txt b/Documentation/Brand/Assets/PlaygroundMascot/profile-left.prompt.txt
new file mode 100644
index 000000000..0072fc33a
--- /dev/null
+++ b/Documentation/Brand/Assets/PlaygroundMascot/profile-left.prompt.txt
@@ -0,0 +1,3 @@
+Use case: identity-preserve. Asset type: reusable full-body mascot character render with a genuinely transparent background. The attached four-view model sheet is the AUTHORITATIVE IDENTITY REFERENCE: all four figures are views of ONE SAME ROBOT, not four separate characters. Render exactly ONE instance of that same tall Ada Playground explorer robot. Preserve its exact helmet shape, large rounded rectangular near-black face visor, two cyan smiling eye arcs (an inverted U each) when the face is visible, warm ivory ceramic shell, golden amber circular ear modules with black centers and thin illuminated rings, ONE short yellow-tipped paddle antenna on the robot's OWN LEFT REAR crown (viewer-right in the frontal reference), a black accordion neck, amber rounded horizontal chest lamp, brown explorer backpack with two dark brown shoulder straps and two backpack buckles, graphite mechanical joint gaps, gray knee caps, rounded cream boots with charcoal rubber soles and small amber ankle details. Head about 42 percent of total standing height excluding antenna; short sturdy legs; four digits per hand, three fingers and a thumb. The physical side of the antenna never changes when the camera changes. Do not mirror or redesign the reference. Keep restrained subtle edge wear, satin ceramic, warm metal hardware, soft neutral studio lighting, polished stylized 3D game art. Entire character including antenna, hands and feet inside frame with about 10 percent clear margin, standing on an implied common ground level, camera at torso height, orthographic-like 85 mm product-view perspective, no environment. True alpha transparency around the subject, no opaque background, no checkerboard drawn into the image, no white rectangle, no floor plane. No text, no labels, no logos, no watermark, no additional robots.
+
+VIEW: strict SIDE PROFILE, the face points toward the LEFT EDGE of the image. Show the robot's own left side, matching the SECOND panel in the supplied model sheet. Neutral relaxed standing A-pose, arms relaxed. The single near-side circular ear module, visor depth, paddle antenna at the back of the crown and brown backpack profile must read clearly. Far-side limbs naturally overlap. Do not rotate into three-quarter view.
diff --git a/Documentation/Brand/Assets/PlaygroundMascot/profile-right-v1.png b/Documentation/Brand/Assets/PlaygroundMascot/profile-right-v1.png
new file mode 100644
index 000000000..85f857e8a
Binary files /dev/null and b/Documentation/Brand/Assets/PlaygroundMascot/profile-right-v1.png differ
diff --git a/Documentation/Brand/Assets/PlaygroundMascot/profile-right.prompt.txt b/Documentation/Brand/Assets/PlaygroundMascot/profile-right.prompt.txt
new file mode 100644
index 000000000..e9fc2d95f
--- /dev/null
+++ b/Documentation/Brand/Assets/PlaygroundMascot/profile-right.prompt.txt
@@ -0,0 +1,3 @@
+Use case: identity-preserve. Asset type: reusable full-body mascot character render with a genuinely transparent background. The attached four-view model sheet is the AUTHORITATIVE IDENTITY REFERENCE: all four figures are views of ONE SAME ROBOT, not four separate characters. Render exactly ONE instance of that same tall Ada Playground explorer robot. Preserve its exact helmet shape, large rounded rectangular near-black face visor, two cyan smiling eye arcs (an inverted U each) when the face is visible, warm ivory ceramic shell, golden amber circular ear modules with black centers and thin illuminated rings, ONE short yellow-tipped paddle antenna on the robot's OWN LEFT REAR crown (viewer-right in the frontal reference), a black accordion neck, amber rounded horizontal chest lamp, brown explorer backpack with two dark brown shoulder straps and two backpack buckles, graphite mechanical joint gaps, gray knee caps, rounded cream boots with charcoal rubber soles and small amber ankle details. Head about 42 percent of total standing height excluding antenna; short sturdy legs; four digits per hand, three fingers and a thumb. The physical side of the antenna never changes when the camera changes. Do not mirror or redesign the reference. Keep restrained subtle edge wear, satin ceramic, warm metal hardware, soft neutral studio lighting, polished stylized 3D game art. Entire character including antenna, hands and feet inside frame with about 10 percent clear margin, standing on an implied common ground level, camera at torso height, orthographic-like 85 mm product-view perspective, no environment. True alpha transparency around the subject, no opaque background, no checkerboard drawn into the image, no white rectangle, no floor plane. No text, no labels, no logos, no watermark, no additional robots.
+
+VIEW: strict opposite SIDE PROFILE, the face points toward the RIGHT EDGE of the image. Show the robot's own right side, matching the FOURTH panel in the supplied model sheet. Neutral relaxed standing A-pose, arms relaxed. Show visor depth and the brown backpack profile. The antenna belongs to the FAR left-rear side of the helmet and should be naturally occluded by the head in this exact view, as in the fourth panel; do not add a second antenna or move it to the near side. Far-side limbs naturally overlap. Do not rotate into three-quarter view.
diff --git a/Documentation/Brand/Assets/PlaygroundMascot/source-header-v1.png b/Documentation/Brand/Assets/PlaygroundMascot/source-header-v1.png
new file mode 100644
index 000000000..97ed5ed21
Binary files /dev/null and b/Documentation/Brand/Assets/PlaygroundMascot/source-header-v1.png differ
diff --git a/Documentation/Brand/Assets/PlaygroundMascot/three-quarter-pointing-v1.png b/Documentation/Brand/Assets/PlaygroundMascot/three-quarter-pointing-v1.png
new file mode 100644
index 000000000..6ed893b11
Binary files /dev/null and b/Documentation/Brand/Assets/PlaygroundMascot/three-quarter-pointing-v1.png differ
diff --git a/Documentation/Brand/Assets/PlaygroundMascot/three-quarter-pointing.prompt.txt b/Documentation/Brand/Assets/PlaygroundMascot/three-quarter-pointing.prompt.txt
new file mode 100644
index 000000000..71166057e
--- /dev/null
+++ b/Documentation/Brand/Assets/PlaygroundMascot/three-quarter-pointing.prompt.txt
@@ -0,0 +1,3 @@
+Use case: identity-preserve. Asset type: reusable full-body mascot character render with a genuinely transparent background. The attached four-view model sheet is the AUTHORITATIVE IDENTITY REFERENCE: all four figures are views of ONE SAME ROBOT, not four separate characters. Render exactly ONE instance of that same tall Ada Playground explorer robot. Preserve its exact helmet shape, large rounded rectangular near-black face visor, two cyan smiling eye arcs (an inverted U each) when the face is visible, warm ivory ceramic shell, golden amber circular ear modules with black centers and thin illuminated rings, ONE short yellow-tipped paddle antenna on the robot's OWN LEFT REAR crown (viewer-right in the frontal reference), a black accordion neck, amber rounded horizontal chest lamp, brown explorer backpack with two dark brown shoulder straps and two backpack buckles, graphite mechanical joint gaps, gray knee caps, rounded cream boots with charcoal rubber soles and small amber ankle details. Head about 42 percent of total standing height excluding antenna; short sturdy legs; four digits per hand, three fingers and a thumb. The physical side of the antenna never changes when the camera changes. Do not mirror or redesign the reference. Keep restrained subtle edge wear, satin ceramic, warm metal hardware, soft neutral studio lighting, polished stylized 3D game art. Entire character including antenna, hands and feet inside frame with about 10 percent clear margin, standing on an implied common ground level, camera at torso height, orthographic-like 85 mm product-view perspective, no environment. True alpha transparency around the subject, no opaque background, no checkerboard drawn into the image, no white rectangle, no floor plane. No text, no labels, no logos, no watermark, no additional robots.
+
+VIEW AND POSE: a three-quarter FRONT view, the robot turns gently toward image-left. It makes the characteristic friendly pointing gesture from the original header: the robot's OWN RIGHT arm (viewer-left) extends diagonally toward the upper-left of the image; its index finger points forward, thumb and other fingers naturally curl, the left arm hangs relaxed. Both bright cyan smiling eye arcs visible. Standing with both feet on the ground in a balanced relaxed stance, not running. Show the antenna on the same own-left-rear crown and a glimpse of its backpack. This is the guide/invitation pose, optimistic and welcoming.
diff --git a/Documentation/Brand/Assets/PlaygroundMascot/turnaround-v1.png b/Documentation/Brand/Assets/PlaygroundMascot/turnaround-v1.png
new file mode 100644
index 000000000..d33cf6e27
Binary files /dev/null and b/Documentation/Brand/Assets/PlaygroundMascot/turnaround-v1.png differ
diff --git a/Documentation/Brand/Assets/PlaygroundMascot/turnaround.prompt.txt b/Documentation/Brand/Assets/PlaygroundMascot/turnaround.prompt.txt
new file mode 100644
index 000000000..de75eb432
--- /dev/null
+++ b/Documentation/Brand/Assets/PlaygroundMascot/turnaround.prompt.txt
@@ -0,0 +1 @@
+Use case: identity-preserve. Asset type: authoritative four-view character turnaround sheet for an Ada Playground brand mascot. Reference image role: the attached image is the approved design reference, NOT a layout to reproduce. Select ONLY the TALL STANDING ROBOT POINTING FORWARD (the larger robot to the left of the crouching small robot, in the right half of the reference). The small crouching robot must not appear. Preserve the selected tall robot's identity: oversized rounded off-white ceramic helmet, a single large glossy near-black rounded rectangular inset face screen, exactly two luminous cyan upside-down-U smiling eye arcs, no nose and no human mouth, warm golden-yellow circular ear modules with a dark center and a thin amber light ring, one short flattened paddle antenna on the robot's own LEFT REAR crown (viewer-right in the FRONT view), warm yellow antenna tip, compact cream segmented torso, a small horizontal rounded amber status lamp centered high on the chest, dark graphite accordion neck and joint gaps, rounded cream shoulder/forearm/shin armor, dark articulated elbow/hip/knee joints, gray rounded knee caps, short sturdy legs, broad rounded cream boots with dark rubber soles, friendly four-digit hands (three fingers plus thumb), a small cocoa-brown explorer backpack with two dark brown shoulder straps. Keep the same original head silhouette and face proportions, cute but capable, no redesign into another generic robot. Resolve previously hidden feet and back consistently. Body proportion: head about 42 percent of full standing height excluding antenna; torso compact; stocky short legs; no thin adult-human proportions. Four views of exactly the SAME one robot, all in a neutral relaxed A-pose, arms slightly away from body, full body visible, equal size and common ground baseline: FRONT, SIDE FACING LEFT, BACK, SIDE FACING RIGHT, ordered left to right in four evenly spaced columns. Orthographic-style product visualization with minimal perspective, 70–100 mm lens feel, camera at torso height. The side views must be genuinely opposite sides, never a mirrored copy; keep physical antenna and backpack asymmetry fixed on the same body side. Clean pale warm-gray studio background, soft neutral shadow under each pair of feet, high-quality stylized 3D game rendering, satin ivory ceramic with subtle edge wear, amber/gold hardware, cyan light; neutral soft studio lighting so the palette is accurate. Wide landscape sheet, generous margins, no cropping. No lettering or panel titles, no logos, no watermark, no environment, no props, no second character design.
diff --git a/Documentation/Brand/PlaygroundMascot.html b/Documentation/Brand/PlaygroundMascot.html
new file mode 100644
index 000000000..274186d50
--- /dev/null
+++ b/Documentation/Brand/PlaygroundMascot.html
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+ Робот-проводник — Ada Playground
+
+
+
+ Ada Playground · МаскотПаспорт v1 · 19 октября 2026
+
+
+
+ Робот-проводник
+
Открывать миры. Создавать своё.
+
Высокий робот из шапки Playground: тёплая керамика, голубые глаза-дуги и походный рюкзак. Любопытный помощник, который приглашает сделать следующий шаг.
Большая округлая голова, короткие крепкие ноги, светлая сатиновая оболочка, единый чёрный экран, два голубых глаза, янтарные кольца и коричневый рюкзак.
+ #E8E5DC#091720#39D9FF#E8B34B#655144
+
+
Как сохранять личность
К новой генерации прикладывайте модельный лист. Сторона антенны закреплена за телом робота: слева сзади у него самого. Не зеркальте изображение для противоположного профиля. Полные ограничения и примеры сцен есть в паспорте.
+
+
+
+
+
diff --git a/Documentation/Brand/PlaygroundMascot.md b/Documentation/Brand/PlaygroundMascot.md
new file mode 100644
index 000000000..046bf90c3
--- /dev/null
+++ b/Documentation/Brand/PlaygroundMascot.md
@@ -0,0 +1,299 @@
+# Робот-проводник Ada Playground
+
+Паспорт маскота · версия 1 · 19 октября 2026
+
+Основной персонаж — **высокий робот, который показывает вперёд**, из шапки Playground. Маленький робот у растения — другой персонаж; его пропорции и поза не входят в этот паспорт. Собственное имя маскоту пока не назначено: «робот-проводник» описывает его роль.
+
+
+
+Это визуальный референс для иллюстраций, интерфейса и будущей 3D-модели. Изображения сгенерированы встроенным imagegen; это не скриншоты работающей игры и не готовая 3D-модель.
+
+## Что сохраняем из исходной иллюстрации
+
+Маскот выглядит как небольшой дружелюбный исследователь: большая округлая голова, короткое крепкое тело, светлая оболочка и тёмные подвижные соединения. Его выражение узнаётся по двум светящимся голубым глазам-дугам на едином чёрном экране. Тёплые жёлто-янтарные детали уравновешивают холодное голубое свечение. Коричневый рюкзак делает его путешественником, который готов вместе с пользователем открыть новый мир.
+
+Характерный жест — правой рукой показать путь вперёд, немного вверх. Это приглашение попробовать, поиграть или создать что-то своё. Он любопытный, внимательный и уверенный, но не командует пользователем и не изображает всезнающего наставника.
+
+В исходной шапке ступни частично закрыты травой, а спина не видна. **Подошвы, задние панели головы, конструкция рюкзака и его застёжки уточнены на модельном листе v1.** Для последующих изображений используем именно этот лист, а не достраиваем невидимые детали заново в каждой генерации.
+
+Исходная сцена: [два робота в шапке Playground](Assets/PlaygroundMascot/source-header-v1.png).
+
+## Каноническая внешность
+
+### Силуэт и пропорции
+
+Голова — крупный мягкий объём, похожий на скруглённый шлем или коробку с сильно сглаженными углами. Она немного шире корпуса. Ни острых скул, ни угловатой челюсти, ни острых наплечников. Корпус компактный, с округлой передней панелью и узким тёмным поясом механики у таза.
+
+Пропорции ниже — ориентиры для проверки силуэта, а не размеры CAD-модели. Высота H считается от подошв до верхней точки головы, без антенны.
+
+| Часть | Ориентир |
+| --- | --- |
+| Голова | Около 40–45% H; узнаваемый большой объём, не маленькая человеческая голова |
+| Шея | Короткий тёмный гофрированный переход; не длинная стойка |
+| Корпус и таз | Примерно четверть H; корпус чуть уже головы |
+| Ноги со ступнями | Около трети H; короткие, устойчивые, без тонких человеческих голеней |
+| Ступни | Широкие округлые ботинки с читаемой тёмной подошвой |
+| Ладони | Небольшие относительно головы, но достаточно крупные для понятных жестов |
+| Антенна | Одна короткая деталь; не меняет основной силуэт головы |
+
+Робот должен казаться способным ходить, исследовать и что-то мастерить. Не превращаем его в младенца, длинноногого гуманоида, тяжёлого боевого меха или мягкую плюшевую игрушку.
+
+### Голова и лицо
+
+Передняя часть головы — **единый большой глянцевый экран почти чёрного цвета**, в форме горизонтального прямоугольника с очень скруглёнными углами. Вокруг экрана остаётся непрерывная светлая рамка корпуса. Экран слегка выпуклый, с мягкими отражениями окружения; он не выглядит плоской наклейкой.
+
+На экране — ровно **два голубых глаза**. Базовая радостная форма каждого глаза — толстая перевёрнутая U-образная дуга, как «∩», с округлыми концами. Между глазами сохраняется тёмное пространство. Светящийся ореол небольшой: форма глаз должна оставаться резкой и читаться в уменьшении.
+
+У базового персонажа нет человеческого носа, зубов, губ, ресниц и отдельного нарисованного рта. Эмоцию передают глаза, наклон головы, руки и положение корпуса. Новые выражения не должны превращать экран в человеческое лицо.
+
+По бокам головы находятся круглые золотистые модули: тёмный центр, объёмный тёплый ободок и тонкое янтарное световое кольцо. Они встроены в шлем и не похожи на отдельные большие наушники. На верхней и задней поверхности головы есть несколько аккуратных стыков панелей.
+
+### Антенна и асимметрия
+
+Антенна **одна**, короткая, уплощённая, с жёлтой светлой вставкой и тёмным основанием. Она установлена **слева сзади на голове самого робота**.
+
+| Ракурс | Как ведёт себя антенна |
+| --- | --- |
+| Спереди | На правой стороне изображения для зрителя |
+| Сзади | На левой стороне изображения для зрителя |
+| Профиль, лицо влево | Видна у задней части макушки |
+| Профиль, лицо вправо | На дальней стороне головы и может быть полностью скрыта |
+
+«Слева у робота» и «слева на картинке» — разные вещи. Нельзя получать противоположный профиль простым зеркальным отражением: оно перенесёт антенну на неверную сторону.
+
+### Корпус, руки и ноги
+
+Светлая броня состоит из отдельных мягко скруглённых деталей. Плечо и предплечье разделены тёмным шарнирным узлом; запястье подвижное. В соединениях видны графитовые кольца и механика, но без плотного набора проводов, трубок и случайных микродеталей.
+
+На груди находится небольшой горизонтальный янтарный индикатор в скруглённой рамке. Это световой элемент, а не текстовый бейдж. Не добавляем туда случайные буквы, надписи или сгенерированное подобие логотипа Ada.
+
+На каждой кисти — **три пальца и большой палец, всего четыре**. Пальцы сегментированные, с округлыми светлыми внешними деталями и тёмными сочленениями. В указательном жесте выпрямлен указательный палец; остальные согнуты естественно, большой палец различим.
+
+Колени закрыты округлыми серыми чашечками. Голени короткие, светлые, слегка расширяются к ботинкам. У ботинок тёмная толстая подошва с простым протектором и небольшие янтарные элементы у щиколотки. Обе ступни полностью показываем в ракурсных изображениях.
+
+### Рюкзак
+
+На спине — один компактный рюкзак тёплого коричневого цвета, по ширине примерно с корпус. Он сидит высоко и не опускается ниже таза. В v1 у него верхний клапан, две вертикальные тёмные застёжки с металлическими пряжками и небольшие боковые карманы.
+
+Спереди видны две плечевые лямки и их крепления. В профиль рюкзак выступает за линию спины. Сзади он занимает центральную часть корпуса. Это походный аксессуар с мягкими краями, не ракетный двигатель и не второй робот.
+
+## Материалы, палитра и свет
+
+Оболочка напоминает тёплую сатиновую керамику или качественный окрашенный композит. На крупных формах есть мягкие блики. Допустимы редкие мелкие потёртости на кромках, но персонаж остаётся аккуратным: без ржавчины, копоти, боевых повреждений и грязи.
+
+Палитра — ориентир для материалов при нейтральном освещении. Цвет конкретного пикселя на иллюстрации меняется от света, отражений и экспозиции; эти HEX не являются результатом измерения всей поверхности.
+
+| Материал | Целевой тон | Назначение |
+| --- | --- | --- |
+| Основная оболочка | #E8E5DC | Тёплый слоновый, не чистый белый и не жёлтая броня |
+| Экран лица | #091720 | Почти чёрный с холодным оттенком |
+| Глаза | #39D9FF | Голубой источник света |
+| Золотистые детали | #E8B34B | Кольца и небольшие аппаратные акценты |
+| Янтарный свет | #FFC86A | Кольца, грудной индикатор, мелкие световые детали |
+| Механические соединения | #30343A | Тёмный графит |
+| Рюкзак и лямки | #655144 | Тёплый коричневый |
+| Подошвы | #242A30 | Тёмная резина |
+
+Для модельных ракурсов используем мягкий нейтральный студийный свет. Для сцен Playground — холодное голубое окружение и умеренные тёплые источники рядом с роботом. Фиолетовый не заменяет голубые глаза или основной акцент продукта.
+
+Не делаем всю поверхность зеркальным хромом. Не заливаем корпус неоном. Свечение глаз не должно скрывать их форму или засвечивать половину лица.
+
+## Ракурсы и файлы
+
+Все отдельные изображения ниже показывают одного и того же персонажа. Передний и боковые ракурсы — студийные референсы с мягкой полупрозрачной световой подложкой; задний ракурс и указательный жест — изолированные PNG на прозрачном фоне. В галерее используется нейтральная подложка. Модельный лист остаётся главным источником конструкции, если в отдельной генерации расходятся мелкие стыки или блики.
+
+### Спереди
+
+
+
+Камера перед роботом. Два глаза одинакового размера, корпус не повёрнут, антенна справа для зрителя. Нейтральная A-поза: руки немного отведены от корпуса, кисти расслаблены.
+
+### Профиль, лицо влево
+
+
+
+Чёткий боковой вид: читаются глубина экрана, боковой модуль, антенна и объём рюкзака. Это не поворот в три четверти.
+
+### Сзади
+
+
+
+Рюкзак, задние панели головы, затылок, обе ноги и подошвы. Лицевой экран, глаза и грудной индикатор не видны. Антенна слева для зрителя.
+
+### Противоположный профиль, лицо вправо
+
+
+
+Противоположная сторона той же конструкции. Антенна остаётся на дальней стороне головы: не переносим её ради симметрии кадра.
+
+### Три четверти: жест проводника
+
+
+
+Робот повёрнут немного влево на изображении, правой рукой показывает вперёд и вверх. Обе ноги опираются на землю, поза спокойная и устойчивая. Этот ракурс подходит для приглашения создать игру, начала обучения и подсказок.
+
+[Открыть галерею с увеличением](PlaygroundMascot.html).
+
+## Эмоции и использование в интерфейсе
+
+- **Приветствие:** глаза-дуги, небольшой наклон головы, открытая ладонь или жест проводника.
+- **Любопытство:** лёгкий наклон головы и корпуса к объекту, внимание выражено позой.
+- **Успех:** более открытая поза, небольшой подъём рук; без вспышек и чрезмерного неона.
+- **Ожидание:** спокойная поза, интерес к происходящему; не изображаем ошибку или поломку.
+- **Ошибка:** внимательный и сочувствующий персонаж, без паники, красных глаз и обвинительного жеста в сторону пользователя.
+
+В шапке держим робота в стороне от заголовка, оставляя спокойное место под текст. В компактном пустом состоянии уменьшаем окружение и количество мелких деталей, сохраняя силуэт головы и форму глаз. На очень маленьком размере лучше использовать голову, чем пытаться показать весь костюм и рюкзак.
+
+Маскот сопровождает контент и действия. Не превращаем его в постоянный большой оверлей, который закрывает игры, редактор или элементы управления.
+
+## Как генерировать того же робота
+
+### Референсы и порядок работы
+
+1. Приложить [turnaround-v1.png](Assets/PlaygroundMascot/turnaround-v1.png) как основной референс личности и конструкции.
+2. Если нужна сцена в духе Playground, дополнительно приложить [source-header-v1.png](Assets/PlaygroundMascot/source-header-v1.png) как референс света и окружения. Уточнить: брать высокого стоящего робота, маленького не включать.
+3. Взять основной промпт ниже и добавить один конкретный ракурс, позу, кадрирование и фон.
+4. Для отдельного персонажа запросить настоящую прозрачность в инструменте генерации. Текст «transparent» сам по себе не заменяет настройку прозрачного фона.
+5. Сначала проверить лицо, силуэт и сторону антенны. При ошибке править только проблемный признак, продолжая прикладывать тот же модельный лист.
+6. Сохранять новые варианты отдельными файлами с версией, не перезаписывая v1.
+
+Для текущих материалов использован **встроенный imagegen**, без CLI и без ручной правки пикселей. Одинаковый текстовый промпт без изображения-референса не гарантирует сохранение личности персонажа; один текст не заменяет модельный лист.
+
+### Основной промпт на английском
+
+Английская формулировка фиксирует геометрию и материалы. Изменяем блок VARIABLE SHOT, а блок IDENTITY LOCK оставляем целиком. [Открыть промпт отдельным TXT](Assets/PlaygroundMascot/master.prompt.txt).
+
+~~~text
+Use case: identity-preserve.
+Asset type: a polished stylized 3D brand mascot illustration for Ada Playground.
+
+REFERENCE PRIORITY
+The supplied turnaround-v1.png is the authoritative identity and construction
+reference. All four figures on that sheet are views of ONE SAME ROBOT.
+If a scene reference is also attached, use it for atmosphere only.
+Select the tall standing explorer robot, never the small crouching companion.
+
+IDENTITY LOCK
+Render the same friendly compact explorer robot with an oversized rounded
+warm-ivory ceramic helmet, slightly wider than its compact torso.
+Its head occupies about 42 percent of standing height, excluding the antenna.
+The face is one large glossy near-black rounded-rectangular inset visor.
+Exactly two luminous cyan eyes, each a thick inverted-U smiling arc with
+rounded ends. Keep the eye shapes crisp with restrained bloom.
+No human nose, lips, teeth, eyelashes or separate drawn mouth.
+
+Two warm golden circular side modules with dark centers and thin amber
+light rings. ONE short flattened paddle antenna with a yellow tip on the
+robot's OWN LEFT REAR crown. In front view it appears on viewer-right;
+in back view it appears on viewer-left. In the opposite side view it may
+be hidden by the head. Never mirror the robot or move the antenna.
+
+A short graphite accordion neck. Rounded cream torso armor with one small
+horizontal amber status lamp high at the center of the chest.
+Dark graphite joint gaps, rounded cream shoulder and forearm armor,
+gray rounded knee caps, short sturdy legs, broad cream boots with dark
+rubber soles and small amber ankle details.
+Each hand has exactly four digits: three fingers and one thumb.
+
+One compact cocoa-brown explorer backpack, two dark brown shoulder straps,
+a top flap and two dark fastening straps with metal buckles.
+From the front, show the shoulder straps, not the rear backpack face.
+From behind, do not show the visor, eyes or front chest lamp.
+
+MATERIAL AND RENDER
+High-quality stylized 3D game art with tactile satin ivory ceramic,
+warm metal hardware, dark rubber and restrained subtle edge wear.
+Clean and cared for, not a battle-damaged robot.
+Soft readable lighting, controlled reflections, moderate contrast.
+Preserve the reference silhouette, panel layout, backpack and proportions.
+
+VARIABLE SHOT
+[Specify ONE camera angle and ONE pose here.]
+[Specify full-body, portrait or scene framing here.]
+[Specify transparent background or a particular environment here.]
+
+COMPOSITION
+Keep the antenna, hands and feet inside the requested framing.
+For a full-body reference render, leave roughly ten percent clear margin.
+Use an orthographic-like 85 mm product-view perspective for turnarounds.
+For a UI header, place the robot to the right and leave calm negative space
+for live HTML text on the left. Do not generate text into the illustration.
+
+AVOID
+No extra robots, no extra antennae, no mirrored asymmetry, no human face,
+no extra fingers, no long thin adult-human limbs, no oversized combat armor,
+no guns or weapons, no chrome body, no purple eyes, no neon wash,
+no excessive bloom, no random lettering, no invented logo, no watermark.
+For transparent output, no painted checkerboard or opaque floor rectangle.
+~~~
+
+### Точные промпты для приложенных изображений
+
+Эти файлы содержат полные тексты фактических запросов, а не сокращённые подписи. Для отдельных ракурсов к запросу прикладывался модельный лист v1; для самого листа — исходная шапка.
+
+| Изображение | Полный промпт |
+| --- | --- |
+| Модельный лист, четыре ракурса | [turnaround.prompt.txt](Assets/PlaygroundMascot/turnaround.prompt.txt) |
+| Спереди | [front.prompt.txt](Assets/PlaygroundMascot/front.prompt.txt) |
+| Профиль с лицом влево | [profile-left.prompt.txt](Assets/PlaygroundMascot/profile-left.prompt.txt) |
+| Сзади | [back.prompt.txt](Assets/PlaygroundMascot/back.prompt.txt) |
+| Профиль с лицом вправо | [profile-right.prompt.txt](Assets/PlaygroundMascot/profile-right.prompt.txt) |
+| Три четверти, указательный жест | [three-quarter-pointing.prompt.txt](Assets/PlaygroundMascot/three-quarter-pointing.prompt.txt) |
+
+### Примеры заменяемого блока
+
+**Нейтральный фронт:**
+
+~~~text
+VIEW: exact front elevation, body square to camera, looking toward the viewer.
+POSE: relaxed A-pose, arms slightly away from the torso, both hands relaxed.
+FRAMING: full body, antenna and both boots visible, equal clear margin.
+BACKGROUND: genuine alpha transparency, no environment or floor plane.
+LIGHTING: soft neutral studio lighting.
+~~~
+
+**Сцена для шапки Playground:**
+
+~~~text
+VIEW: three-quarter front view, gently turned toward image-left.
+POSE: the robot's OWN RIGHT hand points diagonally forward and upward,
+while the other hand rests naturally. Both feet are planted.
+FRAMING: wide panoramic header; robot in the right half.
+ENVIRONMENT: a tiny stylized garden on a floating island, quiet blue
+atmosphere, subtle warm lights. No second robot.
+COMPOSITION: left half is calm dark charcoal negative space for HTML text;
+the scene recedes into #121314 at the left and bottom edges.
+Keep the face and entire pointing hand clearly visible.
+~~~
+
+### Как исправлять расхождения
+
+Не просить «сделай похожего милого робота»: это разрешает модели менять конструкцию. Вместо этого прикладываем канонический лист и называем одно расхождение:
+
+~~~text
+Keep the exact same character identity, materials, pose, framing and lighting.
+Correct ONLY the antenna: it belongs on the character's own left-rear crown.
+Do not mirror the character, add a second antenna or redesign any other part.
+~~~
+
+Для лица аналогично просим вернуть ровно две голубые глаза-дуги, не меняя остальное. Для ракурса сзади отдельно запрещаем видимые глаза и грудной индикатор. Для пальцев просим три пальца плюс большой и проверяем обе кисти.
+
+## Проверка нового варианта
+
+- Узнаётся тот же силуэт большой округлой головы и короткого корпуса.
+- Экран один; глаза голубые, их два, базовая форма — дуги.
+- Нет человеческого носа, зубов, губ или случайного текста на лице.
+- Боковые круглые модули золотистые, с тёмным центром.
+- Антенна одна и остаётся на собственной левой задней стороне головы.
+- Ракурс с другой стороны не сделан зеркальным отражением.
+- Руки и ноги соответствуют референсу; пальцы не слились и не размножились.
+- Рюкзак коричневый, с двумя плечевыми лямками; не стал двигателем или бронёй.
+- Задний вид не показывает лицо и передний грудной индикатор.
+- Свечение не съело форму глаз и не выбелило корпус.
+- Нет обрезанной антенны, кисти, ботинка или случайного второго персонажа.
+- При прозрачном экспорте фон действительно имеет alpha, а не нарисованную шахматную сетку.
+- В интерфейсе текст не ложится поверх лица и указательной руки.
+
+## Реестр материалов
+
+[manifest.json](Assets/PlaygroundMascot/manifest.json) хранит названия файлов, размеры, SHA-256, наличие alpha, связи с референсами и инструмент генерации. Исходные PNG сохраняются без ресайза и перерисовки. Модельный лист и точные промпты версионируются вместе с этим документом.
diff --git a/Documentation/Brand/README.md b/Documentation/Brand/README.md
new file mode 100644
index 000000000..8bcb81795
--- /dev/null
+++ b/Documentation/Brand/README.md
@@ -0,0 +1,5 @@
+# Визуальный стиль Ada
+
+- [Робот-проводник Playground](PlaygroundMascot.md) — подробный паспорт маскота, ракурсы, материалы и промпты.
+- [Галерея ракурсов](PlaygroundMascot.html) — локальная HTML-страница с отдельными изображениями и ссылками на полные PNG.
+
diff --git a/Editor/AGENTS.md b/Editor/AGENTS.md
index 04d0aec23..dbe81ed76 100644
--- a/Editor/AGENTS.md
+++ b/Editor/AGENTS.md
@@ -85,8 +85,9 @@ and assess gaps before proposing new features.
### Browser Studio
-`WebUI/` is the default React/DOM browser shell, with floating scene panels and
-native text inputs. The scene viewport and Play use one isolated fixed AdaPlayer
+`WebUI/` is the default React/DOM browser shell, with inset floating panels over
+a full-width viewport: a full-height assistant on the left, scene panels on the
+right, a bottom log console and native text inputs. The scene viewport and Play use one isolated fixed AdaPlayer
with authored cameras, materials and lighting. Opening a project loads the engine;
document/mode changes replace its world without recreating WASM or the GPU device.
There is no separate SVG scene renderer. Editor mode disables gameplay, physics
@@ -95,9 +96,12 @@ duplicate/delete and a transient free camera are available. Gizmo gestures commi
one undo transaction; navigation never changes authored cameras. CodeMirror provides
AdaScript highlighting, indentation and search. It implements the existing
portable snapshot/scene/API contracts with transactions/undo, cloud recovery, ZIP
-interchange and an in-tab agent. The rail switches overview, scene, assets, code
-and assistant within one project. Home opens its overview; All Projects waits for
-cloud save and retains the current document, undo, selection and Player for Continue.
+interchange and an in-tab agent. The entry library uses
+Games/Create/My projects/Account navigation and the public catalog independently
+of Studio sign-in. Opening a workspace switches the rail
+to overview, scene, assets, code and assistant within that project. The Ada mark
+returns to Games through the same save/recovery path as All Projects. The overview
+button opens the project overview; All Projects waits for cloud save and retains the current document, undo, selection and Player for Continue.
Save failure offers Stay, retry, ZIP or an explicit IndexedDB-backed local exit;
failed local writes keep the project open. Closing a tab with unsaved changes or
an active agent requests browser confirmation. `Web/` retains the prior AdaUI/WASM application
@@ -115,8 +119,9 @@ progress and interactive forms persist with the project. Build/plan modes and
optional per-tool approval use a fixed 35-tool registry. A2UI uses validated DOM
controls and native action envelopes; local scene actions and their durable Undo
are atomic and revision checked. The isolated Player runs native procedural asset
-generators and exports `.ui` through AdaA2UI. New assistant chrome uses the shared
-en/ru catalogs; browser settings use the same language preference enum. OS/process,
+generators and exports `.ui` through AdaA2UI. Browser chrome, inspector, source search and embedded Cloud panels use the shared
+en/ru catalogs and language preference enum. Language changes preserve mounted input;
+the frontend build rejects hardcoded Russian UI literals and missing translations. OS/process,
external MCP, search, TestWorld and publication tools remain unavailable here.
Web Annotate selects entities via hierarchy/native viewport, A2UI components, or
@@ -590,14 +595,14 @@ achievements/Game Center, cloud account/settings and standalone update UI exist.
Shared AdaUI settings switches place labels and controls at opposite row edges,
with animated thumb movement and track tint; settings rows omit On/Off text.
+Cloud Pro includes 500 shared monthly AI credits. Browser Studio selects hosted Coder Next from the server registry and shows remaining/used/reserved credits and renewal; missing service is distinct from an empty balance. BYOK/local connections remain independent.
+
Ada Cloud AI wallet integration uses the existing account session for catalog,
balance, cursor-paginated usage, quotes, reservations, lookup and cancellation.
Credit counters appear on the start screen, shared workspace toolbar and iPhone navigation;
Cloud settings show available/used/reserved credits and cycle activity. AI credits
are separate from Cloud publishing Pro. Missing/disabled service and signed-out
-states do not imply a zero balance. Provider execution, settlement and subscription
-SKU activation remain server responsibilities; this client integration does not
-turn external/BYOK agents into hosted AI.
+states do not imply a zero balance. Hosted Ada AI uses the shared Cloud account transport, Coder Next tool loop and server wallet. Desktop selects the built-in Ada AI entry; mobile selects Ada AI in Providers. Tool mutations require approval; Cloud conversations persist separately from external runtime sessions. Provider keys and settlement stay server-side, and external/BYOK agents remain independent.
Entry points: [EditorCloudAIClient.swift](Sources/AdaEditor/Cloud/EditorCloudAIClient.swift),
[EditorAICreditsModel.swift](Sources/AdaEditor/Cloud/EditorAICreditsModel.swift).
Coverage: [EditorAICreditsTests.swift](Tests/AdaEditorTests/EditorAICreditsTests.swift),
@@ -640,3 +645,13 @@ Before calling a feature missing, trace its UI entry point, implementation and
nearest tests. Distinguish engine support, editor authoring, platform availability
and runtime validation. Record proposals as proposals; do not list plans or
test filenames as proof of a working feature.
+
+### Unified Playground web entry
+
+`Editor/WebUI` owns Games/Create/My projects and the editor in one shell. Robot
+artwork stays in `assets/playground`. Canonical routes include `/games`, `/create`,
+`/projects/:id`, `/account` and `/publications/:id`; `/studio/` is a compatibility
+entry and `/studio/*` serves static assets. Shared account/auth/legal/publication
+panels are built from adawebsite (`npm run build:panels`) and embedded without an
+iframe. Every navigation uses the existing save/leave guard. Keep publication
+page IDs separate from private workspace IDs and retain native/Store auth callbacks.
diff --git a/Editor/Documentation/CloudAI.md b/Editor/Documentation/CloudAI.md
new file mode 100644
index 000000000..560f01090
--- /dev/null
+++ b/Editor/Documentation/CloudAI.md
@@ -0,0 +1,50 @@
+# Ada AI in Cloud Pro
+
+Cloud Pro includes 500 AI credits per account per month. Browser and native Studio
+share the same balance and billing anniversary. There are no separate AI purchase
+tiers. Token usage is charged by the server's versioned credit tariff; a credit is
+not a message or an OpenRouter dollar.
+
+## Connection
+
+The backend owns `STUDIO_OPENROUTER_API_KEY` and selects `qwen/qwen3-coder-next`.
+Enable `AI_ENABLED`, the `llm` capability and approved countries on AIService.
+Clients use their existing Ada Cloud session and never receive the provider key.
+
+Browser Studio selects Ada AI in Assistant settings. Native Studio selects the
+built-in Ada AI entry in the agent catalog; iPhone Providers has the same choice.
+Cloud settings show available, used and reserved credits and the next cycle.
+Missing service is distinct from a real zero balance. Cloud Pro purchase/restore
+uses the existing subscription flow and SKU.
+
+Coder Next is text-only. Image attachments require another compatible provider.
+Native Cloud tool names are normalized from dots to underscores. Project files,
+docs, scene editing, build and Play use existing host tools. Mutations require
+permission; ask/plan modes advertise read-only tools. Shell and external agents
+are not part of this connection. File tools reject traversal, symbolic links and
+silent overwrites; open dirty documents must be saved first.
+
+## Accounting and recovery
+
+The server reserves a bounded request before calling OpenRouter, rechecks Billing
+before execution, then settles measured input/output tokens. Each model/tool round
+is a new metered operation. Completed operation replays never submit again.
+Definitive provider rejections release funds; uncertain/missing usage retains a
+reservation for reconciliation. A client disconnect does not make admitted work
+free or trigger a paid retry. Native account/server changes invalidate a running
+transport before another model round.
+
+Mobile Cloud conversations use `cloud-` session IDs in the native agent store.
+Provider changes start a new conversation; existing external runtime histories
+remain accessible in their provider's session list.
+
+## Local builds
+
+Studio prefers an adjacent AdaMCP checkout when present; `ADA_MCP_LOCAL_PATH`
+overrides that path. The current source uses MCP tool `_meta` verification data.
+The companion AdaMCP change adds flat Codable metadata support; keep it with this
+integration when preparing the dependency release. A copy of that patch and the
+backend validation report live in adaengine-service/docs.
+
+Focused tests: `EditorCloudAgentTests`, `EditorAICreditsTests`,
+`EditorLocalizationTests`, and WebUI `ai-credits.test.mjs`.
diff --git a/Editor/Documentation/StudioWeb.md b/Editor/Documentation/StudioWeb.md
index 3d4f5c7db..c5d7e71c7 100644
--- a/Editor/Documentation/StudioWeb.md
+++ b/Editor/Documentation/StudioWeb.md
@@ -1,13 +1,42 @@
# Ada Studio Web
`Editor/WebUI` is the default browser editor: a small React/DOM shell with native
-text inputs and floating object/assistant/inspector panels. Both the scene viewport
+text inputs and inset floating panels over a full-width viewport. The full-height
+assistant sits on the left, with scene objects and the inspector on the right.
+The bottom console reads the current AdaPlayer log stream and viewport diagnostics. Both the scene viewport
and Play use the fixed `AdaWebPlayer` in one isolated iframe; the editor does not
draw a separate scene in SVG, Canvas or another renderer. Opening a project starts
AdaEngine asynchronously while the DOM controls remain available. There is no
user Swift compilation, native process runner, ACP subprocess, SourceKit or
credential-store dependency in the browser target.
+## Playground entry point
+
+The DOM WebUI now starts with the Playground library. Its rail has Games, Create,
+My projects and Account. Games reads the public `/v1/catalog` independently of
+Studio sign-in and AI availability; an empty catalog is shown honestly. Create
+uses the existing assistant and 2D/3D templates, and My projects opens private
+Cloud workspaces. Generated tag artwork lives under `WebUI/assets/playground`
+and is included in the ordinary frontend export.
+
+Opening a project restores the project tools in the rail. The Ada mark returns
+to Games and My projects opens the private list. Both paths wait for save, retain
+the document/undo/selection/Player and use the existing recovery dialog on failure.
+The library starts without automatically opening the last project. Create and My projects share an aligned mascot welcome layout with localized actions.
+Signed-out, loading, unavailable and genuinely empty workspaces have distinct
+states; an existing project list is never replaced by example content. Public game
+launches use a separate tab (or the native Ada Studio link); they do not replace
+the current editing document. Publishing controls remain a separate boundary.
+
+Rebuild `Editor/WebUI` and refresh `/studio/` to see this interface. The standalone
+HTML design preview and the Cloud website build are separate artifacts; changing
+one does not update WebUI. Hosted deployment still needs an explicit rollout.
+
+Optional assistant workflows are discovered by typing `/` in the composer.
+The compact completion menu filters commands as you type; Enter/Tab inserts a
+command into the editable draft, while Escape dismisses it. Only Send starts the
+agent. Explicit workflow commands can appear anywhere in the request.
+
## Modules and boundaries
- `Editor/StudioCore` owns portable values, project snapshots, scene operations,
@@ -75,6 +104,16 @@ IndexedDB transaction; storage failure keeps the project open. Local recovery is
scoped to this browser/device and cloud reopen currently requires a connection.
The browser's unload guard protects unsaved cloud changes and active agent work.
+## Local development
+
+Use the real Cloud services for project creation and persistence. A static server
+cannot handle `/v1/workspaces` or sign-in. From `Editor/WebUI`, run
+`npm run dev:setup` once, then `npm run dev`. The isolated stack serves Studio at
+`http://localhost:8080/studio/`, normal Cloud sign-in at `/cloud`, and email codes
+at `http://127.0.0.1:28088/`. It keeps PostgreSQL, Redis, Kafka and mirrored S3
+objects in ignored local state. See [WebUI development](../WebUI/README.md) for
+prerequisites, the separate Player export, ports and restart checks.
+
## Export
Use Swift 6.3.2 with its `swift-6.3.2-RELEASE_wasm` SDK on Linux/macOS (WSL on
diff --git a/Editor/Package.swift b/Editor/Package.swift
index 361956577..cef2f1277 100644
--- a/Editor/Package.swift
+++ b/Editor/Package.swift
@@ -3,9 +3,11 @@ import Foundation
import PackageDescription
let adaEngineLocalPath = ProcessInfo.processInfo.environment["ADAENGINE_PACKAGE_PATH"] ?? ".."
+let editorPackageURL = URL(fileURLWithPath: #filePath).deletingLastPathComponent()
+let adjacentAdaMCP = editorPackageURL.appendingPathComponent("../../AdaMCP").standardizedFileURL
let adaMCPURL = ProcessInfo.processInfo.environment["ADA_MCP_LOCAL_PATH"].map {
- URL(fileURLWithPath: $0, relativeTo: URL(fileURLWithPath: #filePath).deletingLastPathComponent()).standardizedFileURL
-}
+ URL(fileURLWithPath: $0, relativeTo: editorPackageURL).standardizedFileURL
+} ?? (FileManager.default.fileExists(atPath: adjacentAdaMCP.appendingPathComponent("Package.swift").path) ? adjacentAdaMCP : nil)
let isAndroidStudio = ProcessInfo.processInfo.environment["ADAEDITOR_ANDROID"] == "1"
let sloppyRuntimeLocalPath = ProcessInfo.processInfo.environment["SLOPPY_RUNTIME_LOCAL_PATH"] ?? "../../Sloppy/Packages/SloppyRuntime"
let sloppyRuntimeURL = URL(fileURLWithPath: sloppyRuntimeLocalPath, relativeTo: URL(fileURLWithPath: #filePath).deletingLastPathComponent()).standardizedFileURL
diff --git a/Editor/Sources/AdaEditor/Agent/EditorAgentService.swift b/Editor/Sources/AdaEditor/Agent/EditorAgentService.swift
index 0bc1f3395..c27e0a223 100644
--- a/Editor/Sources/AdaEditor/Agent/EditorAgentService.swift
+++ b/Editor/Sources/AdaEditor/Agent/EditorAgentService.swift
@@ -1148,9 +1148,10 @@ enum EditorAgentPromptContext {
enum EditorAgentDefaultService {
static func make() -> any EditorAgentServicing {
#if os(visionOS) && canImport(SloppyRuntime)
- EditorVRRuntimeAgentService()
+ let external: any EditorAgentServicing = EditorVRRuntimeAgentService()
#else
- EditorACPAgentService()
+ let external: any EditorAgentServicing = EditorACPAgentService()
#endif
+ return EditorCloudAgentRouter(fallback: external)
}
}
diff --git a/Editor/Sources/AdaEditor/Agent/EditorCloudAgentRouter.swift b/Editor/Sources/AdaEditor/Agent/EditorCloudAgentRouter.swift
new file mode 100644
index 000000000..da96d7f10
--- /dev/null
+++ b/Editor/Sources/AdaEditor/Agent/EditorCloudAgentRouter.swift
@@ -0,0 +1,46 @@
+import Foundation
+
+/// Selecting Ada AI never launches or reconfigures a user's external ACP agent.
+actor EditorCloudAgentRouter: EditorAgentServicing {
+ static let command = "ada-cloud"
+ static let entry = EditorInstalledAgent(
+ id: "ada-cloud", name: "Ada AI", version: "Coder Next",
+ target: .init(command: command), managedDirectory: nil, icon: nil)
+ private let cloud = EditorCloudAgentService()
+ private let fallback: any EditorAgentServicing
+ private var cloudSessions: Set = []
+ init(fallback: any EditorAgentServicing) { self.fallback = fallback }
+ private func selected(_ request: EditorAgentRunRequest) -> any EditorAgentServicing {
+ let hosted = request.project.ai.agent.target.command == Self.command
+ if hosted { cloudSessions.insert(request.session.id) } else { cloudSessions.remove(request.session.id) }
+ return hosted ? cloud : fallback
+ }
+ func connect(
+ _ request: EditorAgentRunRequest, onEvent: @escaping @Sendable (EditorAgentEvent) async -> Void,
+ onProjectFileChanged: @escaping @Sendable (String) async -> Void
+ ) async throws -> EditorAgentSessionConfiguration {
+ try await selected(request).connect(request, onEvent: onEvent, onProjectFileChanged: onProjectFileChanged)
+ }
+ func send(
+ _ request: EditorAgentRunRequest, onEvent: @escaping @Sendable (EditorAgentEvent) async -> Void,
+ onProjectFileChanged: @escaping @Sendable (String) async -> Void
+ ) async throws -> EditorAgentRunResult {
+ try await selected(request).send(request, onEvent: onEvent, onProjectFileChanged: onProjectFileChanged)
+ }
+ func setConfiguration(sessionID: String, selectorID: String, valueID: String) async throws -> EditorAgentSessionConfiguration {
+ let service: any EditorAgentServicing = cloudSessions.contains(sessionID) ? cloud : fallback
+ return try await service.setConfiguration(sessionID: sessionID, selectorID: selectorID, valueID: valueID)
+ }
+ func resolvePermission(requestID: String, optionID: String?) async {
+ await cloud.resolvePermission(requestID: requestID, optionID: optionID)
+ await fallback.resolvePermission(requestID: requestID, optionID: optionID)
+ }
+ func cancel(sessionID: String) async {
+ if cloudSessions.contains(sessionID) { await cloud.cancel(sessionID: sessionID) } else { await fallback.cancel(sessionID: sessionID) }
+ }
+ func shutdown() async {
+ await cloud.shutdown()
+ await fallback.shutdown()
+ cloudSessions.removeAll()
+ }
+}
diff --git a/Editor/Sources/AdaEditor/Agent/EditorCloudAgentService.swift b/Editor/Sources/AdaEditor/Agent/EditorCloudAgentService.swift
new file mode 100644
index 000000000..4677cba7b
--- /dev/null
+++ b/Editor/Sources/AdaEditor/Agent/EditorCloudAgentService.swift
@@ -0,0 +1,190 @@
+import Foundation
+
+struct EditorCloudAgentTool: Sendable {
+ var name: String
+ var definition: EditorCloudValue
+ var readOnly: Bool
+ var invoke: @Sendable (EditorCloudValue) async throws -> String
+}
+
+/// The native tool loop uses the same server wallet and model as browser Studio.
+actor EditorCloudAgentService: EditorAgentServicing {
+ typealias TransportFactory = @Sendable () async throws -> EditorCloudAITransport
+ typealias ToolsFactory = @Sendable (URL) async throws -> [EditorCloudAgentTool]
+ private let transportFactory: TransportFactory
+ private let toolsFactory: ToolsFactory
+ private var turns: [String: Task] = [:]
+ private var permissions: [String: (String, CheckedContinuation)] = [:]
+
+ init(
+ transportFactory: @escaping TransportFactory = { try await EditorCloudAccount.shared.hostedAIGenerator() },
+ toolsFactory: @escaping ToolsFactory = { url in try await MainActor.run { try EditorCloudToolHost(projectURL: url).tools() } }
+ ) {
+ self.transportFactory = transportFactory
+ self.toolsFactory = toolsFactory
+ }
+ func connect(
+ _ request: EditorAgentRunRequest, onEvent: @escaping @Sendable (EditorAgentEvent) async -> Void,
+ onProjectFileChanged: @escaping @Sendable (String) async -> Void
+ ) async throws -> EditorAgentSessionConfiguration {
+ do { _ = try await transportFactory() } catch { throw await Self.localized(error) }
+ return .init(agentName: "Ada AI · Coder Next", selectors: [])
+ }
+ func send(
+ _ request: EditorAgentRunRequest, onEvent: @escaping @Sendable (EditorAgentEvent) async -> Void,
+ onProjectFileChanged: @escaping @Sendable (String) async -> Void
+ ) async throws -> EditorAgentRunResult {
+ guard turns[request.session.id] == nil else { throw EditorAgentServiceError.sessionUnavailable }
+ let task = Task { try await run(request, onEvent: onEvent, onProjectFileChanged: onProjectFileChanged) }
+ turns[request.session.id] = task
+ defer { turns.removeValue(forKey: request.session.id) }
+ do {
+ return try await withTaskCancellationHandler {
+ try await task.value
+ } onCancel: {
+ Task { await self.cancel(sessionID: request.session.id) }
+ }
+ } catch is CancellationError { throw CancellationError() } catch {
+ if task.isCancelled || Task.isCancelled { throw CancellationError() }
+ throw await Self.localized(error)
+ }
+ }
+ private static func localized(_ error: any Error) async -> EditorAgentServiceError {
+ let key = error.localizedDescription
+ return .providerFailure(await MainActor.run { L10n.tr(key) })
+ }
+ private func run(
+ _ request: EditorAgentRunRequest, onEvent: @escaping @Sendable (EditorAgentEvent) async -> Void,
+ onProjectFileChanged: @escaping @Sendable (String) async -> Void
+ ) async throws -> EditorAgentRunResult {
+ guard !request.attachments.contains(where: { $0.mimeType.hasPrefix("image/") }) else {
+ throw EditorAgentServiceError.providerFailure("Coder Next supports text. Choose a vision model to send images.")
+ }
+ let transport = try await transportFactory()
+ let planning = [.ask, .plan].contains(request.mode)
+ let tools = try await toolsFactory(request.projectURL).filter { !planning || ($0.readOnly && EditorCloudToolHost.planTools.contains($0.name)) }
+ guard tools.count <= 96, Set(tools.map(\.name)).count == tools.count else { throw EditorAIError.invalidResponse }
+ var messages = request.session.events.compactMap { event -> EditorCloudValue? in
+ guard let message = event.message, [.user, .assistant].contains(message.role) else { return nil }
+ return ["role": .string(message.role.rawValue), "content": .string(message.segments.compactMap(\.text).joined(separator: "\n"))]
+ }
+ if messages.last?["role"].string == "user" { messages.removeLast() }
+ messages.append(["role": "user", "content": .string(EditorAgentPromptContext.text(for: request))])
+ for _ in 0..<40 {
+ try Task.checkCancellation()
+ while messages.count > 2, try JSONEncoder().encode(messages).count > 90_000 {
+ messages.removeFirst()
+ while messages.first?["role"].string == "tool" { messages.removeFirst() }
+ }
+ let input: EditorCloudValue = [
+ "operationID": .string(UUID().uuidString), "providerID": "openrouter", "model": "qwen/qwen3-coder-next",
+ "billingMode": "ada", "mode": planning ? "plan" : "build", "tools": .array(tools.map(\.definition)),
+ "messages": .array(messages + [["role": "user", "content": .string("Current request: " + String(request.prompt.prefix(4000)))]]),
+ ]
+ guard try JSONEncoder().encode(input).count <= 180_000 else {
+ throw EditorAgentServiceError.providerFailure("The AI context is too large. Start a new conversation to continue.")
+ }
+ let response = Reply()
+ let messageID = UUID().uuidString
+ try await transport(input) { event in
+ try Task.checkCancellation()
+ if let text = try await response.receive(event) {
+ await onEvent(.init(id: messageID, kind: .message, message: .init(role: .assistant, segments: [.init(kind: .text, text: text)])))
+ }
+ }
+ try Task.checkCancellation()
+ let (text, calls) = try await response.result()
+ if calls.isEmpty {
+ return .init(
+ upstreamSessionID: request.session.id, assistantText: text, stopReason: "end_turn",
+ configuration: .init(agentName: "Ada AI · Coder Next", selectors: []))
+ }
+ messages.append(["role": "assistant", "content": .string(text), "toolCalls": .array(calls)])
+ for call in calls {
+ try Task.checkCancellation()
+ let id = call["id"].string ?? ""
+ let name = call["function"]["name"].string ?? ""
+ var toolEvent = EditorAgentEvent(
+ id: id, kind: .toolCall,
+ toolCall: .init(id: id, title: name.replacingOccurrences(of: "_", with: "."), kind: "tool", status: .inProgress, content: [], locations: []))
+ await onEvent(toolEvent)
+ let output: String
+ do {
+ guard let tool = tools.first(where: { $0.name == name }), let raw = call["function"]["arguments"].string,
+ case .object(let arguments) = try JSONDecoder().decode(EditorCloudValue.self, from: Data(raw.utf8))
+ else { throw EditorAIError.invalidResponse }
+ if !tool.readOnly {
+ let details: [String] = [name, request.project.project.name ?? request.projectURL.lastPathComponent, arguments["path"]?.string ?? ""]
+ let permissionSummary = details.joined(separator: "\n")
+ guard request.project.ai.agent.permissionMode != .deny,
+ await permission(session: request.session.id, toolID: id, name: permissionSummary, onEvent: onEvent)
+ else {
+ throw EditorAgentServiceError.providerFailure("The user declined this project change.")
+ }
+ }
+ try Task.checkCancellation()
+ output = try await tool.invoke(.object(arguments))
+ toolEvent.toolCall?.status = .completed
+ if !tool.readOnly, let path = arguments["path"]?.string { await onProjectFileChanged(path) }
+ } catch is CancellationError { throw CancellationError() } catch {
+ output = "Tool failed: " + error.localizedDescription
+ toolEvent.toolCall?.status = .failed
+ }
+ toolEvent.toolCall?.content = [.init(kind: .text, text: String(output.prefix(32_000)))]
+ await onEvent(toolEvent)
+ messages.append(["role": "tool", "toolCallID": .string(id), "content": .string(String(output.prefix(32_000)))])
+ }
+ }
+ throw EditorAgentServiceError.providerFailure("The agent reached its step limit. Continue from the saved project.")
+ }
+ private func permission(
+ session: String, toolID: String, name: String,
+ onEvent: @escaping @Sendable (EditorAgentEvent) async -> Void
+ ) async -> Bool {
+ let id = UUID().uuidString
+ let event = EditorAgentEvent(
+ kind: .permission,
+ permission: .init(
+ id: id, summary: name, toolCallID: toolID,
+ options: [.init(id: "allow", name: "Allow once", kind: "allow_once"), .init(id: "deny", name: "Reject", kind: "reject_once")], state: .pending, selectedOptionID: nil))
+ return await withCheckedContinuation { continuation in
+ permissions[id] = (session, continuation)
+ Task { await onEvent(event) }
+ }
+ }
+ func resolvePermission(requestID: String, optionID: String?) async {
+ permissions.removeValue(forKey: requestID)?.1.resume(returning: optionID == "allow")
+ }
+ func setConfiguration(sessionID: String, selectorID: String, valueID: String) async throws -> EditorAgentSessionConfiguration {
+ .init(agentName: "Ada AI · Coder Next", selectors: [])
+ }
+ func cancel(sessionID: String) async {
+ turns[sessionID]?.cancel()
+ for id in permissions.keys.filter({ permissions[$0]?.0 == sessionID }) { await resolvePermission(requestID: id, optionID: nil) }
+ }
+ func shutdown() async { for id in Array(turns.keys) { await cancel(sessionID: id) } }
+
+ private actor Reply {
+ var text = "", calls: [EditorCloudValue] = [], complete = false
+ func receive(_ event: EditorCloudValue) throws -> String? {
+ switch event["type"].string {
+ case "text":
+ text += event["text"].string ?? ""
+ guard text.utf8.count <= 512 * 1024 else { throw EditorAIError.invalidResponse }
+ return text
+ case "tool":
+ guard calls.count < 32 else { throw EditorAIError.invalidResponse }
+ calls.append(event["call"])
+ case "complete": complete = true
+ case "usage": break
+ default: throw EditorAIError.invalidResponse
+ }
+ return nil
+ }
+ func result() throws -> (String, [EditorCloudValue]) {
+ let ids = calls.compactMap { $0["id"].string }
+ guard complete, ids.count == calls.count, Set(ids).count == ids.count, ids.allSatisfy({ !$0.isEmpty }) else { throw EditorAIError.invalidResponse }
+ return (text, calls)
+ }
+ }
+}
diff --git a/Editor/Sources/AdaEditor/Agent/EditorCloudToolHost.swift b/Editor/Sources/AdaEditor/Agent/EditorCloudToolHost.swift
new file mode 100644
index 000000000..92d7eb756
--- /dev/null
+++ b/Editor/Sources/AdaEditor/Agent/EditorCloudToolHost.swift
@@ -0,0 +1,164 @@
+import Foundation
+import MCP
+
+/// Reuses the native editor's tool implementations, with project ownership and dirty-document guards.
+@MainActor
+final class EditorCloudToolHost {
+ nonisolated static let planTools: Set = [
+ "editor_files_list", "editor_file_read", "editor_project_context", "editor_scene_list", "editor_scene_list_open", "editor_scene_get", "editor_asset_list",
+ "editor_gravity_diagnostics", "editor_gravity_completion", "editor_gravity_hover", "editor_gravity_definition", "editor_skills_list", "editor_skills_read", "editor_docs_read",
+ "editor_docs_search", "editor_api_describe", "editor_components_describe", "editor_examples_list", "editor_examples_read", "editor_output_read", "editor_task_status",
+ "editor_runtime_inspect", "editor_play_inspect", "editor_model_inspect", "editor_audio_inspect",
+ ]
+ let projectURL: URL
+ private let mobile: EditorMobileAgentToolService
+ private let isMobile: Bool
+
+ init(projectURL: URL, mobile: EditorMobileAgentToolService? = nil) {
+ self.projectURL = projectURL.resolvingSymlinksInPath().standardizedFileURL
+ self.mobile = mobile ?? EditorMobileAgentToolService(projectURL: projectURL)
+ self.isMobile = mobile != nil
+ }
+ deinit { Task { @MainActor [mobile] in mobile.finish() } }
+ func tools() throws -> [EditorCloudAgentTool] {
+ let inventory =
+ isMobile
+ ? EditorMobileAgentTools.tools() + [MobileEditorTool.build]
+ : EditorAgentMCPTools.tools() + EditorAgentWorkspaceMCPTools.tools() + EditorAgentGravityMCPTools.tools() + EditorAgentAuthoringMCPTools.tools()
+ return try (Self.fileTools + inventory).filter {
+ // Publishing and provider-backed image operations require their own explicit flows.
+ !["editor.community.submit", "editor.image.read", "editor.image.generate", "editor.image.edit"].contains($0.name)
+ }.map { tool in
+ let toolName = tool.name
+ let name = tool.name.replacingOccurrences(of: ".", with: "_")
+ let schema = try JSONDecoder().decode(EditorCloudValue.self, from: JSONEncoder().encode(tool.inputSchema))
+ return EditorCloudAgentTool(
+ name: name,
+ definition: ["type": "function", "function": ["name": .string(name), "description": .string(tool.description ?? ""), "parameters": schema]],
+ readOnly: tool.annotations?.readOnlyHint == true,
+ invoke: { [self] arguments in try await invoke(toolName, arguments: arguments) })
+ }
+ }
+ private func invoke(_ name: String, arguments: EditorCloudValue) async throws -> String {
+ try Task.checkCancellation()
+ let args = try JSONDecoder().decode([String: Value].self, from: JSONEncoder().encode(arguments))
+ if name == "editor.files.list" { return try listFiles() }
+ if ["editor.file.read", "editor.file.write", "editor.file.patch"].contains(name) { return try file(name, args) }
+ if isMobile {
+ let result: EditorMobileAgentToolResult
+ if name == "editor.build.start" { result = mobile.build() } else { result = await mobile.handle(name: name, arguments: args) }
+ guard result.ok else { throw EditorAgentServiceError.providerFailure(result.payload) }
+ return result.payload
+ }
+ try requireDesktopProject()
+ let result: CallTool.Result?
+ if let value = EditorAgentMCPTools.shared.handle(name: name, arguments: args) {
+ result = value
+ } else if let value = EditorAgentWorkspaceMCPTools.shared.handle(name: name, arguments: args) {
+ result = value
+ } else if let value = await EditorAgentWorkspaceMCPTools.shared.handleAsync(name: name, arguments: args) {
+ result = value
+ } else if let value = await EditorAgentGravityMCPTools.shared.handle(name: name, arguments: args) {
+ result = value
+ } else {
+ result = await EditorAgentAuthoringMCPTools.handle(name: name, arguments: args)
+ }
+ guard let result else { throw EditorAIError.invalidResponse }
+ let text = String(decoding: try JSONEncoder().encode(result), as: UTF8.self)
+ guard result.isError != true else { throw EditorAgentServiceError.providerFailure(text) }
+ return text
+ }
+ private func requireDesktopProject() throws {
+ guard EditorAgentMCPTools.shared.activeViewModel?.projectURL?.resolvingSymlinksInPath().standardizedFileURL == projectURL else {
+ throw EditorAgentServiceError.providerFailure("A different project is open. Return to this project before continuing.")
+ }
+ }
+ private func resolve(_ path: String) throws -> URL {
+ let parts = path.split(separator: "/", omittingEmptySubsequences: false)
+ let roots: Set = ["Sources", "Scenes", "Assets", "Tests", "Docs", "Documentation"]
+ let notes: Set = ["AGENTS.md", "DESIGN.md", "PLAN.md", "PROGRESS.md", "README.md"]
+ guard !path.isEmpty, path.utf8.count <= 512, !path.contains("\\"), !path.contains(":"), !path.unicodeScalars.contains(where: { $0.value < 32 }),
+ parts.allSatisfy({ !$0.isEmpty && $0 != "." && $0 != ".." }),
+ roots.contains(String(parts[0])) || notes.contains(path) || path.hasPrefix(".ada/skills/")
+ else {
+ throw EditorAgentServiceError.pathOutsideProject(path)
+ }
+ // Resolving the full URL alone does not follow an existing parent link when
+ // the final file does not exist yet. Reject links component by component.
+ var result = projectURL
+ for part in parts {
+ result.appendPathComponent(String(part))
+ guard (try? FileManager.default.destinationOfSymbolicLink(atPath: result.path)) == nil else {
+ throw EditorAgentServiceError.pathOutsideProject(path)
+ }
+ }
+ result = result.standardizedFileURL
+ guard result.path.hasPrefix(projectURL.path + "/"), !result.pathComponents.contains(".git") else { throw EditorAgentServiceError.pathOutsideProject(path) }
+ return result
+ }
+ private func file(_ name: String, _ args: [String: Value]) throws -> String {
+ guard let path = args["path"]?.stringValue else { throw EditorAIError.invalidResponse }
+ let url = try resolve(path)
+ if !isMobile {
+ try requireDesktopProject()
+ if EditorAgentMCPTools.shared.activeViewModel?.workbench.openDocuments.contains(where: { $0.relativePath == path && $0.isDirty }) == true {
+ throw EditorAgentServiceError.providerFailure("Save the open document before changing it with Ada AI.")
+ }
+ }
+ let extensions: Set = ["ada", "ascn", "ui", "md", "txt", "json", "glsl", "swift"]
+ guard extensions.contains(url.pathExtension.lowercased()) else { throw EditorAIError.invalidResponse }
+ if name == "editor.file.read" {
+ guard try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0 <= 128_000 else { throw EditorAIError.invalidResponse }
+ return try String(contentsOf: url, encoding: .utf8)
+ }
+ let text: String
+ if name == "editor.file.patch" {
+ let old = try String(contentsOf: url, encoding: .utf8)
+ guard let match = args["oldText"]?.stringValue, !match.isEmpty, old.components(separatedBy: match).count == 2,
+ let replacement = args["newText"]?.stringValue
+ else { throw EditorAIError.invalidResponse }
+ text = old.replacingOccurrences(of: match, with: replacement)
+ } else {
+ guard !FileManager.default.fileExists(atPath: url.path), let value = args["content"]?.stringValue else {
+ throw EditorAgentServiceError.providerFailure("Use editor.file.patch to change an existing file.")
+ }
+ text = value
+ }
+ guard text.utf8.count <= 512_000 else { throw EditorAIError.invalidResponse }
+ try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
+ try text.write(to: url, atomically: true, encoding: .utf8)
+ return "Saved " + path
+ }
+ private func listFiles() throws -> String {
+ guard let entries = FileManager.default.enumerator(at: projectURL, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles]) else {
+ throw EditorAIError.invalidResponse
+ }
+ var result: [String] = []
+ for case let url as URL in entries {
+ let path = String(url.path.dropFirst(projectURL.path.count + 1))
+ if (try? resolve(path)) == nil {
+ entries.skipDescendants()
+ continue
+ }
+ if (try? url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true { result.append(path) }
+ if result.count >= 512 { break }
+ }
+ return result.sorted().joined(separator: "\n")
+ }
+ private static var fileTools: [Tool] {
+ let path: Value = .object(["type": "string"])
+ let text: Value = .object(["type": "string"])
+ return [
+ EditorMobileAgentTools.tool("editor.files.list", "List project source, scene and asset paths."),
+ EditorMobileAgentTools.tool("editor.file.read", "Read a project text file before changing it.", ["path": path], ["path"]),
+ EditorMobileAgentTools.tool(
+ "editor.file.write", "Create a NEW project text file. Use patch for existing files.", ["path": path, "content": text], ["path", "content"], readOnly: false),
+ EditorMobileAgentTools.tool(
+ "editor.file.patch", "Replace exactly one occurrence in an existing text file. Read it first.", ["path": path, "oldText": text, "newText": text],
+ ["path", "oldText", "newText"], readOnly: false),
+ ]
+ }
+ private enum MobileEditorTool {
+ static var build: Tool { EditorMobileAgentTools.tool("editor.build.start", "Compile AdaScript and validate project scenes.", readOnly: false) }
+ }
+}
diff --git a/Editor/Sources/AdaEditor/Assets/Localization/en.json b/Editor/Sources/AdaEditor/Assets/Localization/en.json
index 9b80a0dc8..3e52a09b4 100644
--- a/Editor/Sources/AdaEditor/Assets/Localization/en.json
+++ b/Editor/Sources/AdaEditor/Assets/Localization/en.json
@@ -1,4 +1,97 @@
{
+ " · open": " · open",
+ "2D game": "2D game",
+ "3D world": "3D world",
+ "A GAME BY THE COMMUNITY": "A GAME BY THE COMMUNITY",
+ "A new world, made with Ada.": "A new world, made with Ada.",
+ "A simple idea can become a great game": "A simple idea can become a great game",
+ "About this game": "About this game",
+ "Account": "Account",
+ "Ada Playground home": "Ada Playground home",
+ "Agent settings": "Agent settings",
+ "All changes saved": "All changes saved",
+ "All games": "All games",
+ "Browser": "Browser",
+ "Browser Studio is not enabled on this server.": "Browser Studio is not enabled on this server.",
+ "Catalog order": "Catalog order",
+ "Clear filters": "Clear filters",
+ "Continue working": "Continue working",
+ "Could not update your like. Try again.": "Could not update your like. Try again.",
+ "Create a game": "Create a game",
+ "Create your first project or upload one.": "Create your first project or upload one.",
+ "Create. Share. Play.": "Create. Share. Play.",
+ "Creator {0}": "Creator {0}",
+ "DISCOVER SOMETHING DIFFERENT": "DISCOVER SOMETHING DIFFERENT",
+ "Dismiss message": "Dismiss message",
+ "Download project": "Download project",
+ "Everything you create belongs to you": "Everything you create belongs to you",
+ "Explore a game made with Ada.": "Explore a game made with Ada.",
+ "Explore what people are making. Make something of your own.": "Explore what people are making. Make something of your own.",
+ "FROM PLAYER TO CREATOR": "FROM PLAYER TO CREATOR",
+ "Filter games by platform or tag": "Filter games by platform or tag",
+ "Find your next game": "Find your next game",
+ "Games": "Games",
+ "In Ada Studio": "In Ada Studio",
+ "In your browser": "In your browser",
+ "Inside the game": "Inside the game",
+ "Light, space and new adventures": "Light, space and new adventures",
+ "Like": "Like",
+ "Loading Studio…": "Loading Studio…",
+ "Loading games…": "Loading games…",
+ "Loading game…": "Loading game…",
+ "Make your first game": "Make your first game",
+ "Most liked": "Most liked",
+ "My projects": "My projects",
+ "No games match your search.": "No games match your search.",
+ "OPEN PROJECT": "OPEN PROJECT",
+ "Offline": "Offline",
+ "Open in Ada Studio": "Open in Ada Studio",
+ "Play now": "Play now",
+ "Playground navigation": "Playground navigation",
+ "Project overview": "Project overview",
+ "Project sections": "Project sections",
+ "Project title": "Project title",
+ "Published games will appear here. Create the first one.": "Published games will appear here. Create the first one.",
+ "Recent projects": "Recent projects",
+ "Remove like": "Remove like",
+ "Saved": "Saved",
+ "Saving": "Saving",
+ "Saving…": "Saving…",
+ "Scroll tags left": "Scroll tags left",
+ "Scroll tags right": "Scroll tags right",
+ "Search games": "Search games",
+ "Search titles, creators, or tags": "Search titles, creators, or tags",
+ "Sign in to like": "Sign in to like",
+ "Sign in to save": "Sign in to save",
+ "Sort by": "Sort by",
+ "Start from scratch": "Start from scratch",
+ "Start with an idea. Build the rest together with your assistant.": "Start with an idea. Build the rest together with your assistant.",
+ "The cloud is unavailable. You can save a local copy before leaving.": "The cloud is unavailable. You can save a local copy before leaving.",
+ "The creator has not added a description yet.": "The creator has not added a description yet.",
+ "The game catalog is temporarily unavailable.": "The game catalog is temporarily unavailable.",
+ "The next world could be yours.": "The next world could be yours.",
+ "This game is unavailable.": "This game is unavailable.",
+ "This release is no longer available.": "This release is no longer available.",
+ "Title A–Z": "Title A–Z",
+ "Try another title or tag.": "Try another title or tag.",
+ "Upload project": "Upload project",
+ "Version history": "Version history",
+ "Version {0}": "Version {0}",
+ "WHERE GAMES BEGIN": "WHERE GAMES BEGIN",
+ "What will you create today?": "What will you create today?",
+ "What will you play today?": "What will you play today?",
+ "YOUR NEXT LITTLE ADVENTURE": "YOUR NEXT LITTLE ADVENTURE",
+ "Your idea belongs here.": "Your idea belongs here.",
+ "Your ideas. Your worlds.": "Your ideas. Your worlds.",
+ "Your project “{0}” stays open. Retry saving or save a local copy before returning to Playground.": "Your project “{0}” stays open. Retry saving or save a local copy before returning to Playground.",
+ "Your scene, code and edit history are right where you left them.": "Your scene, code and edit history are right where you left them.",
+ "Your working projects are private": "Your working projects are private",
+ "Your workspace": "Your workspace",
+ "Your worlds will appear here": "Your worlds will appear here",
+ "likes": "likes",
+ "{0} games": "{0} games",
+ "{0} likes": "{0} likes",
+ "{0} — gameplay screenshot {1}": "{0} — gameplay screenshot {1}",
"Continue your message": "Continue your message",
"What next?": "What next?",
"Dismiss suggestions": "Dismiss suggestions",
@@ -1656,6 +1749,618 @@
"Edit annotation #{0}: {1}": "Edit annotation #{0}: {1}",
"Ready to send together: {0}": "Ready to send together: {0}",
"This note was edited in the composer. Edit its text there or remove it before replacing it.": "This note was edited in the composer. Edit its text there or remove it before replacing it.",
+ "Local Codex": "Local Codex",
+ "Uses the Codex CLI sign-in on this computer. No API key is needed.": "Uses the Codex CLI sign-in on this computer. No API key is needed.",
+ "Local Codex request failed. Check the development server log.": "Local Codex request failed. Check the development server log.",
+ "AI credit balance is unavailable. Please retry.": "AI credit balance is unavailable. Please retry.",
+ "Ada AI is not available on this server yet.": "Ada AI is not available on this server yet.",
+ "Ada AI is unavailable for this account.": "Ada AI is unavailable for this account.",
+ "Cloud Pro includes 500 AI credits per month. Subscribe to use Ada AI.": "Cloud Pro includes 500 AI credits per month. Subscribe to use Ada AI.",
+ "Your AI credits are used up. They renew with your monthly Cloud Pro allowance.": "Your AI credits are used up. They renew with your monthly Cloud Pro allowance.",
+ "AI credits: {0} / {1}": "AI credits: {0} / {1}",
+ "AI credits —": "AI credits —",
+ "Cloud Pro includes 500 AI credits per month, shared across your devices.": "Cloud Pro includes 500 AI credits per month, shared across your devices.",
+ "Used: {0} · Reserved: {1}": "Used: {0} · Reserved: {1}",
+ "Credits renew: {0}": "Credits renew: {0}",
+ "Refresh AI credits": "Refresh AI credits",
+ "Show console": "Show console",
+ "Hide console": "Hide console",
+ "Runtime console": "Runtime console",
+ "Clear console": "Clear console",
+ "AdaPlayer logs": "AdaPlayer logs",
+ "Waiting for AdaPlayer logs…": "Waiting for AdaPlayer logs…",
+ "No logs yet. Run the game to see output.": "No logs yet. Run the game to see output.",
+ "Conversation settings": "Conversation settings",
+ "Scene panels": "Scene panels",
+ "Expand assistant": "Expand assistant",
+ "Playing your current version": "Playing your current version",
+ "Right mouse + WASD — camera · Wheel — zoom · F — focus": "Right mouse + WASD — camera · Wheel — zoom · F — focus",
+ "W / E / R — gizmos": "W / E / R — gizmos",
+ "Scene objects": "Scene objects",
+ "Expand objects": "Expand objects",
+ "Collapse objects": "Collapse objects",
+ "Search objects": "Search objects",
+ "Find in scene": "Find in scene",
+ "Add object": "Add object",
+ "Objects": "Objects",
+ "Changes are saved automatically": "Changes are saved automatically",
+ "Generation interrupted. Your saved project is available; check your AI credits before continuing.": "Generation interrupted. Your saved project is available; check your AI credits before continuing.",
+ "Coder Next supports text. Choose a vision model to send images.": "Coder Next supports text. Choose a vision model to send images.",
+ "The AI context is too large. Start a new conversation to continue.": "The AI context is too large. Start a new conversation to continue.",
+ "The user declined this project change.": "The user declined this project change.",
+ "The agent reached its step limit. Continue from the saved project.": "The agent reached its step limit. Continue from the saved project.",
+ "Not enough AI credits for this request. Check your balance in Cloud settings.": "Not enough AI credits for this request. Check your balance in Cloud settings.",
+ "Too many AI requests. Please wait and try again.": "Too many AI requests. Please wait and try again.",
+ "A different project is open. Return to this project before continuing.": "A different project is open. Return to this project before continuing.",
+ "Save the open document before changing it with Ada AI.": "Save the open document before changing it with Ada AI.",
+ "Use editor.file.patch to change an existing file.": "Use editor.file.patch to change an existing file.",
+ "Commands": "Commands",
+ "Commands are unavailable. Try typing / again.": "Commands are unavailable. Try typing / again.",
+ "Loading commands…": "Loading commands…",
+ "No matching commands": "No matching commands",
+ "↑↓ Navigate · Enter Select · Esc Close": "↑↓ Navigate · Enter Select · Esc Close",
+ "Ada AI · Coder Next": "Ada AI · Coder Next",
+ "Use Ada AI": "Use Ada AI",
+ "Allow project change?": "Allow project change?",
+ "Desktop view": "Desktop view",
+ "Mobile view": "Mobile view",
+ "Local movement axes": "Local movement axes",
+ "Snap: 0.5 / 10 in 2D, 15°, scale 0.1 (Ctrl)": "Snap: 0.5 / 10 in 2D, 15°, scale 0.1 (Ctrl)",
+ "Focus selection (F)": "Focus selection (F)",
+ "Reset to game camera": "Reset to game camera",
+ "Undo edit": "Undo edit",
+ "Redo edit": "Redo edit",
+ "Project assets": "Project assets",
+ "Scene controls": "Scene controls",
+ "Close scene help": "Close scene help",
+ "Selection": "Selection",
+ "Click an object": "Click an object",
+ "Gizmos": "Gizmos",
+ "W · move, E · rotate, R · scale": "W · move, E · rotate, R · scale",
+ "Free camera": "Free camera",
+ "Right mouse + drag, WASD / QE, Shift for speed": "Right mouse + drag, WASD / QE, Shift for speed",
+ "Orbit selection": "Orbit selection",
+ "Alt + left drag": "Alt + left drag",
+ "Middle drag or Q + drag": "Middle drag or Q + drag",
+ "Mouse wheel": "Mouse wheel",
+ "Focus": "Focus",
+ "F or double-click": "F or double-click",
+ "Snapping": "Snapping",
+ "Ctrl while dragging a gizmo": "Ctrl while dragging a gizmo",
+ "Cancel gesture": "Cancel gesture",
+ "Duplicate / delete": "Duplicate / delete",
+ "Ctrl F · search, Tab · indent, Ctrl Z · undo": "Ctrl F · search, Tab · indent, Ctrl Z · undo",
+ "Could not update the scene": "Could not update the scene",
+ "Starting AdaEngine…": "Starting AdaEngine…",
+ "AdaEngine renders the scene and game. The editor is ready to use.": "AdaEngine renders the scene and game. The editor is ready to use.",
+ "Retry loading": "Retry loading",
+ "AdaEngine · Updating scene…": "AdaEngine · Updating scene…",
+ "AdaEngine · Scene error": "AdaEngine · Scene error",
+ "AdaEngine · Starting…": "AdaEngine · Starting…",
+ "AdaEngine · Game running": "AdaEngine · Game running",
+ "AdaEngine · Scene editor": "AdaEngine · Scene editor",
+ "Return to panels (Esc)": "Return to panels (Esc)",
+ "Expand scene": "Expand scene",
+ "Camera / pan (Q)": "Camera / pan (Q)",
+ "Move (W)": "Move (W)",
+ "Rotate (E)": "Rotate (E)",
+ "Scale (R)": "Scale (R)",
+ "Scene update cancelled": "Scene update cancelled",
+ "A WebGPU browser is required for the scene. Panels and code remain available.": "A WebGPU browser is required for the scene. Panels and code remain available.",
+ "The scene viewport is not ready yet.": "The scene viewport is not ready yet.",
+ "The scene requires a separate secure host.": "The scene requires a separate secure host.",
+ "AdaEngine — scene and game preview": "AdaEngine — scene and game preview",
+ "AdaPlayer did not respond": "AdaPlayer did not respond",
+ "Could not open AdaPlayer": "Could not open AdaPlayer",
+ "The scene has not loaded yet.": "The scene has not loaded yet.",
+ "AdaPlayer is not responding": "AdaPlayer is not responding",
+ "Loading your workspace…": "Loading your workspace…",
+ "Explore games": "Explore games",
+ "Sign in to create and save your own worlds.": "Sign in to create and save your own worlds.",
+ "We could not load your workspace. Check your connection and try again.": "We could not load your workspace. Check your connection and try again.",
+ "Your workspace is on its way": "Your workspace is on its way",
+ "Let’s get you back to your worlds": "Let’s get you back to your worlds",
+ "Your first world starts here": "Your first world starts here",
+ "A home for your worlds": "A home for your worlds",
+ "Loading your projects…": "Loading your projects…",
+ "Start a new 2D or 3D game, or bring a project you have already started.": "Start a new 2D or 3D game, or bring a project you have already started.",
+ "Sign in to keep your projects together, save your progress and continue creating whenever inspiration strikes.": "Sign in to keep your projects together, save your progress and continue creating whenever inspiration strikes.",
+ "Create your first world": "Create your first world",
+ "Pick up where you left off, or start a new world.": "Pick up where you left off, or start a new world.",
+ "Start with an idea": "Start with an idea",
+ "Describe a game or choose a 2D or 3D world.": "Describe a game or choose a 2D or 3D world.",
+ "Make it yours": "Make it yours",
+ "Shape scenes, edit code and build with your assistant.": "Shape scenes, edit code and build with your assistant.",
+ "Pick up where you left off": "Pick up where you left off",
+ "Keep your projects in your account, ready for your next idea.": "Keep your projects in your account, ready for your next idea.",
+ "Use Ada AI with Cloud Pro, Codex, or your own API key": "Use Ada AI with Cloud Pro, Codex, or your own API key",
+ "Sign in to Ada Cloud to see AI credits.": "Sign in to Ada Cloud to see AI credits.",
+ "Checking AI credits…": "Checking AI credits…",
+ "AI credits are unavailable on this server.": "AI credits are unavailable on this server.",
+ "Couldn’t refresh AI credits. Please retry.": "Couldn’t refresh AI credits. Please retry.",
+ "This account is closed. AI spending is disabled.": "This account is closed. AI spending is disabled.",
+ "AI spending is suspended for this account.": "AI spending is suspended for this account.",
+ "Hosted AI will open soon.": "Hosted AI will open soon.",
+ "Hosted AI is unavailable in your region.": "Hosted AI is unavailable in your region.",
+ "AI credits are shared across your Ada Studio apps.": "AI credits are shared across your Ada Studio apps.",
+ "Credits…": "Credits…",
+ "Credits —": "Credits —",
+ "Couldn’t load credit activity. Please retry.": "Couldn’t load credit activity. Please retry.",
+ "Allowance adjustment": "Allowance adjustment",
+ "Reserved": "Reserved",
+ "Used": "Used",
+ "Released": "Released",
+ "Credit adjustment": "Credit adjustment",
+ "Publishing": "Publishing",
+ "Report this game": "Report this game",
+ "Send report": "Send report",
+ "Report sent": "Report sent",
+ "Close window": "Close window",
+ "Your assistant": "Your assistant",
+ "Interface language": "Interface language",
+ "Choose who will help you create your game.": "Choose who will help you create your game.",
+ "Your API key": "Your API key",
+ "{0} — no tools": "{0} — no tools",
+ "Selected provider API key": "Selected provider API key",
+ "The key stays in this tab’s memory and is not included in the project.": "The key stays in this tab’s memory and is not included in the project.",
+ "Your world’s history": "Your world’s history",
+ "Return to any checkpoint. Restoring creates a new version.": "Return to any checkpoint. Restoring creates a new version.",
+ "Save checkpoint": "Save checkpoint",
+ "Could not save the project to Cloud": "Could not save the project to Cloud",
+ "The local copy is available only on this device. Studio will restore it the next time you open the project. Download a ZIP for a separate backup.": "The local copy is available only on this device. Studio will restore it the next time you open the project. Download a ZIP for a separate backup.",
+ "Stay in project": "Stay in project",
+ "Download ZIP": "Download ZIP",
+ "Retry saving": "Retry saving",
+ "Save locally and leave": "Save locally and leave",
+ "Open test inbox": "Open test inbox",
+ "The 40-step limit was reached. Continue from the saved result.": "The 40-step limit was reached. Continue from the saved result.",
+ "File not found": "File not found",
+ "The file is too large for text context": "The file is too large for text context",
+ "Object not found": "Object not found",
+ "This model does not support images": "This model does not support images",
+ "This card is unavailable": "This card is unavailable",
+ "Discuss mode does not allow project changes": "Discuss mode does not allow project changes",
+ "The user declined the tool": "The user declined the tool",
+ "Stop the assistant before switching projects": "Stop the assistant before switching projects",
+ "The project snapshot is unavailable": "The project snapshot is unavailable",
+ "Unsaved changes were recovered as a separate project": "Unsaved changes were recovered as a separate project",
+ "Stop the assistant before returning to projects": "Stop the assistant before returning to projects",
+ "The project changed in another tab. Your edits were saved as a separate copy.": "The project changed in another tab. Your edits were saved as a separate copy.",
+ "Invalid transform": "Invalid transform",
+ "New object": "New object",
+ "The text attachment exceeds 100 KB": "The text attachment exceeds 100 KB",
+ "Stop the assistant before restoring a version": "Stop the assistant before restoring a version",
+ "Invalid API address": "Invalid API address",
+ "The provider response is too large": "The provider response is too large",
+ "Unsupported scene format": "Unsupported scene format",
+ "Duplicate entity IDs": "Duplicate entity IDs",
+ "Invalid scene entity": "Invalid scene entity",
+ "Invalid hierarchy": "Invalid hierarchy",
+ "Invalid project": "Invalid project",
+ "The project exceeds 64 MB": "The project exceeds 64 MB",
+ "An AdaScript project with a scene is required": "An AdaScript project with a scene is required",
+ "Use the Sources and Assets folders inside the project": "Use the Sources and Assets folders inside the project",
+ "Chat history exceeds the project limit": "Chat history exceeds the project limit",
+ "The archive exceeds 65 MB": "The archive exceeds 65 MB",
+ "Unsafe ZIP path or size": "Unsafe ZIP path or size",
+ "The extracted project exceeds 64 MB": "The extracted project exceeds 64 MB",
+ "Imported project": "Imported project",
+ "Invalid ZIP archive": "Invalid ZIP archive",
+ "Unsupported ZIP structure": "Unsupported ZIP structure",
+ "Unsupported ZIP compression": "Unsupported ZIP compression",
+ "Duplicate ZIP path": "Duplicate ZIP path",
+ "Valley of winds": "Valley of winds",
+ "New 2D game": "New 2D game",
+ "Sun": "Sun",
+ "Meadow": "Meadow",
+ "Invalid file: {0}": "Invalid file: {0}",
+ "Invalid ZIP archive: {0}": "Invalid ZIP archive: {0}",
+ "Pine {0}": "Pine {0}",
+ "Foliage {0}": "Foliage {0}",
+ "Rock {0}": "Rock {0}",
+ "Server error: {0}": "Server error: {0}",
+ "Unavailable tool: {0}": "Unavailable tool: {0}",
+ "{0} — recovered": "{0} — recovered",
+ "{0} — copy": "{0} — copy",
+ "Hide inspector": "Hide inspector",
+ "Focus selected object": "Focus selected object",
+ "Duplicate object": "Duplicate object",
+ "Delete object": "Delete object",
+ "The selected object is included in the assistant’s context.": "The selected object is included in the assistant’s context.",
+ "Render order": "Render order",
+ "Cast shadows": "Cast shadows",
+ "Receive shadows": "Receive shadows",
+ "Volume": "Volume",
+ "Autoplay": "Autoplay",
+ "{0}, {1} axis": "{0}, {1} axis",
+ "Validate in AdaPlayer": "Validate in AdaPlayer",
+ "Project text": "Project text",
+ "Upload asset": "Upload asset",
+ "Close assets": "Close assets",
+ "UTF-8 · {0} lines": "UTF-8 · {0} lines",
+ "Use {0}": "Use {0}",
+ "{0} KB": "{0} KB",
+ "AdaEngine scene controls": "AdaEngine scene controls",
+ "YOUR PROJECT": "YOUR PROJECT",
+ "{0} objects · camera, gizmos and inspector": "{0} objects · camera, gizmos and inspector",
+ "{0} files · models, images and audio": "{0} files · models, images and audio",
+ "{0} AdaScript files · your game’s behavior": "{0} AdaScript files · your game’s behavior",
+ "Chat, project context and agent changes": "Chat, project context and agent changes",
+ "Scene, assets, code and chat are parts of one game. Switch between them on the left.": "Scene, assets, code and chat are parts of one game. Switch between them on the left.",
+ "Project saved in Ada Cloud": "Project saved in Ada Cloud",
+ "Saving changes…": "Saving changes…",
+ "Changes have not been saved to Cloud yet": "Changes have not been saved to Cloud yet",
+ "Private project · revision {0}. Autosave works in every section.": "Private project · revision {0}. Autosave works in every section.",
+ "Save now": "Save now",
+ "Continue where you left off": "Continue where you left off",
+ "Find your project in All projects. ZIP includes sources and assets so you can reopen it.": "Find your project in All projects. ZIP includes sources and assets so you can reopen it.",
+ "Expected absolute JSON Pointer": "Expected absolute JSON Pointer",
+ "Unsafe data binding": "Unsafe data binding",
+ "Cannot delete root": "Cannot delete root",
+ "Binding parent is not a container": "Binding parent is not a container",
+ "Invalid array index": "Invalid array index",
+ "Action nesting exceeds limit": "Action nesting exceeds limit",
+ "Unsafe context key": "Unsafe context key",
+ "Input requires a writable binding": "Input requires a writable binding",
+ "Text exceeds limit": "Text exceeds limit",
+ "Text binding requires a string": "Text binding requires a string",
+ "Invalid or oversized data model": "Invalid or oversized data model",
+ "Incompatible binding types": "Incompatible binding types",
+ "Too many components": "Too many components",
+ "Unsupported component": "Unsupported component",
+ "Unsupported or missing component property": "Unsupported or missing component property",
+ "Invalid placeholder": "Invalid placeholder",
+ "Wrong input binding type": "Wrong input binding type",
+ "Invalid choices": "Invalid choices",
+ "Invalid slider range": "Invalid slider range",
+ "Invalid children": "Invalid children",
+ "Invalid spacing": "Invalid spacing",
+ "Only server event actions are supported": "Only server event actions are supported",
+ "Invalid form checks": "Invalid form checks",
+ "Unsupported check": "Unsupported check",
+ "Invalid check arguments": "Invalid check arguments",
+ "Invalid check range": "Invalid check range",
+ "Cyclic or repeated component mount": "Cyclic or repeated component mount",
+ "Unsupported A2UI envelope": "Unsupported A2UI envelope",
+ "One A2UI message per envelope required": "One A2UI message per envelope required",
+ "Invalid surface ID": "Invalid surface ID",
+ "Surface belongs to another agent": "Surface belongs to another agent",
+ "Unsupported surface or catalog": "Unsupported surface or catalog",
+ "Create the surface before updating": "Create the surface before updating",
+ "Invalid deletion": "Invalid deletion",
+ "Invalid component update": "Invalid component update",
+ "Invalid component ID": "Invalid component ID",
+ "Invalid data update": "Invalid data update",
+ "A2UI frame exceeds limit": "A2UI frame exceeds limit",
+ "Form is invalid": "Form is invalid",
+ "Patch must match exactly once": "Patch must match exactly once",
+ "AdaScript views are unavailable": "AdaScript views are unavailable",
+ "Invalid progress plan": "Invalid progress plan",
+ "Selected model cannot inspect images": "Selected model cannot inspect images",
+ "Use a project PNG/JPEG below 375 KiB": "Use a project PNG/JPEG below 375 KiB",
+ "Invalid GLB": "Invalid GLB",
+ "Expected WAV": "Expected WAV",
+ "Truncated WAV": "Truncated WAV",
+ "Expected PCM16": "Expected PCM16",
+ "Expected PCM16 WAV": "Expected PCM16 WAV",
+ "Project changed": "Project changed",
+ "Destination exists": "Destination exists",
+ "Tool not available in this mode": "Tool not available in this mode",
+ "Invalid component field": "Invalid component field",
+ "Invalid AI catalog": "Invalid AI catalog",
+ "Invalid annotations": "Invalid annotations",
+ "Invalid annotation": "Invalid annotation",
+ "Use the color picker on the card to set its color binding.": "Use the color picker on the card to set its color binding.",
+ "Card requires a matching revision binding": "Card requires a matching revision binding",
+ "Scene changed. Read the scene and refresh the card.": "Scene changed. Read the scene and refresh the card.",
+ "Scene changed while validating revision": "Scene changed while validating revision",
+ "Surface expired": "Surface expired",
+ "Scene changed": "Scene changed",
+ "Duplicate chat state": "Duplicate chat state",
+ "Invalid chat message": "Invalid chat message",
+ "Invalid chat state": "Invalid chat state",
+ "Invalid chat session": "Invalid chat session",
+ "Invalid persisted surface": "Invalid persisted surface",
+ "Chat session not found": "Chat session not found",
+ "At most 20 conversations per project": "At most 20 conversations per project",
+ "Agent resources unavailable": "Agent resources unavailable",
+ "Unknown skill ID": "Unknown skill ID",
+ "Recipes unavailable": "Recipes unavailable",
+ "Unknown documentation ID": "Unknown documentation ID",
+ "Unknown example ID": "Unknown example ID",
+ "Unknown knowledge tool": "Unknown knowledge tool",
+ "Invalid assistant mode": "Invalid assistant mode",
+ "Invalid AdaPlayer log snapshot": "Invalid AdaPlayer log snapshot",
+ "Invalid scene key": "Invalid scene key",
+ "Use 1–100 scene operations": "Use 1–100 scene operations",
+ "Entity ID already exists": "Entity ID already exists",
+ "Expected boolean": "Expected boolean",
+ "Component already exists": "Component already exists",
+ "Expected full component payload": "Expected full component payload",
+ "Scene entity limit reached": "Scene entity limit reached",
+ "Only the active project scene is available": "Only the active project scene is available",
+ "Scene changed. Read editor.scene.get and refresh the revision.": "Scene changed. Read editor.scene.get and refresh the revision.",
+ "Choose an existing NPC, count 1–100 and finite spacing": "Choose an existing NPC, count 1–100 and finite spacing",
+ "Spawn may create at most 100 entities including children": "Spawn may create at most 100 entities including children",
+ "NPC requires a transform": "NPC requires a transform",
+ "Use #RRGGBB or #RRGGBBAA": "Use #RRGGBB or #RRGGBBAA",
+ "Choose an existing registered color field": "Choose an existing registered color field",
+ "Invalid command ID": "Invalid command ID",
+ "Invalid language": "Invalid language",
+ "Entity not found: {0}": "Entity not found: {0}",
+ "Unsupported scene operation: {0}": "Unsupported scene operation: {0}",
+ "Unsupported local action: {0}": "Unsupported local action: {0}",
+ "Choose a new project asset path ending in .{0}": "Choose a new project asset path ending in .{0}",
+ "Find": "Find",
+ "Replace": "Replace",
+ "Match case": "Match case",
+ "Regular expression": "Regular expression",
+ "Whole words": "Whole words",
+ "Replace all": "Replace all",
+ "Close search": "Close search",
+ "Go to line": "Go to line",
+ "Go": "Go",
+ "Current match": "Current match",
+ "On line": "On line",
+ "Replaced match on line {0}": "Replaced match on line {0}",
+ "Replaced {0} matches": "Replaced {0} matches",
+ "Source code": "Source code",
+ " Try again in {0} min.": " Try again in {0} min.",
+ "Access granted.": "Access granted.",
+ "Access revoked. New sign-ins and session renewals are blocked; existing access tokens expire within 15 minutes.": "Access revoked. New sign-ins and session renewals are blocked; existing access tokens expire within 15 minutes.",
+ "Access type": "Access type",
+ "Account ID": "Account ID",
+ "Account ID copied.": "Account ID copied.",
+ "Account UUID": "Account UUID",
+ "Ada Playground": "Ada Playground",
+ "Ada Playground — Create. Share. Play.": "Ada Playground — Create. Share. Play.",
+ "Ada Pro": "Ada Pro",
+ "Add its catalog details now. You can upload a playable build after the project is created.": "Add its catalog details now. You can upload a playable build after the project is created.",
+ "Admin": "Admin",
+ "Administrator access required": "Administrator access required",
+ "Administrators: {0}. Administrator access is configured by the server operator.": "Administrators: {0}. Administrator access is configured by the server operator.",
+ "Allowed users and pending invitations": "Allowed users and pending invitations",
+ "Approve and publish": "Approve and publish",
+ "Author: {0}": "Author: {0}",
+ "Available until you unpublish it": "Available until you unpublish it",
+ "Available until {0}": "Available until {0}",
+ "Back to sign in": "Back to sign in",
+ "Block game": "Block game",
+ "Blocked": "Blocked",
+ "Build active": "Build active",
+ "Build is not ready yet. You can retry this upload.": "Build is not ready yet. You can retry this upload.",
+ "Build published. Replacing a temporary build keeps its link and expiry time.": "Build published. Replacing a temporary build keeps its link and expiry time.",
+ "Change email": "Change email",
+ "Checking the archive and preparing your game…": "Checking the archive and preparing your game…",
+ "Choose a cover and up to five screenshots, each no larger than 5 MB.": "Choose a cover and up to five screenshots, each no larger than 5 MB.",
+ "Cloud": "Cloud",
+ "Cloud Services are launching in selected countries first. You can create an account now. Payments, settings sync and publishing will become available when we launch in your region.": "Cloud Services are launching in selected countries first. You can create an account now. Payments, settings sync and publishing will become available when we launch in your region.",
+ "Cloud is temporarily unavailable": "Cloud is temporarily unavailable",
+ "Cloud is temporarily unavailable. Please try again.": "Cloud is temporarily unavailable. Please try again.",
+ "Cloud navigation": "Cloud navigation",
+ "Code sent. Check your email.": "Code sent. Check your email.",
+ "Configured administrator": "Configured administrator",
+ "Continue to Ada Editor": "Continue to Ada Editor",
+ "Copy account ID": "Copy account ID",
+ "Could not grant access": "Could not grant access",
+ "Could not load access list": "Could not load access list",
+ "Could not revoke access": "Could not revoke access",
+ "Create a game page": "Create a game page",
+ "Create game page": "Create game page",
+ "Create preview link": "Create preview link",
+ "Create review preview": "Create review preview",
+ "Create your Ada account. Cloud Services will open soon in your region.": "Create your Ada account. Cloud Services will open soon in your region.",
+ "Create your first game page": "Create your first game page",
+ "Current build": "Current build",
+ "Delete account": "Delete account",
+ "Delete build": "Delete build",
+ "Delete current build": "Delete current build",
+ "Delete game page": "Delete game page",
+ "Delete the current build? The public link will stop working immediately.": "Delete the current build? The public link will stop working immediately.",
+ "Delete your account, synced settings and game publications? Subscription cancellation is managed separately with your payment provider.": "Delete your account, synced settings and game publications? Subscription cancellation is managed separately with your payment provider.",
+ "Devices and account access": "Devices and account access",
+ "Display name": "Display name",
+ "Draft legal documents — preview only, not yet effective.": "Draft legal documents — preview only, not yet effective.",
+ "Draft — not yet in effect": "Draft — not yet in effect",
+ "Each game page keeps its catalog details and public releases.": "Each game page keeps its catalog details and public releases.",
+ "Editor settings": "Editor settings",
+ "Editor sign-in is unavailable": "Editor sign-in is unavailable",
+ "Email": "Email",
+ "Email invitation": "Email invitation",
+ "Email or account ID": "Email or account ID",
+ "Enter a reason so the author can fix this version.": "Enter a reason so the author can fix this version.",
+ "Enter the code sent to {0}.": "Enter the code sent to {0}.",
+ "Existing subscription": "Existing subscription",
+ "Free": "Free",
+ "GDPR rights": "GDPR rights",
+ "Game catalog test": "Game catalog test",
+ "Game package ZIP": "Game package ZIP",
+ "Game page": "Game page",
+ "Game preview": "Game preview",
+ "Game reports": "Game reports",
+ "Game versions awaiting review": "Game versions awaiting review",
+ "Go to account": "Go to account",
+ "Grant access": "Grant access",
+ "How we handle personal data: {0} · {1}": "How we handle personal data: {0} · {1}",
+ "I agree to the {0}": "I agree to the {0}",
+ "Link another sign-in method": "Link another sign-in method",
+ "Make this version live": "Make this version live",
+ "Manage App Store subscription": "Manage App Store subscription",
+ "NEW GAME PAGE": "NEW GAME PAGE",
+ "New game page": "New game page",
+ "New uploads are not available in your region yet. You can still delete this project or its current build.": "New uploads are not available in your region yet. You can still delete this project or its current build.",
+ "No active build": "No active build",
+ "No active build. Upload a ZIP to create one.": "No active build. Upload a ZIP to create one.",
+ "No game pages yet. Create one to upload your first public build.": "No game pages yet. Create one to upload your first public build.",
+ "No invitations or user grants. Only configured administrators can sign in.": "No invitations or user grants. Only configured administrators can sign in.",
+ "No versions are waiting for review.": "No versions are waiting for review.",
+ "OTP Code": "OTP Code",
+ "Only approved versions can go live. You can restore a previous approved version.": "Only approved versions can go live. You can restore a previous approved version.",
+ "Only existing organization operators can sign in.": "Only existing organization operators can sign in.",
+ "Only invited users can sign in to this test environment. Add an existing account ID or invite a new user by email. An email invitation becomes account access after the first verified sign-in.": "Only invited users can sign in to this test environment. Add an existing account ID or invite a new user by email. An email invitation becomes account access after the first verified sign-in.",
+ "Open Ada Editor": "Open Ada Editor",
+ "Open build": "Open build",
+ "Open build ↗": "Open build ↗",
+ "Open preview ↗": "Open preview ↗",
+ "Open review preview ↗": "Open review preview ↗",
+ "PUBLICATIONS": "PUBLICATIONS",
+ "Paid access until {0}": "Paid access until {0}",
+ "Permanent game · reviewed before publishing": "Permanent game · reviewed before publishing",
+ "Permanent game · version {0}": "Permanent game · version {0}",
+ "Permanent versions": "Permanent versions",
+ "Play in Ada Studio": "Play in Ada Studio",
+ "Play in Ada Studio ↗": "Play in Ada Studio ↗",
+ "Play preview in Ada Studio ↗": "Play preview in Ada Studio ↗",
+ "Please accept the Terms of Use before continuing.": "Please accept the Terms of Use before continuing.",
+ "Previous builds": "Previous builds",
+ "Privacy Policy": "Privacy Policy",
+ "Pro: one active build per project · five uploads per day · 10 GB game traffic per month": "Pro: one active build per project · five uploads per day · 10 GB game traffic per month",
+ "Published": "Published",
+ "Publishing requires an active Pro subscription.": "Publishing requires an active Pro subscription.",
+ "Reason for rejection": "Reason for rejection",
+ "Refresh access list": "Refresh access list",
+ "Reject version": "Reject version",
+ "Remove this version and unpublish the game?": "Remove this version and unpublish the game?",
+ "Remove this version permanently?": "Remove this version permanently?",
+ "Remove version": "Remove version",
+ "Request failed": "Request failed",
+ "Request failed ({0})": "Request failed ({0})",
+ "Return to Ada Editor": "Return to Ada Editor",
+ "Return to Ada Store →": "Return to Ada Store →",
+ "Review the game and its metadata. The unlisted preview expires in 10 minutes.": "Review the game and its metadata. The unlisted preview expires in 10 minutes.",
+ "Revoke": "Revoke",
+ "Revoke access": "Revoke access",
+ "Screenshots — up to five": "Screenshots — up to five",
+ "Secret link": "Secret link",
+ "Secret link · 48 hours": "Secret link · 48 hours",
+ "Select a ZIP up to 300 MB.": "Select a ZIP up to 300 MB.",
+ "Send code": "Send code",
+ "Show synced settings": "Show synced settings",
+ "Sign in to Ada": "Sign in to Ada",
+ "Sign in to Ada Editor": "Sign in to Ada Editor",
+ "Sign in to the editor as {0}.": "Sign in to the editor as {0}.",
+ "Sign in with Apple": "Sign in with Apple",
+ "Sign in with Google": "Sign in with Google",
+ "Signed in {0}": "Signed in {0}",
+ "Subscribe monthly": "Subscribe monthly",
+ "Sync your editor settings for free. Pro adds web build publishing.": "Sync your editor settings for free. Pro adds web build publishing.",
+ "Tags, separated by commas": "Tags, separated by commas",
+ "Temporary catalog test · 96 hours": "Temporary catalog test · 96 hours",
+ "Terms of Use": "Terms of Use",
+ "Test environment access": "Test environment access",
+ "The request may have expired. Start sign-in again from Ada Editor.": "The request may have expired. Start sign-in again from Ada Editor.",
+ "These builds were published before projects were introduced. You can keep using or delete them.": "These builds were published before projects were introduced. You can keep using or delete them.",
+ "This test environment is invite-only. Ask an administrator to grant access to your account ID or email.": "This test environment is invite-only. Ask an administrator to grant access to your account ID or email.",
+ "This unlisted preview link expires in 10 minutes. Share it only with reviewers.": "This unlisted preview link expires in 10 minutes. Share it only with reviewers.",
+ "Unexpected editor callback": "Unexpected editor callback",
+ "Unpublish game": "Unpublish game",
+ "Upload a new version": "Upload a new version",
+ "Upload failed. Retry to reuse this upload.": "Upload failed. Retry to reuse this upload.",
+ "Upload interrupted. Retry to reuse this upload.": "Upload interrupted. Retry to reuse this upload.",
+ "Upload new build": "Upload new build",
+ "Upload progress": "Upload progress",
+ "Use another account": "Use another account",
+ "Verify code": "Verify code",
+ "Version approved and published.": "Version approved and published.",
+ "Version rejected. The author can see your reason.": "Version rejected. The author can see your reason.",
+ "Version submitted for review. Your approved version stays available while this one is checked.": "Version submitted for review. Your approved version stays available while this one is checked.",
+ "Version {0} · {1}": "Version {0} · {1}",
+ "Web game ZIP or AdaScript game package · 300 MB maximum": "Web game ZIP or AdaScript game package · 300 MB maximum",
+ "Web payments are not available yet. App Store purchases are managed in Ada Editor.": "Web payments are not available yet. App Store purchases are managed in Ada Editor.",
+ "Your account": "Your account",
+ "Your browser will return you to the editor. If it does not open automatically, use the link below.": "Your browser will return you to the editor. If it does not open automatically, use the link below.",
+ "Your editor settings and shared games, in one account.": "Your editor settings and shared games, in one account.",
+ "approved": "approved",
+ "awaiting review": "awaiting review",
+ "game page": "game page",
+ "game pages": "game pages",
+ "live": "live",
+ "or continue with email": "or continue with email",
+ "rejected": "rejected",
+ "{0} {1}": "{0} {1}",
+ "{0} · version {1}": "{0} · version {1}",
+ "{0} · {1}": "{0} · {1}",
+ "{0}: {1}": "{0}: {1}",
+ "Game pages: {0}": "Game pages: {0}",
+ "Created {0}": "Created {0}",
+ "Animate 3D models and preview their motion.": "Animate 3D models and preview their motion.",
+ "Import and prepare 3D models for your game.": "Import and prepare 3D models for your game.",
+ "Create materials, lighting and surface effects.": "Create materials, lighting and surface effects.",
+ "Find, import and organize game assets.": "Find, import and organize game assets.",
+ "Create a game with an autonomous Studio workflow.": "Create a game with an autonomous Studio workflow.",
+ "Build, run and diagnose your project.": "Build, run and diagnose your project.",
+ "Write and update your game’s AdaScript code.": "Write and update your game’s AdaScript code.",
+ "Prepare a game for Community review.": "Prepare a game for Community review.",
+ "Turn your idea into a playable game.": "Turn your idea into a playable game.",
+ "Check AdaScript symbols and code diagnostics.": "Check AdaScript symbols and code diagnostics.",
+ "Create pixel art and animated sprites.": "Create pixel art and animated sprites.",
+ "Generate 3D models from shapes and parameters.": "Generate 3D models from shapes and parameters.",
+ "Explore the project and choose the next step.": "Explore the project and choose the next step.",
+ "Create and edit scene objects and components.": "Create and edit scene objects and components.",
+ "Add sound effects and music to your game.": "Add sound effects and music to your game.",
+ "Find and fix problems in your game’s UI.": "Find and fix problems in your game’s UI.",
+ "Create interactive forms and UI controls.": "Create interactive forms and UI controls.",
+ "Check how your game looks and behaves.": "Check how your game looks and behaves.",
+ "Create voxel models for your game.": "Create voxel models for your game.",
+ "Show interactive controls and previews in chat.": "Show interactive controls and previews in chat.",
+ "Project workflow": "Project workflow",
+ "your Ada account": "your Ada account",
+ "3D mesh": "3D mesh",
+ "Directional light": "Directional light",
+ "Point light": "Point light",
+ "Spot light": "Spot light",
+ "3D model": "3D model",
+ "Scene instance": "Scene instance",
+ "Background color": "Background color",
+ "Light color": "Light color",
+ "Tint color": "Tint color",
+ "Player startup failed": "Player startup failed",
+ "Selection deleted": "Selection deleted",
+ "Create a platformer where I jump between floating islands and collect stars.": "Create a platformer where I jump between floating islands and collect stars.",
+ "Create a racing game where I dodge obstacles and beat my best time.": "Create a racing game where I dodge obstacles and beat my best time.",
+ "Create a puzzle game where I move blocks to open a path to the exit.": "Create a puzzle game where I move blocks to open a path to the exit.",
+ "Create a cozy farming game where I grow plants and explore a small village.": "Create a cozy farming game where I grow plants and explore a small village.",
+ "I want to make a game where I explore a mysterious island and discover hidden treasures.": "I want to make a game where I explore a mysterious island and discover hidden treasures.",
+ "A game about a robot that collects spare parts and builds its own little world.": "A game about a robot that collects spare parts and builds its own little world.",
+ "A platformer where each level introduces a new obstacle and a secret to discover.": "A platformer where each level introduces a new obstacle and a secret to discover.",
+ "A racing game with short tracks, checkpoints and a timer.": "A racing game with short tracks, checkpoints and a timer.",
+ "A puzzle game with switches, moving blocks and a clear goal in every level.": "A puzzle game with switches, moving blocks and a clear goal in every level.",
+ "Make a game where I explore a forest, collect crystals and unlock new paths.": "Make a game where I explore a forest, collect crystals and unlock new paths.",
+ "Game dimension": "Game dimension",
+ "Player mode": "Player mode",
+ "Single-player": "Single-player",
+ "Multiplayer": "Multiplayer",
+ "Tab to complete": "Tab to complete",
+ "Complete suggestion: {0}": "Complete suggestion: {0}",
+ "2D": "2D",
+ "3D": "3D",
+ "Accept suggestion": "Accept suggestion",
+ "Create a game where I explore a forest, collect crystals and unlock new paths.": "Create a game where I explore a forest, collect crystals and unlock new paths.",
+ "I want a game about exploring an island and finding treasures.": "I want a game about exploring an island and finding treasures.",
+ "An adventure game where I explore ruins, solve puzzles and find my way home.": "An adventure game where I explore ruins, solve puzzles and find my way home.",
+ "Depth, light and space": "Depth, light and space",
+ "Sprites and flat worlds": "Sprites and flat worlds",
+ "An adventure of your own": "An adventure of your own",
+ "Play together with others": "Play together with others",
+ "Unsupported attachment. Choose an image, GLB/VOX model, audio or text file.": "Unsupported attachment. Choose an image, GLB/VOX model, audio or text file.",
+ "Converted audio exceeds 16 MB. Choose a shorter recording.": "Converted audio exceeds 16 MB. Choose a shorter recording.",
+ "Choose mono or stereo audio.": "Choose mono or stereo audio.",
+ "Audio conversion is unavailable in this browser. Choose WAV or OGG.": "Audio conversion is unavailable in this browser. Choose WAV or OGG.",
+ "Could not read this audio file. Choose WAV or OGG, or try another recording.": "Could not read this audio file. Choose WAV or OGG, or try another recording.",
+ "Could not read this image. Choose a PNG, JPEG or WebP file.": "Could not read this image. Choose a PNG, JPEG or WebP file.",
+ "Image processing is unavailable in this browser.": "Image processing is unavailable in this browser.",
+ "Could not prepare this image for the assistant. Try a smaller image.": "Could not prepare this image for the assistant. Try a smaller image.",
+ "An attachment can be up to 16 MB.": "An attachment can be up to 16 MB.",
+ "Wait for the current operation before attaching files.": "Wait for the current operation before attaching files.",
+ "Attachments exceed 63 MB in total.": "Attachments exceed 63 MB in total.",
+ "Use the attached files in this game.": "Use the attached files in this game.",
+ "Invalid attachment details": "Invalid attachment details",
+ "Attached files": "Attached files",
+ "{0} MB": "{0} MB",
+ "Converted for your project": "Converted for your project",
+ "Remove attachment {0}": "Remove attachment {0}",
+ "Drop files to attach": "Drop files to attach",
+ "Preparing files…": "Preparing files…",
"Morph Targets": "Morph Targets",
"MORPH TARGETS": "MORPH TARGETS",
"Loading morph target model…": "Loading morph target model…",
@@ -1664,5 +2369,13 @@
"Applied to scene. Save the scene to keep these weights.": "Applied to scene. Save the scene to keep these weights.",
"Morph weights cleared.": "Morph weights cleared.",
"Morph weights applied": "Morph weights applied",
- "Morph weights cleared": "Morph weights cleared"
+ "Morph weights cleared": "Morph weights cleared",
+ "Sprite Animation": "Sprite Animation",
+ "Add Clip": "Add Clip",
+ "Add Row Frames": "Add Row Frames",
+ "Import from Aseprite": "Import from Aseprite",
+ "Export the sheet with Trim off and a JSON array. Replaces the image and clips.": "Export the sheet with Trim off and a JSON array. Replaces the image and clips.",
+ "Choose sprite animation…": "Choose sprite animation…",
+ "Search sprite animations": "Search sprite animations",
+ "Create a Sprite Animation resource in Assets first.": "Create a Sprite Animation resource in Assets first."
}
diff --git a/Editor/Sources/AdaEditor/Assets/Localization/ru.json b/Editor/Sources/AdaEditor/Assets/Localization/ru.json
index 810a0e3c8..babc233f2 100644
--- a/Editor/Sources/AdaEditor/Assets/Localization/ru.json
+++ b/Editor/Sources/AdaEditor/Assets/Localization/ru.json
@@ -1,4 +1,97 @@
{
+ " · open": " · открыт",
+ "2D game": "2D-игра",
+ "3D world": "3D-мир",
+ "A GAME BY THE COMMUNITY": "ИГРА СООБЩЕСТВА",
+ "A new world, made with Ada.": "Новый мир, созданный с Ada.",
+ "A simple idea can become a great game": "Простая идея может стать большой игрой",
+ "About this game": "Об игре",
+ "Account": "Аккаунт",
+ "Ada Playground home": "Главная Ada Playground",
+ "Agent settings": "Настройки ассистента",
+ "All changes saved": "Все изменения сохранены",
+ "All games": "Все игры",
+ "Browser": "Браузер",
+ "Browser Studio is not enabled on this server.": "Браузерная Studio пока не включена на этом сервере.",
+ "Catalog order": "Как в каталоге",
+ "Clear filters": "Сбросить фильтры",
+ "Continue working": "Продолжить работу",
+ "Could not update your like. Try again.": "Не удалось обновить лайк. Попробуйте ещё раз.",
+ "Create a game": "Создать игру",
+ "Create your first project or upload one.": "Создайте первый проект или загрузите свой.",
+ "Create. Share. Play.": "Создавайте. Делитесь. Играйте.",
+ "Creator {0}": "Автор {0}",
+ "DISCOVER SOMETHING DIFFERENT": "ОТКРОЙТЕ ЧТО-ТО НОВОЕ",
+ "Dismiss message": "Закрыть сообщение",
+ "Download project": "Скачать проект",
+ "Everything you create belongs to you": "Всё, что вы создаёте, принадлежит вам",
+ "Explore a game made with Ada.": "Откройте игру, созданную с Ada.",
+ "Explore what people are making. Make something of your own.": "Посмотрите, что создают другие. Придумайте что-то своё.",
+ "FROM PLAYER TO CREATOR": "ОТ ИГРОКА К АВТОРУ",
+ "Filter games by platform or tag": "Фильтр игр по платформе или тегу",
+ "Find your next game": "Найдите следующую игру",
+ "Games": "Игры",
+ "In Ada Studio": "В Ada Studio",
+ "In your browser": "В браузере",
+ "Inside the game": "Кадры из игры",
+ "Light, space and new adventures": "Свет, пространство и новые приключения",
+ "Like": "Нравится",
+ "Loading Studio…": "Загружаем Studio…",
+ "Loading games…": "Загружаем игры…",
+ "Loading game…": "Загружаем игру…",
+ "Make your first game": "Создать первую игру",
+ "Most liked": "По лайкам",
+ "My projects": "Мои проекты",
+ "No games match your search.": "Таких игр пока нет.",
+ "OPEN PROJECT": "ОТКРЫТЫЙ ПРОЕКТ",
+ "Offline": "Нет соединения",
+ "Open in Ada Studio": "Открыть в Ada Studio",
+ "Play now": "Играть",
+ "Playground navigation": "Разделы Playground",
+ "Project overview": "Обзор проекта",
+ "Project sections": "Разделы проекта",
+ "Project title": "Название проекта",
+ "Published games will appear here. Create the first one.": "Здесь появятся опубликованные игры. Создайте первую.",
+ "Recent projects": "Недавние проекты",
+ "Remove like": "Убрать лайк",
+ "Saved": "Сохранено",
+ "Saving": "Сохранение",
+ "Saving…": "Сохраняем…",
+ "Scroll tags left": "Прокрутить теги влево",
+ "Scroll tags right": "Прокрутить теги вправо",
+ "Search games": "Поиск игр",
+ "Search titles, creators, or tags": "Название, автор или тег",
+ "Sign in to like": "Войти, чтобы поставить лайк",
+ "Sign in to save": "Войдите для сохранения",
+ "Sort by": "Сортировка",
+ "Start from scratch": "Начать с чистого листа",
+ "Start with an idea. Build the rest together with your assistant.": "Начните с идеи. Остальное создавайте вместе с вашим ассистентом.",
+ "The cloud is unavailable. You can save a local copy before leaving.": "Облако недоступно. Перед выходом можно сохранить локальную копию.",
+ "The creator has not added a description yet.": "Автор пока не добавил описание.",
+ "The game catalog is temporarily unavailable.": "Каталог игр временно недоступен.",
+ "The next world could be yours.": "Следующий мир может стать вашим.",
+ "This game is unavailable.": "Эта игра недоступна.",
+ "This release is no longer available.": "Эта версия больше недоступна.",
+ "Title A–Z": "По названию",
+ "Try another title or tag.": "Попробуйте другое название или тег.",
+ "Upload project": "Загрузить проект",
+ "Version history": "История версий",
+ "Version {0}": "Версия {0}",
+ "WHERE GAMES BEGIN": "МЕСТО, ГДЕ НАЧИНАЮТСЯ ИГРЫ",
+ "What will you create today?": "Что создадим сегодня?",
+ "What will you play today?": "Во что сыграем сегодня?",
+ "YOUR NEXT LITTLE ADVENTURE": "ВАШЕ СЛЕДУЮЩЕЕ ПРИКЛЮЧЕНИЕ",
+ "Your idea belongs here.": "Здесь есть место вашей идее.",
+ "Your ideas. Your worlds.": "Ваши идеи. Ваши миры.",
+ "Your project “{0}” stays open. Retry saving or save a local copy before returning to Playground.": "Ваш проект «{0}» остаётся открыт. Повторите сохранение или сохраните локальную копию перед переходом в Playground.",
+ "Your scene, code and edit history are right where you left them.": "Вернитесь к работе: сцена, код и история изменений на месте.",
+ "Your working projects are private": "Рабочие проекты сохраняются приватно",
+ "Your workspace": "Ваше пространство",
+ "Your worlds will appear here": "Здесь появятся ваши миры",
+ "likes": "лайки",
+ "{0} games": "Игр: {0}",
+ "{0} likes": "Лайки: {0}",
+ "{0} — gameplay screenshot {1}": "{0} — кадр из игры {1}",
"Continue your message": "Продолжите сообщение",
"What next?": "Что дальше?",
"Dismiss suggestions": "Скрыть подсказки",
@@ -1656,6 +1749,618 @@
"Edit annotation #{0}: {1}": "Редактировать аннотацию №{0}: {1}",
"Ready to send together: {0}": "Будут отправлены вместе: {0}",
"This note was edited in the composer. Edit its text there or remove it before replacing it.": "Текст замечания изменён в композере. Отредактируйте его там или удалите аннотацию перед заменой.",
+ "Local Codex": "Локальный Codex",
+ "Uses the Codex CLI sign-in on this computer. No API key is needed.": "Используется вход в Codex CLI на этом компьютере. API-ключ не нужен.",
+ "Local Codex request failed. Check the development server log.": "Не удалось получить ответ Codex. Посмотрите журнал локального сервера.",
+ "AI credit balance is unavailable. Please retry.": "Не удалось загрузить AI-кредиты. Попробуйте ещё раз.",
+ "Ada AI is not available on this server yet.": "Ada AI пока недоступен на этом сервере.",
+ "Ada AI is unavailable for this account.": "Ada AI недоступен для этого аккаунта.",
+ "Cloud Pro includes 500 AI credits per month. Subscribe to use Ada AI.": "Cloud Pro включает 500 AI-кредитов в месяц. Оформите подписку, чтобы пользоваться Ada AI.",
+ "Your AI credits are used up. They renew with your monthly Cloud Pro allowance.": "AI-кредиты закончились. Они обновятся в следующем месячном периоде Cloud Pro.",
+ "AI credits: {0} / {1}": "AI-кредиты: {0} / {1}",
+ "AI credits —": "AI-кредиты —",
+ "Cloud Pro includes 500 AI credits per month, shared across your devices.": "Cloud Pro включает 500 AI-кредитов в месяц на все ваши устройства.",
+ "Used: {0} · Reserved: {1}": "Использовано: {0} · В резерве: {1}",
+ "Credits renew: {0}": "Кредиты обновятся: {0}",
+ "Refresh AI credits": "Обновить AI-кредиты",
+ "Show console": "Показать консоль",
+ "Hide console": "Скрыть консоль",
+ "Runtime console": "Консоль движка",
+ "Clear console": "Очистить консоль",
+ "AdaPlayer logs": "Логи AdaPlayer",
+ "Waiting for AdaPlayer logs…": "Ожидаем логи AdaPlayer…",
+ "No logs yet. Run the game to see output.": "Логов пока нет. Запустите игру, чтобы увидеть сообщения.",
+ "Conversation settings": "Настройки беседы",
+ "Scene panels": "Панели сцены",
+ "Expand assistant": "Развернуть ассистента",
+ "Playing your current version": "Играет ваша текущая версия",
+ "Right mouse + WASD — camera · Wheel — zoom · F — focus": "ПКМ + WASD — камера · Колесо — масштаб · F — фокус",
+ "W / E / R — gizmos": "W / E / R — гизмо",
+ "Scene objects": "Объекты сцены",
+ "Expand objects": "Развернуть объекты",
+ "Collapse objects": "Свернуть объекты",
+ "Search objects": "Поиск объектов",
+ "Find in scene": "Найти в сцене",
+ "Add object": "Добавить объект",
+ "Objects": "Объекты",
+ "Changes are saved automatically": "Изменения сохраняются автоматически",
+ "Generation interrupted. Your saved project is available; check your AI credits before continuing.": "Ответ прервался. Проект сохранён; проверьте AI-кредиты перед продолжением.",
+ "Coder Next supports text. Choose a vision model to send images.": "Coder Next работает с текстом. Для изображений выберите модель с поддержкой зрения.",
+ "The AI context is too large. Start a new conversation to continue.": "Контекст AI слишком большой. Начните новый разговор, чтобы продолжить.",
+ "The user declined this project change.": "Пользователь отклонил изменение проекта.",
+ "The agent reached its step limit. Continue from the saved project.": "Ассистент достиг лимита шагов. Продолжите с сохранённого проекта.",
+ "Not enough AI credits for this request. Check your balance in Cloud settings.": "Для этого запроса не хватает AI-кредитов. Проверьте баланс в настройках Cloud.",
+ "Too many AI requests. Please wait and try again.": "Слишком много AI-запросов. Подождите немного и попробуйте ещё раз.",
+ "A different project is open. Return to this project before continuing.": "Открыт другой проект. Вернитесь к этому проекту, чтобы продолжить.",
+ "Save the open document before changing it with Ada AI.": "Сохраните открытый документ перед изменением через Ada AI.",
+ "Use editor.file.patch to change an existing file.": "Для изменения существующего файла используйте editor.file.patch.",
+ "Commands": "Команды",
+ "Commands are unavailable. Try typing / again.": "Не удалось загрузить команды. Попробуйте ввести / ещё раз.",
+ "Loading commands…": "Загружаем команды…",
+ "No matching commands": "Подходящих команд нет",
+ "↑↓ Navigate · Enter Select · Esc Close": "↑↓ Выбор · Enter Вставить · Esc Закрыть",
+ "Ada AI · Coder Next": "Ada AI · Coder Next",
+ "Use Ada AI": "Использовать Ada AI",
+ "Allow project change?": "Разрешить изменение проекта?",
+ "Desktop view": "Настольный вид",
+ "Mobile view": "Мобильный вид",
+ "Local movement axes": "Локальные оси перемещения",
+ "Snap: 0.5 / 10 in 2D, 15°, scale 0.1 (Ctrl)": "Привязка: 0.5 / 10 в 2D, 15°, масштаб 0.1 (Ctrl)",
+ "Focus selection (F)": "Фокус на объекте (F)",
+ "Reset to game camera": "Вернуться к камере игры",
+ "Undo edit": "Отменить правку",
+ "Redo edit": "Повторить правку",
+ "Project assets": "Ассеты проекта",
+ "Scene controls": "Управление сценой",
+ "Close scene help": "Закрыть подсказки",
+ "Selection": "Выбор",
+ "Click an object": "Щелчок по объекту",
+ "Gizmos": "Гизмо",
+ "W · move, E · rotate, R · scale": "W · перемещение, E · вращение, R · масштаб",
+ "Free camera": "Свободная камера",
+ "Right mouse + drag, WASD / QE, Shift for speed": "ПКМ + мышь, WASD / QE, Shift быстрее",
+ "Orbit selection": "Облететь объект",
+ "Alt + left drag": "Alt + левая кнопка",
+ "Middle drag or Q + drag": "Средняя кнопка или Q + перетаскивание",
+ "Mouse wheel": "Колесо мыши",
+ "Focus": "Фокус",
+ "F or double-click": "F или двойной щелчок",
+ "Snapping": "Привязка",
+ "Ctrl while dragging a gizmo": "Ctrl при перетаскивании гизмо",
+ "Cancel gesture": "Отменить жест",
+ "Duplicate / delete": "Копия / удалить",
+ "Ctrl F · search, Tab · indent, Ctrl Z · undo": "Ctrl F · поиск, Tab · отступ, Ctrl Z · undo",
+ "Could not update the scene": "Не удалось обновить сцену",
+ "Starting AdaEngine…": "Запускаем AdaEngine…",
+ "AdaEngine renders the scene and game. The editor is ready to use.": "Сцену и игру рисует AdaEngine. Редактор уже готов к работе.",
+ "Retry loading": "Повторить загрузку",
+ "AdaEngine · Updating scene…": "AdaEngine · Обновление сцены…",
+ "AdaEngine · Scene error": "AdaEngine · Ошибка сцены",
+ "AdaEngine · Starting…": "AdaEngine · Запуск…",
+ "AdaEngine · Game running": "AdaEngine · Игра запущена",
+ "AdaEngine · Scene editor": "AdaEngine · Редактор сцены",
+ "Return to panels (Esc)": "Вернуться к панелям (Esc)",
+ "Expand scene": "Развернуть сцену",
+ "Camera / pan (Q)": "Камера / панорама (Q)",
+ "Move (W)": "Перемещение (W)",
+ "Rotate (E)": "Вращение (E)",
+ "Scale (R)": "Масштаб (R)",
+ "Scene update cancelled": "Обновление сцены отменено",
+ "A WebGPU browser is required for the scene. Panels and code remain available.": "Для сцены нужен браузер с WebGPU. Панели и код доступны без него.",
+ "The scene viewport is not ready yet.": "Игровая область пока недоступна",
+ "The scene requires a separate secure host.": "Для сцены нужен отдельный защищённый хост",
+ "AdaEngine — scene and game preview": "AdaEngine — сцена и игровой предпросмотр",
+ "AdaPlayer did not respond": "AdaPlayer не ответил",
+ "Could not open AdaPlayer": "Не удалось открыть AdaPlayer",
+ "The scene has not loaded yet.": "Сцена ещё не загружена",
+ "AdaPlayer is not responding": "AdaPlayer не отвечает",
+ "Loading your workspace…": "Загружаем ваши проекты…",
+ "Explore games": "Посмотреть игры",
+ "Sign in to create and save your own worlds.": "Войдите, чтобы создавать и сохранять свои миры.",
+ "We could not load your workspace. Check your connection and try again.": "Не удалось загрузить ваши проекты. Проверьте подключение и попробуйте ещё раз.",
+ "Your workspace is on its way": "Ваши проекты скоро появятся",
+ "Let’s get you back to your worlds": "Вернёмся к вашим мирам",
+ "Your first world starts here": "Ваш первый мир начинается здесь",
+ "A home for your worlds": "Здесь живут ваши миры",
+ "Loading your projects…": "Загружаем ваши проекты…",
+ "Start a new 2D or 3D game, or bring a project you have already started.": "Создайте новую 2D- или 3D-игру или откройте начатый проект.",
+ "Sign in to keep your projects together, save your progress and continue creating whenever inspiration strikes.": "Войдите, чтобы хранить проекты вместе, сохранять изменения и возвращаться к творчеству в любой момент.",
+ "Create your first world": "Создать первый мир",
+ "Pick up where you left off, or start a new world.": "Продолжите начатое или создайте новый мир.",
+ "Start with an idea": "Начните с идеи",
+ "Describe a game or choose a 2D or 3D world.": "Опишите игру или выберите 2D- или 3D-мир.",
+ "Make it yours": "Создайте свой мир",
+ "Shape scenes, edit code and build with your assistant.": "Создавайте сцены, пишите код и творите вместе с ассистентом.",
+ "Pick up where you left off": "Продолжите с того места, где остановились",
+ "Keep your projects in your account, ready for your next idea.": "Храните проекты в аккаунте, чтобы вернуться к ним с новой идеей.",
+ "Use Ada AI with Cloud Pro, Codex, or your own API key": "Ada AI с Cloud Pro, Codex или свой API-ключ",
+ "Sign in to Ada Cloud to see AI credits.": "Войдите в Ada Cloud, чтобы увидеть AI-кредиты.",
+ "Checking AI credits…": "Проверяем AI-кредиты…",
+ "AI credits are unavailable on this server.": "AI-кредиты недоступны на этом сервере.",
+ "Couldn’t refresh AI credits. Please retry.": "Не удалось обновить AI-кредиты. Повторите попытку.",
+ "This account is closed. AI spending is disabled.": "Аккаунт закрыт. Использование AI-кредитов отключено.",
+ "AI spending is suspended for this account.": "Использование AI-кредитов для этого аккаунта приостановлено.",
+ "Hosted AI will open soon.": "Облачный AI скоро станет доступен.",
+ "Hosted AI is unavailable in your region.": "Облачный AI недоступен в вашем регионе.",
+ "AI credits are shared across your Ada Studio apps.": "AI-кредиты общие для всех ваших приложений Ada Studio.",
+ "Credits…": "Кредиты…",
+ "Credits —": "Кредиты —",
+ "Couldn’t load credit activity. Please retry.": "Не удалось загрузить историю кредитов. Повторите попытку.",
+ "Allowance adjustment": "Изменение лимита",
+ "Reserved": "Зарезервировано",
+ "Used": "Использовано",
+ "Released": "Возвращено",
+ "Credit adjustment": "Изменение баланса",
+ "Publishing": "Публикации",
+ "Report this game": "Пожаловаться на игру",
+ "Send report": "Отправить жалобу",
+ "Report sent": "Жалоба отправлена",
+ "Close window": "Закрыть окно",
+ "Your assistant": "Ваш ассистент",
+ "Interface language": "Язык интерфейса",
+ "Choose who will help you create your game.": "Выберите, кто поможет вам создать игру.",
+ "Your API key": "Свой API-ключ",
+ "{0} — no tools": "{0} — без инструментов",
+ "Selected provider API key": "Ключ выбранного провайдера",
+ "The key stays in this tab’s memory and is not included in the project.": "Ключ хранится только в памяти этой вкладки и не входит в проект.",
+ "Your world’s history": "История вашего мира",
+ "Return to any checkpoint. Restoring creates a new version.": "Вернитесь к любой контрольной версии. Восстановление создаёт новую версию.",
+ "Save checkpoint": "Сохранить контрольную версию",
+ "Could not save the project to Cloud": "Не удалось сохранить проект в облаке",
+ "The local copy is available only on this device. Studio will restore it the next time you open the project. Download a ZIP for a separate backup.": "Локальная копия доступна только на этом устройстве. При следующем открытии проекта Studio восстановит её. ZIP можно скачать для отдельной резервной копии.",
+ "Stay in project": "Остаться в проекте",
+ "Download ZIP": "Скачать ZIP",
+ "Retry saving": "Повторить сохранение",
+ "Save locally and leave": "Сохранить локально и выйти",
+ "Open test inbox": "Открыть тестовую почту",
+ "The 40-step limit was reached. Continue from the saved result.": "Достигнут лимит 40 шагов. Продолжите с сохранённого результата.",
+ "File not found": "Файл не найден",
+ "The file is too large for text context": "Файл слишком велик для текстового контекста",
+ "Object not found": "Объект не найден",
+ "This model does not support images": "Модель не поддерживает изображения",
+ "This card is unavailable": "Карточка недоступна",
+ "Discuss mode does not allow project changes": "Режим обсуждения не разрешает правки проекта",
+ "The user declined the tool": "Пользователь отклонил инструмент",
+ "Stop the assistant before switching projects": "Остановите ассистента перед сменой проекта",
+ "The project snapshot is unavailable": "Снимок проекта недоступен",
+ "Unsaved changes were recovered as a separate project": "Несохранённые изменения восстановлены отдельным проектом",
+ "Stop the assistant before returning to projects": "Остановите ассистента перед выходом к проектам",
+ "The project changed in another tab. Your edits were saved as a separate copy.": "Проект изменён в другой вкладке. Ваши правки сохранены отдельной копией.",
+ "Invalid transform": "Некорректная трансформация",
+ "New object": "Новый объект",
+ "The text attachment exceeds 100 KB": "Текстовое вложение превышает 100 КБ",
+ "Stop the assistant before restoring a version": "Остановите ассистента перед восстановлением",
+ "Invalid API address": "Недопустимый адрес API",
+ "The provider response is too large": "Слишком большой ответ провайдера",
+ "Unsupported scene format": "Неподдерживаемый формат сцены",
+ "Duplicate entity IDs": "Повторяющиеся ID объектов",
+ "Invalid scene entity": "Некорректный объект сцены",
+ "Invalid hierarchy": "Некорректная иерархия",
+ "Invalid project": "Некорректный проект",
+ "The project exceeds 64 MB": "Размер проекта превышает 64 МБ",
+ "An AdaScript project with a scene is required": "Нужен AdaScript-проект со сценой",
+ "Use the Sources and Assets folders inside the project": "Используйте папки Sources и Assets внутри проекта",
+ "Chat history exceeds the project limit": "История чата превышает лимит проекта",
+ "The archive exceeds 65 MB": "Архив превышает 65 МБ",
+ "Unsafe ZIP path or size": "Небезопасный путь или размер ZIP",
+ "The extracted project exceeds 64 MB": "Распакованный проект превышает 64 МБ",
+ "Imported project": "Импортированный проект",
+ "Invalid ZIP archive": "Повреждённый ZIP",
+ "Unsupported ZIP structure": "Неподдерживаемая структура ZIP",
+ "Unsupported ZIP compression": "Неподдерживаемое сжатие ZIP",
+ "Duplicate ZIP path": "Повторяющийся путь ZIP",
+ "Valley of winds": "Долина ветров",
+ "New 2D game": "Новая 2D-игра",
+ "Sun": "Солнце",
+ "Meadow": "Лужайка",
+ "Invalid file: {0}": "Недопустимый файл: {0}",
+ "Invalid ZIP archive: {0}": "Повреждённый ZIP: {0}",
+ "Pine {0}": "Сосна {0}",
+ "Foliage {0}": "Крона {0}",
+ "Rock {0}": "Скала {0}",
+ "Server error: {0}": "Ошибка сервера: {0}",
+ "Unavailable tool: {0}": "Недоступный инструмент: {0}",
+ "{0} — recovered": "{0} — восстановлено",
+ "{0} — copy": "{0} — копия",
+ "Hide inspector": "Скрыть инспектор",
+ "Focus selected object": "Фокус на выбранном объекте",
+ "Duplicate object": "Дублировать объект",
+ "Delete object": "Удалить объект",
+ "The selected object is included in the assistant’s context.": "Выделенный объект передаётся ассистенту как контекст.",
+ "Render order": "Порядок",
+ "Cast shadows": "Отбрасывает тени",
+ "Receive shadows": "Принимает тени",
+ "Volume": "Громкость",
+ "Autoplay": "Автозапуск",
+ "{0}, {1} axis": "{0}, ось {1}",
+ "Validate in AdaPlayer": "Проверить в AdaPlayer",
+ "Project text": "Текст проекта",
+ "Upload asset": "Загрузить ассет",
+ "Close assets": "Закрыть ассеты",
+ "UTF-8 · {0} lines": "UTF-8 · строк: {0}",
+ "Use {0}": "Используй {0}",
+ "{0} KB": "{0} КБ",
+ "AdaEngine scene controls": "Управление сценой AdaEngine",
+ "YOUR PROJECT": "ВАШ ПРОЕКТ",
+ "{0} objects · camera, gizmos and inspector": "Объектов: {0} · камера, гизмо и инспектор",
+ "{0} files · models, images and audio": "Файлов: {0} · модели, изображения и звук",
+ "{0} AdaScript files · your game’s behavior": "Файлов AdaScript: {0} · поведение вашей игры",
+ "Chat, project context and agent changes": "Чат, контекст проекта и изменения агента",
+ "Scene, assets, code and chat are parts of one game. Switch between them on the left.": "Сцена, ассеты, код и чат — части одной игры. Переключайтесь между ними слева.",
+ "Project saved in Ada Cloud": "Проект сохранён в Ada Cloud",
+ "Saving changes…": "Сохраняем изменения…",
+ "Changes have not been saved to Cloud yet": "Изменения ещё не сохранены в облаке",
+ "Private project · revision {0}. Autosave works in every section.": "Приватный проект · ревизия {0}. Автосохранение работает во всех разделах.",
+ "Save now": "Сохранить сейчас",
+ "Continue where you left off": "Продолжайте с того же места",
+ "Find your project in All projects. ZIP includes sources and assets so you can reopen it.": "Проект доступен в списке «Все проекты». ZIP содержит исходники и ассеты для повторного открытия.",
+ "Expected absolute JSON Pointer": "Нужен абсолютный JSON Pointer",
+ "Unsafe data binding": "Небезопасная привязка данных",
+ "Cannot delete root": "Нельзя удалить корень данных",
+ "Binding parent is not a container": "Родитель привязки должен быть объектом или массивом",
+ "Invalid array index": "Некорректный индекс массива",
+ "Action nesting exceeds limit": "Слишком много вложенных действий",
+ "Unsafe context key": "Небезопасный ключ контекста",
+ "Input requires a writable binding": "Полю ввода нужна привязка с правом записи",
+ "Text exceeds limit": "Текст превышает допустимый размер",
+ "Text binding requires a string": "Текстовая привязка должна содержать строку",
+ "Invalid or oversized data model": "Некорректная или слишком большая модель данных",
+ "Incompatible binding types": "Несовместимые типы привязок",
+ "Too many components": "Слишком много компонентов",
+ "Unsupported component": "Компонент не поддерживается",
+ "Unsupported or missing component property": "Свойство компонента отсутствует или не поддерживается",
+ "Invalid placeholder": "Некорректная подсказка поля",
+ "Wrong input binding type": "Неверный тип привязки поля ввода",
+ "Invalid choices": "Некорректные варианты выбора",
+ "Invalid slider range": "Некорректный диапазон ползунка",
+ "Invalid children": "Некорректный список дочерних компонентов",
+ "Invalid spacing": "Некорректный отступ",
+ "Only server event actions are supported": "Поддерживаются только действия с событиями сервера",
+ "Invalid form checks": "Некорректные проверки формы",
+ "Unsupported check": "Проверка не поддерживается",
+ "Invalid check arguments": "Некорректные параметры проверки",
+ "Invalid check range": "Некорректный диапазон проверки",
+ "Cyclic or repeated component mount": "Компонент подключён повторно или образует цикл",
+ "Unsupported A2UI envelope": "Формат сообщения A2UI не поддерживается",
+ "One A2UI message per envelope required": "В одном пакете должно быть одно сообщение A2UI",
+ "Invalid surface ID": "Некорректный ID формы",
+ "Surface belongs to another agent": "Форма принадлежит другому агенту",
+ "Unsupported surface or catalog": "Форма или каталог не поддерживаются",
+ "Create the surface before updating": "Создайте форму перед обновлением",
+ "Invalid deletion": "Некорректное удаление",
+ "Invalid component update": "Некорректное обновление компонента",
+ "Invalid component ID": "Некорректный ID компонента",
+ "Invalid data update": "Некорректное обновление данных",
+ "A2UI frame exceeds limit": "Сообщение A2UI превышает допустимый размер",
+ "Form is invalid": "Форма заполнена неверно",
+ "Patch must match exactly once": "Заменяемый фрагмент должен встречаться ровно один раз",
+ "AdaScript views are unavailable": "Представления AdaScript недоступны",
+ "Invalid progress plan": "Некорректный план работы",
+ "Selected model cannot inspect images": "Выбранная модель не умеет просматривать изображения",
+ "Use a project PNG/JPEG below 375 KiB": "Выберите PNG или JPEG из проекта размером до 375 КиБ",
+ "Invalid GLB": "Некорректный файл GLB",
+ "Expected WAV": "Нужен файл WAV",
+ "Truncated WAV": "Файл WAV обрезан",
+ "Expected PCM16": "Нужен формат PCM16",
+ "Expected PCM16 WAV": "Нужен файл WAV в формате PCM16",
+ "Project changed": "Открыт другой проект",
+ "Destination exists": "Файл назначения уже существует",
+ "Tool not available in this mode": "Инструмент недоступен в этом режиме",
+ "Invalid component field": "Некорректное поле компонента",
+ "Invalid AI catalog": "Некорректный каталог ИИ",
+ "Invalid annotations": "Некорректные аннотации",
+ "Invalid annotation": "Некорректная аннотация",
+ "Use the color picker on the card to set its color binding.": "Задайте привязанный цвет через палитру в карточке.",
+ "Card requires a matching revision binding": "Карточке нужна привязка к текущей ревизии",
+ "Scene changed. Read the scene and refresh the card.": "Сцена изменилась. Прочитайте её и обновите карточку.",
+ "Scene changed while validating revision": "Сцена изменилась во время проверки ревизии",
+ "Surface expired": "Форма устарела",
+ "Scene changed": "Сцена изменилась",
+ "Duplicate chat state": "Состояние чата записано повторно",
+ "Invalid chat message": "Некорректное сообщение чата",
+ "Invalid chat state": "Некорректное состояние чата",
+ "Invalid chat session": "Некорректная беседа",
+ "Invalid persisted surface": "Некорректная сохранённая форма",
+ "Chat session not found": "Беседа не найдена",
+ "At most 20 conversations per project": "В проекте может быть не более 20 бесед",
+ "Agent resources unavailable": "Ресурсы ассистента недоступны",
+ "Unknown skill ID": "Неизвестный ID навыка",
+ "Recipes unavailable": "Примеры недоступны",
+ "Unknown documentation ID": "Неизвестный ID документации",
+ "Unknown example ID": "Неизвестный ID примера",
+ "Unknown knowledge tool": "Неизвестный инструмент справки",
+ "Invalid assistant mode": "Некорректный режим ассистента",
+ "Invalid AdaPlayer log snapshot": "Некорректный снимок логов AdaPlayer",
+ "Invalid scene key": "Некорректный ключ сцены",
+ "Use 1–100 scene operations": "Используйте от 1 до 100 операций со сценой",
+ "Entity ID already exists": "Объект с таким ID уже существует",
+ "Expected boolean": "Нужно логическое значение",
+ "Component already exists": "Компонент уже существует",
+ "Expected full component payload": "Нужны полные данные компонента",
+ "Scene entity limit reached": "Достигнут лимит объектов сцены",
+ "Only the active project scene is available": "Доступна только активная сцена проекта",
+ "Scene changed. Read editor.scene.get and refresh the revision.": "Сцена изменилась. Прочитайте её через editor.scene.get и обновите ревизию.",
+ "Choose an existing NPC, count 1–100 and finite spacing": "Выберите существующего NPC, количество от 1 до 100 и конечное расстояние",
+ "Spawn may create at most 100 entities including children": "За один раз можно создать не более 100 объектов вместе с дочерними",
+ "NPC requires a transform": "NPC нужен компонент трансформации",
+ "Use #RRGGBB or #RRGGBBAA": "Используйте #RRGGBB или #RRGGBBAA",
+ "Choose an existing registered color field": "Выберите существующее зарегистрированное поле цвета",
+ "Invalid command ID": "Некорректный ID команды",
+ "Invalid language": "Некорректный язык",
+ "Entity not found: {0}": "Объект не найден: {0}",
+ "Unsupported scene operation: {0}": "Операция со сценой не поддерживается: {0}",
+ "Unsupported local action: {0}": "Локальное действие не поддерживается: {0}",
+ "Choose a new project asset path ending in .{0}": "Выберите новый путь ассета проекта с расширением .{0}",
+ "Find": "Найти",
+ "Replace": "Заменить",
+ "Match case": "Учитывать регистр",
+ "Regular expression": "Регулярное выражение",
+ "Whole words": "Слова целиком",
+ "Replace all": "Заменить все",
+ "Close search": "Закрыть поиск",
+ "Go to line": "Перейти к строке",
+ "Go": "Перейти",
+ "Current match": "Текущее совпадение",
+ "On line": "В строке",
+ "Replaced match on line {0}": "Заменено совпадение в строке {0}",
+ "Replaced {0} matches": "Заменено совпадений: {0}",
+ "Source code": "Исходный код",
+ " Try again in {0} min.": " Попробуйте ещё раз через {0} мин.",
+ "Access granted.": "Доступ выдан.",
+ "Access revoked. New sign-ins and session renewals are blocked; existing access tokens expire within 15 minutes.": "Доступ отозван. Новый вход и продление сессий заблокированы; выданные токены перестанут действовать в течение 15 минут.",
+ "Access type": "Тип доступа",
+ "Account ID": "ID аккаунта",
+ "Account ID copied.": "ID аккаунта скопирован.",
+ "Account UUID": "UUID аккаунта",
+ "Ada Playground": "Ada Playground",
+ "Ada Playground — Create. Share. Play.": "Ada Playground — Создавайте. Делитесь. Играйте.",
+ "Ada Pro": "Ada Pro",
+ "Add its catalog details now. You can upload a playable build after the project is created.": "Заполните данные для каталога. Готовую сборку можно загрузить после создания страницы.",
+ "Admin": "Администрирование",
+ "Administrator access required": "Нужен доступ администратора",
+ "Administrators: {0}. Administrator access is configured by the server operator.": "Администраторы: {0}. Их доступ настраивает оператор сервера.",
+ "Allowed users and pending invitations": "Пользователи с доступом и ожидающие приглашения",
+ "Approve and publish": "Одобрить и опубликовать",
+ "Author: {0}": "Автор: {0}",
+ "Available until you unpublish it": "Доступна, пока вы не снимете её с публикации",
+ "Available until {0}": "Доступна до {0}",
+ "Back to sign in": "Вернуться ко входу",
+ "Block game": "Заблокировать игру",
+ "Blocked": "Заблокировано",
+ "Build active": "Сборка активна",
+ "Build is not ready yet. You can retry this upload.": "Сборка пока не готова. Можно повторить загрузку.",
+ "Build published. Replacing a temporary build keeps its link and expiry time.": "Сборка опубликована. При замене временной сборки ссылка и срок её действия сохраняются.",
+ "Change email": "Изменить почту",
+ "Checking the archive and preparing your game…": "Проверяем архив и готовим игру…",
+ "Choose a cover and up to five screenshots, each no larger than 5 MB.": "Выберите обложку и до пяти скриншотов размером не более 5 МБ каждый.",
+ "Cloud": "Cloud",
+ "Cloud Services are launching in selected countries first. You can create an account now. Payments, settings sync and publishing will become available when we launch in your region.": "Сервисы Cloud сначала появятся в отдельных странах. Аккаунт можно создать уже сейчас. Оплата, синхронизация настроек и публикация станут доступны после запуска в вашем регионе.",
+ "Cloud is temporarily unavailable": "Cloud временно недоступен",
+ "Cloud is temporarily unavailable. Please try again.": "Cloud временно недоступен. Попробуйте ещё раз.",
+ "Cloud navigation": "Навигация Cloud",
+ "Code sent. Check your email.": "Код отправлен. Проверьте почту.",
+ "Configured administrator": "Администратор из конфигурации",
+ "Continue to Ada Editor": "Продолжить в Ada Editor",
+ "Copy account ID": "Скопировать ID аккаунта",
+ "Could not grant access": "Не удалось выдать доступ",
+ "Could not load access list": "Не удалось загрузить список доступа",
+ "Could not revoke access": "Не удалось отозвать доступ",
+ "Create a game page": "Создать страницу игры",
+ "Create game page": "Создать страницу игры",
+ "Create preview link": "Создать ссылку предпросмотра",
+ "Create review preview": "Создать предпросмотр для проверки",
+ "Create your Ada account. Cloud Services will open soon in your region.": "Создайте аккаунт Ada. Сервисы Cloud скоро появятся в вашем регионе.",
+ "Create your first game page": "Создайте первую страницу игры",
+ "Current build": "Текущая сборка",
+ "Delete account": "Удалить аккаунт",
+ "Delete build": "Удалить сборку",
+ "Delete current build": "Удалить текущую сборку",
+ "Delete game page": "Удалить страницу игры",
+ "Delete the current build? The public link will stop working immediately.": "Удалить текущую сборку? Публичная ссылка сразу перестанет работать.",
+ "Delete your account, synced settings and game publications? Subscription cancellation is managed separately with your payment provider.": "Удалить аккаунт, синхронизированные настройки и опубликованные игры? Подписку нужно отменить отдельно у платёжного провайдера.",
+ "Devices and account access": "Устройства и доступ к аккаунту",
+ "Display name": "Отображаемое имя",
+ "Draft legal documents — preview only, not yet effective.": "Юридические документы пока являются проектом и ещё не вступили в силу.",
+ "Draft — not yet in effect": "Проект — не вступил в силу",
+ "Each game page keeps its catalog details and public releases.": "Страница игры хранит данные для каталога и опубликованные версии.",
+ "Editor settings": "Настройки редактора",
+ "Editor sign-in is unavailable": "Вход в редактор недоступен",
+ "Email": "Почта",
+ "Email invitation": "Приглашение по почте",
+ "Email or account ID": "Почта или ID аккаунта",
+ "Enter a reason so the author can fix this version.": "Укажите причину, чтобы автор мог исправить версию.",
+ "Enter the code sent to {0}.": "Введите код, отправленный на {0}.",
+ "Existing subscription": "Действующая подписка",
+ "Free": "Бесплатно",
+ "GDPR rights": "Права по GDPR",
+ "Game catalog test": "Тест в каталоге игр",
+ "Game package ZIP": "ZIP-пакет игры",
+ "Game page": "Страница игры",
+ "Game preview": "Предпросмотр игры",
+ "Game reports": "Жалобы на игры",
+ "Game versions awaiting review": "Версии игр на проверке",
+ "Go to account": "Открыть аккаунт",
+ "Grant access": "Выдать доступ",
+ "How we handle personal data: {0} · {1}": "Как мы обрабатываем персональные данные: {0} · {1}",
+ "I agree to the {0}": "Я принимаю {0}",
+ "Link another sign-in method": "Добавить способ входа",
+ "Make this version live": "Опубликовать эту версию",
+ "Manage App Store subscription": "Управлять подпиской App Store",
+ "NEW GAME PAGE": "НОВАЯ СТРАНИЦА ИГРЫ",
+ "New game page": "Новая страница игры",
+ "New uploads are not available in your region yet. You can still delete this project or its current build.": "Новые загрузки пока недоступны в вашем регионе. Вы можете удалить проект или его текущую сборку.",
+ "No active build": "Активной сборки нет",
+ "No active build. Upload a ZIP to create one.": "Активной сборки нет. Загрузите ZIP, чтобы создать её.",
+ "No game pages yet. Create one to upload your first public build.": "Страниц игр пока нет. Создайте страницу, чтобы загрузить первую публичную сборку.",
+ "No invitations or user grants. Only configured administrators can sign in.": "Приглашений и выданных доступов нет. Войти могут только администраторы из конфигурации.",
+ "No versions are waiting for review.": "Версий на проверке нет.",
+ "OTP Code": "Одноразовый код",
+ "Only approved versions can go live. You can restore a previous approved version.": "Публиковать можно только одобренные версии. Вы можете вернуть предыдущую одобренную версию.",
+ "Only existing organization operators can sign in.": "Войти могут только существующие операторы организации.",
+ "Only invited users can sign in to this test environment. Add an existing account ID or invite a new user by email. An email invitation becomes account access after the first verified sign-in.": "В тестовое окружение могут входить только приглашённые пользователи. Добавьте ID существующего аккаунта или пригласите нового пользователя по почте. После первого подтверждённого входа приглашение станет доступом к аккаунту.",
+ "Open Ada Editor": "Открыть Ada Editor",
+ "Open build": "Открыть сборку",
+ "Open build ↗": "Открыть сборку ↗",
+ "Open preview ↗": "Открыть предпросмотр ↗",
+ "Open review preview ↗": "Открыть предпросмотр для проверки ↗",
+ "PUBLICATIONS": "ПУБЛИКАЦИИ",
+ "Paid access until {0}": "Оплаченный доступ до {0}",
+ "Permanent game · reviewed before publishing": "Постоянная игра · проверка перед публикацией",
+ "Permanent game · version {0}": "Постоянная игра · версия {0}",
+ "Permanent versions": "Постоянные версии",
+ "Play in Ada Studio": "Играть в Ada Studio",
+ "Play in Ada Studio ↗": "Играть в Ada Studio ↗",
+ "Play preview in Ada Studio ↗": "Открыть предпросмотр в Ada Studio ↗",
+ "Please accept the Terms of Use before continuing.": "Примите условия использования, чтобы продолжить.",
+ "Previous builds": "Предыдущие сборки",
+ "Privacy Policy": "Политика конфиденциальности",
+ "Pro: one active build per project · five uploads per day · 10 GB game traffic per month": "Pro: одна активная сборка на проект · пять загрузок в день · 10 ГБ игрового трафика в месяц",
+ "Published": "Опубликовано",
+ "Publishing requires an active Pro subscription.": "Для публикации нужна действующая подписка Pro.",
+ "Reason for rejection": "Причина отклонения",
+ "Refresh access list": "Обновить список доступа",
+ "Reject version": "Отклонить версию",
+ "Remove this version and unpublish the game?": "Удалить эту версию и снять игру с публикации?",
+ "Remove this version permanently?": "Удалить эту версию без возможности восстановления?",
+ "Remove version": "Удалить версию",
+ "Request failed": "Не удалось выполнить запрос",
+ "Request failed ({0})": "Не удалось выполнить запрос ({0})",
+ "Return to Ada Editor": "Вернуться в Ada Editor",
+ "Return to Ada Store →": "Вернуться в Ada Store →",
+ "Review the game and its metadata. The unlisted preview expires in 10 minutes.": "Проверьте игру и её данные. Непубличный предпросмотр доступен в течение 10 минут.",
+ "Revoke": "Отозвать",
+ "Revoke access": "Отозвать доступ",
+ "Screenshots — up to five": "Скриншоты — до пяти",
+ "Secret link": "Секретная ссылка",
+ "Secret link · 48 hours": "Секретная ссылка · 48 часов",
+ "Select a ZIP up to 300 MB.": "Выберите ZIP размером до 300 МБ.",
+ "Send code": "Отправить код",
+ "Show synced settings": "Показать синхронизированные настройки",
+ "Sign in to Ada": "Войти в Ada",
+ "Sign in to Ada Editor": "Войти в Ada Editor",
+ "Sign in to the editor as {0}.": "Войти в редактор как {0}.",
+ "Sign in with Apple": "Войти через Apple",
+ "Sign in with Google": "Войти через Google",
+ "Signed in {0}": "Вход: {0}",
+ "Subscribe monthly": "Подписаться на месяц",
+ "Sync your editor settings for free. Pro adds web build publishing.": "Синхронизация настроек редактора бесплатна. Pro добавляет публикацию веб-сборок.",
+ "Tags, separated by commas": "Теги через запятую",
+ "Temporary catalog test · 96 hours": "Временный тест в каталоге · 96 часов",
+ "Terms of Use": "Условия использования",
+ "Test environment access": "Доступ к тестовому окружению",
+ "The request may have expired. Start sign-in again from Ada Editor.": "Срок запроса мог истечь. Начните вход заново из Ada Editor.",
+ "These builds were published before projects were introduced. You can keep using or delete them.": "Эти сборки опубликованы до появления проектов. Вы можете продолжить пользоваться ими или удалить их.",
+ "This test environment is invite-only. Ask an administrator to grant access to your account ID or email.": "Тестовое окружение доступно по приглашению. Попросите администратора выдать доступ вашему аккаунту или почте.",
+ "This unlisted preview link expires in 10 minutes. Share it only with reviewers.": "Непубличная ссылка предпросмотра действует 10 минут. Передавайте её только проверяющим.",
+ "Unexpected editor callback": "Неожиданный ответ редактора",
+ "Unpublish game": "Снять игру с публикации",
+ "Upload a new version": "Загрузить новую версию",
+ "Upload failed. Retry to reuse this upload.": "Не удалось загрузить файл. Повторите попытку, чтобы продолжить ту же загрузку.",
+ "Upload interrupted. Retry to reuse this upload.": "Загрузка прервана. Повторите попытку, чтобы продолжить ту же загрузку.",
+ "Upload new build": "Загрузить новую сборку",
+ "Upload progress": "Ход загрузки",
+ "Use another account": "Использовать другой аккаунт",
+ "Verify code": "Подтвердить код",
+ "Version approved and published.": "Версия одобрена и опубликована.",
+ "Version rejected. The author can see your reason.": "Версия отклонена. Автор увидит указанную причину.",
+ "Version submitted for review. Your approved version stays available while this one is checked.": "Версия отправлена на проверку. Пока идёт проверка, одобренная версия остаётся доступной.",
+ "Version {0} · {1}": "Версия {0} · {1}",
+ "Web game ZIP or AdaScript game package · 300 MB maximum": "ZIP веб-игры или пакет AdaScript · не более 300 МБ",
+ "Web payments are not available yet. App Store purchases are managed in Ada Editor.": "Веб-оплата пока недоступна. Покупками App Store можно управлять в Ada Editor.",
+ "Your account": "Ваш аккаунт",
+ "Your browser will return you to the editor. If it does not open automatically, use the link below.": "Браузер вернёт вас в редактор. Если он не откроется автоматически, воспользуйтесь ссылкой ниже.",
+ "Your editor settings and shared games, in one account.": "Настройки редактора и опубликованные игры в одном аккаунте.",
+ "approved": "одобрено",
+ "awaiting review": "на проверке",
+ "game page": "страница игры",
+ "game pages": "страницы игр",
+ "live": "опубликована",
+ "or continue with email": "или используйте почту",
+ "rejected": "отклонено",
+ "{0} {1}": "{0} {1}",
+ "{0} · version {1}": "{0} · версия {1}",
+ "{0} · {1}": "{0} · {1}",
+ "{0}: {1}": "{0}: {1}",
+ "Game pages: {0}": "Страниц игр: {0}",
+ "Created {0}": "Создано {0}",
+ "Animate 3D models and preview their motion.": "Анимируйте 3D-модели и смотрите их в движении.",
+ "Import and prepare 3D models for your game.": "Импортируйте и готовьте 3D-модели для игры.",
+ "Create materials, lighting and surface effects.": "Создавайте материалы, освещение и эффекты поверхностей.",
+ "Find, import and organize game assets.": "Находите, импортируйте и упорядочивайте ресурсы игры.",
+ "Create a game with an autonomous Studio workflow.": "Создавайте игру с самостоятельным помощником Studio.",
+ "Build, run and diagnose your project.": "Собирайте, запускайте и проверяйте проект.",
+ "Write and update your game’s AdaScript code.": "Пишите и обновляйте код игры на AdaScript.",
+ "Prepare a game for Community review.": "Подготовьте игру к проверке в Community.",
+ "Turn your idea into a playable game.": "Превратите идею в игру, в которую можно играть.",
+ "Check AdaScript symbols and code diagnostics.": "Проверяйте символы и ошибки кода AdaScript.",
+ "Create pixel art and animated sprites.": "Создавайте пиксельные рисунки и анимированные спрайты.",
+ "Generate 3D models from shapes and parameters.": "Создавайте 3D-модели из фигур по заданным параметрам.",
+ "Explore the project and choose the next step.": "Изучите проект и выберите следующий шаг.",
+ "Create and edit scene objects and components.": "Создавайте и меняйте объекты и компоненты сцены.",
+ "Add sound effects and music to your game.": "Добавляйте в игру звуки и музыку.",
+ "Find and fix problems in your game’s UI.": "Находите и исправляйте проблемы интерфейса игры.",
+ "Create interactive forms and UI controls.": "Создавайте интерактивные формы и элементы интерфейса.",
+ "Check how your game looks and behaves.": "Проверяйте внешний вид и работу игры.",
+ "Create voxel models for your game.": "Создавайте воксельные модели для игры.",
+ "Show interactive controls and previews in chat.": "Показывайте интерактивные элементы и предпросмотр в чате.",
+ "Project workflow": "Сценарий проекта",
+ "your Ada account": "вашу учётную запись Ada",
+ "3D mesh": "3D-сетка",
+ "Directional light": "Направленный свет",
+ "Point light": "Точечный свет",
+ "Spot light": "Прожектор",
+ "3D model": "3D-модель",
+ "Scene instance": "Экземпляр сцены",
+ "Background color": "Цвет фона",
+ "Light color": "Цвет света",
+ "Tint color": "Оттенок",
+ "Player startup failed": "Не удалось запустить Player",
+ "Selection deleted": "Выделенный текст удалён",
+ "Create a platformer where I jump between floating islands and collect stars.": "Создай платформер, где я прыгаю между летающими островами и собираю звёзды.",
+ "Create a racing game where I dodge obstacles and beat my best time.": "Создай гоночную игру, где я объезжаю препятствия и улучшаю своё время.",
+ "Create a puzzle game where I move blocks to open a path to the exit.": "Создай головоломку, где я двигаю блоки и открываю путь к выходу.",
+ "Create a cozy farming game where I grow plants and explore a small village.": "Создай уютную ферму, где я выращиваю растения и исследую маленькую деревню.",
+ "I want to make a game where I explore a mysterious island and discover hidden treasures.": "Хочу сделать игру, где я исследую таинственный остров и нахожу спрятанные сокровища.",
+ "A game about a robot that collects spare parts and builds its own little world.": "Игра про робота, который собирает детали и строит свой маленький мир.",
+ "A platformer where each level introduces a new obstacle and a secret to discover.": "Платформер, где на каждом уровне появляются новое препятствие и спрятанный секрет.",
+ "A racing game with short tracks, checkpoints and a timer.": "Гоночная игра с короткими трассами, контрольными точками и таймером.",
+ "A puzzle game with switches, moving blocks and a clear goal in every level.": "Головоломка с переключателями, подвижными блоками и понятной целью на каждом уровне.",
+ "Make a game where I explore a forest, collect crystals and unlock new paths.": "Сделай игру, где я исследую лес, собираю кристаллы и открываю новые пути.",
+ "Game dimension": "Размерность игры",
+ "Player mode": "Режим игроков",
+ "Single-player": "Для одного игрока",
+ "Multiplayer": "Мультиплеер",
+ "Tab to complete": "Tab — дополнить",
+ "Complete suggestion: {0}": "Дополнить: {0}",
+ "2D": "2D",
+ "3D": "3D",
+ "Accept suggestion": "Дополнить текст",
+ "Create a game where I explore a forest, collect crystals and unlock new paths.": "Создай игру, где я исследую лес, собираю кристаллы и открываю новые пути.",
+ "I want a game about exploring an island and finding treasures.": "Хочу игру про исследование острова и поиск сокровищ.",
+ "An adventure game where I explore ruins, solve puzzles and find my way home.": "Приключенческая игра, где я исследую руины, решаю загадки и ищу дорогу домой.",
+ "Depth, light and space": "Глубина, свет и пространство",
+ "Sprites and flat worlds": "Спрайты и плоские миры",
+ "An adventure of your own": "Ваше собственное приключение",
+ "Play together with others": "Играйте вместе с другими",
+ "Unsupported attachment. Choose an image, GLB/VOX model, audio or text file.": "Этот файл пока не поддерживается. Выберите картинку, модель GLB/VOX, аудио или текст.",
+ "Converted audio exceeds 16 MB. Choose a shorter recording.": "После преобразования аудио занимает больше 16 МБ. Выберите запись покороче.",
+ "Choose mono or stereo audio.": "Выберите моно- или стереозапись.",
+ "Audio conversion is unavailable in this browser. Choose WAV or OGG.": "В этом браузере нельзя преобразовать аудио. Выберите WAV или OGG.",
+ "Could not read this audio file. Choose WAV or OGG, or try another recording.": "Не удалось прочитать аудиофайл. Выберите WAV или OGG либо другую запись.",
+ "Could not read this image. Choose a PNG, JPEG or WebP file.": "Не удалось прочитать картинку. Выберите файл PNG, JPEG или WebP.",
+ "Image processing is unavailable in this browser.": "В этом браузере недоступна обработка картинок.",
+ "Could not prepare this image for the assistant. Try a smaller image.": "Не удалось подготовить картинку для ассистента. Попробуйте картинку поменьше.",
+ "An attachment can be up to 16 MB.": "Размер вложения — до 16 МБ.",
+ "Wait for the current operation before attaching files.": "Дождитесь завершения текущего действия, чтобы добавить файлы.",
+ "Attachments exceed 63 MB in total.": "Общий размер вложений превышает 63 МБ.",
+ "Use the attached files in this game.": "Используй приложенные файлы в этой игре.",
+ "Invalid attachment details": "Некорректные сведения о вложении",
+ "Attached files": "Прикреплённые файлы",
+ "{0} MB": "{0} МБ",
+ "Converted for your project": "Преобразовано для проекта",
+ "Remove attachment {0}": "Убрать вложение {0}",
+ "Drop files to attach": "Перетащите сюда файлы",
+ "Preparing files…": "Готовим файлы…",
"Morph Targets": "Цели морфинга",
"MORPH TARGETS": "ЦЕЛИ МОРФИНГА",
"Loading morph target model…": "Загрузка модели с целями морфинга…",
@@ -1664,5 +2369,13 @@
"Applied to scene. Save the scene to keep these weights.": "Применено к сцене. Сохраните сцену, чтобы сохранить эти веса.",
"Morph weights cleared.": "Веса морфинга сброшены.",
"Morph weights applied": "Веса морфинга применены",
- "Morph weights cleared": "Веса морфинга сброшены"
+ "Morph weights cleared": "Веса морфинга сброшены",
+ "Sprite Animation": "Анимация спрайта",
+ "Add Clip": "Добавить клип",
+ "Add Row Frames": "Добавить кадры из ряда",
+ "Import from Aseprite": "Импортировать из Aseprite",
+ "Export the sheet with Trim off and a JSON array. Replaces the image and clips.": "Экспортируйте лист спрайтов с выключенным Trim и JSON-массивом. Изображение и клипы будут заменены.",
+ "Choose sprite animation…": "Выберите анимацию спрайта…",
+ "Search sprite animations": "Поиск анимаций спрайтов",
+ "Create a Sprite Animation resource in Assets first.": "Сначала создайте ресурс анимации спрайта в разделе «Ресурсы»."
}
diff --git a/Editor/Sources/AdaEditor/Cloud/EditorAICreditsModel.swift b/Editor/Sources/AdaEditor/Cloud/EditorAICreditsModel.swift
index f69115fdc..8ed9190e4 100644
--- a/Editor/Sources/AdaEditor/Cloud/EditorAICreditsModel.swift
+++ b/Editor/Sources/AdaEditor/Cloud/EditorAICreditsModel.swift
@@ -50,6 +50,8 @@ final class EditorAICreditsModel {
if catalog?.capabilities.isEmpty == true {
return "Hosted AI will open soon."
}
+ if balance?.allowance == 0 { return "Cloud Pro includes 500 AI credits per month. Subscribe to use Ada AI." }
+ if balance?.available == 0 { return "Your AI credits are used up. They renew with your monthly Cloud Pro allowance." }
return "AI credits are shared across your Ada Studio apps."
}
}
diff --git a/Editor/Sources/AdaEditor/Cloud/EditorAICreditsView.swift b/Editor/Sources/AdaEditor/Cloud/EditorAICreditsView.swift
index 13f1cfef8..dc340e5f4 100644
--- a/Editor/Sources/AdaEditor/Cloud/EditorAICreditsView.swift
+++ b/Editor/Sources/AdaEditor/Cloud/EditorAICreditsView.swift
@@ -56,6 +56,8 @@ struct EditorAICreditsDetails: View {
var body: some View {
VStack(alignment: .leading, spacing: 8) {
+ Text(L10n.text("Cloud Pro includes 500 AI credits per month, shared across your devices."))
+ .font(.system(size: 12)).foregroundColor(theme.editorColors.muted)
Text(L10n.text("AI credits"))
.font(.system(size: 15, weight: .bold))
.foregroundColor(theme.editorColors.text)
@@ -74,7 +76,7 @@ struct EditorAICreditsDetails: View {
.foregroundColor(theme.editorColors.muted)
}
}
- Text(model.summary)
+ Text(L10n.tr(model.summary))
.font(.system(size: 12))
.foregroundColor(theme.editorColors.muted)
.lineLimit(3)
@@ -116,7 +118,7 @@ struct EditorAICreditsDetails: View {
}
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityIdentifier("AdaEditor.Cloud.AICredits.Details")
- .onAppear { loadHistory() }
+ .onAppear { refresh() }
}
private func activityTitle(_ kind: String) -> String {
diff --git a/Editor/Sources/AdaEditor/Cloud/EditorCloudAIStream.swift b/Editor/Sources/AdaEditor/Cloud/EditorCloudAIStream.swift
new file mode 100644
index 000000000..e420ba03c
--- /dev/null
+++ b/Editor/Sources/AdaEditor/Cloud/EditorCloudAIStream.swift
@@ -0,0 +1,20 @@
+import Foundation
+
+typealias EditorCloudAITransport = @Sendable (EditorCloudValue, @escaping @Sendable (EditorCloudValue) async throws -> Void) async throws -> Void
+
+/// Parses only our server's normalized SSE contract; upstream provider data stays on Cloud.
+struct EditorCloudAIStream {
+ private(set) var complete = false
+ mutating func event(line: String) throws -> EditorCloudValue? {
+ guard line.utf8.count <= 2 * 1024 * 1024 else { throw EditorAIError.invalidResponse }
+ guard line.hasPrefix("data:") else { return nil }
+ guard !complete else { throw EditorAIError.invalidResponse }
+ let data = Data(line.dropFirst(5).trimmingCharacters(in: .whitespaces).utf8)
+ let event = try JSONDecoder().decode(EditorCloudValue.self, from: data)
+ guard let type = event["type"].string, ["text", "tool", "usage", "complete", "error"].contains(type) else { throw EditorAIError.invalidResponse }
+ if type == "error" { throw EditorCloudAccount.CloudError.message("Generation interrupted. Your saved project is available; check your AI credits before continuing.") }
+ if type == "complete" { complete = true }
+ return event
+ }
+ func finish() throws { guard complete else { throw EditorAIError.invalidResponse } }
+}
diff --git a/Editor/Sources/AdaEditor/Cloud/EditorCloudAccount.swift b/Editor/Sources/AdaEditor/Cloud/EditorCloudAccount.swift
index 50cddb384..44273a31a 100644
--- a/Editor/Sources/AdaEditor/Cloud/EditorCloudAccount.swift
+++ b/Editor/Sources/AdaEditor/Cloud/EditorCloudAccount.swift
@@ -549,6 +549,89 @@ final class EditorCloudAccount: NSObject {
}
return result
}
+ /// A turn-scoped transport: switching accounts or servers invalidates every later tool round.
+ func hostedAIGenerator() async throws -> EditorCloudAITransport {
+ let revision = sessionRevision
+ guard accountID != nil else { throw CloudError.message("Sign in to Ada Cloud to use AI credits.") }
+ let catalog = try await aiCatalog()
+ guard catalog.enabled, catalog.capabilities.contains("llm") else { throw CloudError.message("Ada AI is not available on this server yet.") }
+ let providers = try await request("/ai/providers")
+ guard providers.array.contains(where: { $0["id"].string == "openrouter" && $0["hosted"].bool == true }) else {
+ throw CloudError.message("Ada AI is not available on this server yet.")
+ }
+ let balance = try await aiClient().balance()
+ guard !balance.suspended, !balance.closed else { throw CloudError.message("Ada AI is unavailable for this account.") }
+ guard balance.allowance > 0 else { throw CloudError.message("Cloud Pro includes 500 AI credits per month. Subscribe to use Ada AI.") }
+ guard balance.available > 0 else { throw CloudError.message("Your AI credits are used up. They renew with your monthly Cloud Pro allowance.") }
+ guard sessionRevision == revision else { throw EditorAIError.sessionChanged }
+ return { [weak self] input, receive in
+ guard let self else { throw EditorAIError.sessionChanged }
+ try await self.generateHostedAI(input, revision: revision, receive: receive)
+ }
+ }
+
+ private func generateHostedAI(_ input: EditorCloudValue, revision: UUID,
+ receive: @escaping @Sendable (EditorCloudValue) async throws -> Void) async throws {
+ guard sessionRevision == revision else { throw EditorAIError.sessionChanged }
+ // Refresh authentication through a read-only call. Never automatically retry a paid submission.
+ _ = try await request("/ai/providers")
+ guard sessionRevision == revision, let token = credentials["accessToken"].string,
+ let base = URL(string: server), Self.isAllowedCloudURL(base),
+ let url = URL(string: server.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/v1/ai/native/generate") else {
+ throw EditorAIError.sessionChanged
+ }
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.timeoutInterval = 180
+ request.setValue("Bearer " + token, forHTTPHeaderField: "Authorization")
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.setValue("text/event-stream", forHTTPHeaderField: "Accept")
+ request.httpBody = try JSONEncoder().encode(input)
+ var parser = EditorCloudAIStream()
+ do {
+ #if canImport(FoundationNetworking)
+ let (data, response) = try await URLSession.shared.data(for: request)
+ try Self.validateAIHTTP(response, data: data)
+ let lines = String(decoding: data, as: UTF8.self).components(separatedBy: .newlines)
+ for line in lines {
+ try Task.checkCancellation()
+ guard sessionRevision == revision else { throw EditorAIError.sessionChanged }
+ if let event = try parser.event(line: line) { try await receive(event) }
+ }
+ #else
+ let (bytes, response) = try await URLSession.shared.bytes(for: request)
+ try Self.validateAIHTTP(response)
+ for try await line in bytes.lines {
+ try Task.checkCancellation()
+ guard sessionRevision == revision else { throw EditorAIError.sessionChanged }
+ if let event = try parser.event(line: line) { try await receive(event) }
+ }
+ #endif
+ try parser.finish()
+ } catch {
+ if sessionRevision == revision { await refreshAICredits(force: true) }
+ throw error
+ }
+ guard sessionRevision == revision else { throw EditorAIError.sessionChanged }
+ await refreshAICredits(force: true)
+ }
+
+ private static func validateAIHTTP(_ response: URLResponse, data: Data? = nil) throws {
+ guard let http = response as? HTTPURLResponse else { throw EditorAIError.invalidResponse }
+ guard http.statusCode == 200 else {
+ let message: String
+ switch http.statusCode {
+ case 401: message = "Sign in to Ada Cloud to use AI credits."
+ case 402: message = "Not enough AI credits for this request. Check your balance in Cloud settings."
+ case 403: message = "Ada AI is unavailable for this account."
+ case 429: message = "Too many AI requests. Please wait and try again."
+ default: message = "Ada AI is not available on this server yet."
+ }
+ throw CloudError.http(http.statusCode, message)
+ }
+ guard http.value(forHTTPHeaderField: "Content-Type")?.hasPrefix("text/event-stream") == true else { throw EditorAIError.invalidResponse }
+ }
+
private func invalidateAISession() {
sessionRevision = UUID()
refreshTask?.cancel()
diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorAgentCatalogView.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorAgentCatalogView.swift
index a0b7bfe66..ceb528ec6 100644
--- a/Editor/Sources/AdaEditor/UI/Editor/EditorAgentCatalogView.swift
+++ b/Editor/Sources/AdaEditor/UI/Editor/EditorAgentCatalogView.swift
@@ -22,6 +22,25 @@ struct EditorAgentCatalogView: View {
EditorAgentCatalogToolbar(agent: agent)
}
}
+ VStack(alignment: .leading, spacing: 8) {
+ Text(L10n.text("Ada AI · Coder Next")).font(.system(size: 14, weight: .semibold)).foregroundColor(theme.editorColors.text)
+ Text(L10n.text("Cloud Pro includes 500 AI credits per month, shared across your devices."))
+ .font(.system(size: 12)).foregroundColor(theme.editorColors.muted)
+ EditorAICreditsBadge()
+ HStack {
+ Button(L10n.text("Use Ada AI")) { Task { await agent.connectCatalogAgent(installed: EditorCloudAgentRouter.entry) } }
+ .buttonStyle(actionButtonStyle)
+ .disabled(!agent.canConnectCatalogAgent)
+ .accessibilityIdentifier("AdaEditor.Agents.UseCloudAI")
+ if EditorCloudAccount.shared.accountID == nil {
+ Button(L10n.text("Sign in to Ada Cloud")) { EditorCloudAccount.shared.perform { try await EditorCloudAccount.shared.signInOnWebsite() } }
+ } else if !EditorCloudAccount.shared.pro, EditorCloudAccount.shared.billingAvailable {
+ Button(L10n.text("Subscribe to Cloud Pro")) { EditorCloudAccount.shared.perform { try await EditorCloudAccount.shared.buyPro() } }
+ }
+ }
+ }
+ .padding(12)
+ .background(RoundedRectangleShape(cornerRadius: 10).fill(theme.editorColors.surface))
#if os(macOS) || os(Windows)
Text(L10n.text("Choose an agent for all projects, then open Agent Chat. Sign in through the agent's own account setup."))
.font(.system(size: 11)).foregroundColor(theme.editorColors.muted)
diff --git a/Editor/Sources/AdaEditor/UI/Mobile/MobileEditorAgentSettingsScreen.swift b/Editor/Sources/AdaEditor/UI/Mobile/MobileEditorAgentSettingsScreen.swift
index e99bea1d2..1c28da6d5 100644
--- a/Editor/Sources/AdaEditor/UI/Mobile/MobileEditorAgentSettingsScreen.swift
+++ b/Editor/Sources/AdaEditor/UI/Mobile/MobileEditorAgentSettingsScreen.swift
@@ -34,6 +34,30 @@ struct MobileEditorAgentSettingsScreen: View {
.foregroundColor(theme.editorColors.muted)
modelSelection
+ MobileEditorCard {
+ VStack(alignment: .leading, spacing: 12) {
+ Text(L10n.text("Ada AI · Coder Next")).font(MobileEditorFont.navigationFont(size: 20)).foregroundColor(theme.editorColors.text)
+ EditorAICreditsDetails()
+ if modelStore.selectedProvider == .ada, !modelStore.errorMessage.isEmpty {
+ Text(L10n.tr(modelStore.errorMessage)).font(MobileEditorFont.font(size: 13)).foregroundColor(theme.editorColors.text).lineLimit(4)
+ .accessibilityIdentifier("AdaEditor.Mobile.CloudAIError")
+ }
+ MobileEditorPrimaryButton(title: "Use Ada AI") { _ = modelStore.useCloudAI() }
+ .accessibilityIdentifier("AdaEditor.Mobile.UseCloudAI")
+ if EditorCloudAccount.shared.accountID == nil {
+ MobileEditorPrimaryButton(title: "Sign in to Ada Cloud") {
+ EditorCloudAccount.shared.perform { try await EditorCloudAccount.shared.signInOnWebsite() }
+ }
+ } else if !EditorCloudAccount.shared.pro, EditorCloudAccount.shared.billingAvailable {
+ MobileEditorPrimaryButton(title: "Subscribe to Cloud Pro") {
+ EditorCloudAccount.shared.perform { try await EditorCloudAccount.shared.buyPro() }
+ }
+ }
+ if !EditorCloudAccount.shared.status.isEmpty {
+ Text(L10n.tr(EditorCloudAccount.shared.status)).font(MobileEditorFont.font(size: 12)).foregroundColor(theme.editorColors.muted).lineLimit(3)
+ }
+ }.padding(17)
+ }
MobileEditorCard {
EditorSloppyInviteSettingsView(catalog: sloppyInvites)
.padding(17)
diff --git a/Editor/Sources/AdaEditor/UI/Mobile/MobileEditorChatState.swift b/Editor/Sources/AdaEditor/UI/Mobile/MobileEditorChatState.swift
index 43ff9f282..2165d2f57 100644
--- a/Editor/Sources/AdaEditor/UI/Mobile/MobileEditorChatState.swift
+++ b/Editor/Sources/AdaEditor/UI/Mobile/MobileEditorChatState.swift
@@ -81,6 +81,17 @@ final class MobileEditorChatState {
return true
}
+ func selectProvider(cloud: Bool, for id: UUID) {
+ guard runningProjectID != id else { return }
+ var conversation = conversations[id] ?? Conversation()
+ guard conversation.sessionID.hasPrefix("cloud-") != cloud else { return }
+ conversation.sessionID = (cloud ? "cloud-" : "session-") + UUID().uuidString.lowercased()
+ conversation.events = []
+ conversation.previousSession = nil
+ conversation.didCheckPreviousSession = true
+ conversations[id] = conversation
+ }
+
func begin(_ id: UUID, agentIdentity: String? = nil) {
interfaces.controller.sessions[sessionID(for: id)]?.beginRun(agentIdentity: agentIdentity)
runningProjectID = id
diff --git a/Editor/Sources/AdaEditor/UI/Mobile/MobileEditorHomeScreens.swift b/Editor/Sources/AdaEditor/UI/Mobile/MobileEditorHomeScreens.swift
index 37d9d8ec8..73c3a0498 100644
--- a/Editor/Sources/AdaEditor/UI/Mobile/MobileEditorHomeScreens.swift
+++ b/Editor/Sources/AdaEditor/UI/Mobile/MobileEditorHomeScreens.swift
@@ -179,7 +179,7 @@ struct MobileEditorSettingsScreen: View {
return credentials.model.isEmpty ? "OpenAI API key saved" : "\(credentials.model) configured"
}
if let agentStatus, !agentStatus.isEmpty { return agentStatus }
- return "Connect Codex or configure an OpenAI model"
+ return "Use Ada AI with Cloud Pro, Codex, or your own API key"
}
}
diff --git a/Editor/Sources/AdaEditor/UI/Mobile/MobileEditorProviderModelStore.swift b/Editor/Sources/AdaEditor/UI/Mobile/MobileEditorProviderModelStore.swift
index 75a09ef6f..5649f649c 100644
--- a/Editor/Sources/AdaEditor/UI/Mobile/MobileEditorProviderModelStore.swift
+++ b/Editor/Sources/AdaEditor/UI/Mobile/MobileEditorProviderModelStore.swift
@@ -42,15 +42,23 @@ final class MobileEditorProviderModelStore {
apiURL = credentials.apiURL
apiKey = credentials.apiKey
modelID = credentials.model
- let initialProvider = credentials.provider ?? (MobileCodexCredentialStore.load() == nil ? .api : .codex)
+ let initialProvider = credentials.provider ?? (MobileCodexCredentialStore.load() != nil ? .codex : credentials.apiKey.isEmpty ? .ada : .api)
provider = initialProvider
selectedProvider = initialProvider
selectedModelID = credentials.model
selectedTitle = credentials.model
+ if initialProvider == .ada { modelID = Self.cloudModel.id; selectedModelID = modelID; selectedTitle = Self.cloudModel.title; models = [Self.cloudModel] }
}
- var providerTitle: String { provider == .codex ? "Codex" : "API key" }
- var selectedProviderTitle: String { selectedProvider == .codex ? "Codex" : "API key" }
+ static let cloudModel = EditorProviderModel(id: "qwen/qwen3-coder-next", title: "Coder Next")
+ @discardableResult func useCloudAI() -> Bool {
+ switchProvider(to: .ada)
+ modelID = Self.cloudModel.id; models = [Self.cloudModel]; errorMessage = ""
+ return save()
+ }
+ func reportCloudError(_ message: String) { errorMessage = message }
+ var providerTitle: String { provider == .ada ? "Ada AI · Cloud Pro" : provider == .codex ? "Codex" : "API key" }
+ var selectedProviderTitle: String { selectedProvider == .ada ? "Ada AI · Cloud Pro" : selectedProvider == .codex ? "Codex" : "API key" }
var isCodexConnected: Bool { MobileCodexCredentialStore.load() != nil }
func switchProvider(to selected: MobileAgentProvider) {
@@ -112,6 +120,7 @@ final class MobileEditorProviderModelStore {
}
func reload() {
+ if provider == .ada { models = [Self.cloudModel]; modelID = Self.cloudModel.id; errorMessage = ""; return }
cancel()
let generation = requestID
let providerForRequest = provider
diff --git a/Editor/Sources/AdaEditor/UI/Mobile/MobileEditorRootView.swift b/Editor/Sources/AdaEditor/UI/Mobile/MobileEditorRootView.swift
index 758d47472..7710ed0ab 100644
--- a/Editor/Sources/AdaEditor/UI/Mobile/MobileEditorRootView.swift
+++ b/Editor/Sources/AdaEditor/UI/Mobile/MobileEditorRootView.swift
@@ -52,6 +52,9 @@ struct MobileEditorRootView: View {
@State private var didPrepareImageQA = false
@State private var didPrepareBackgroundQA = false
@State private var didPrepareVoiceLayoutQA = false
+ @State private var cloudAgent: EditorCloudAgentService?
+ @State private var cloudPermission: EditorAgentPermissionRequest?
+ @State private var cloudAdmitting = false
@State private var sloppyRuntime = SloppyRuntimeHost()
@State private var chatState = MobileEditorChatState()
@State private var agentOperation: MobileEditorAgentOperation?
@@ -97,6 +100,21 @@ struct MobileEditorRootView: View {
)
.mobileEditorSafeAreaContent(.bottom)
.background(theme.editorColors.background.ignoresSafeArea())
+ .overlay {
+ if let permission = cloudPermission {
+ MobileEditorCard {
+ VStack(alignment: .leading, spacing: 12) {
+ Text(L10n.text("Allow project change?")).font(MobileEditorFont.navigationFont(size: 20)).foregroundColor(theme.editorColors.text)
+ Text(permission.summary).font(MobileEditorFont.font(size: 13)).foregroundColor(theme.editorColors.muted).lineLimit(4)
+ HStack(spacing: 12) {
+ MobileEditorPrimaryButton(title: "Allow once") { resolveCloudPermission("allow") }
+ MobileEditorPrimaryButton(title: "Reject") { resolveCloudPermission("deny") }
+ }
+ }.padding(17)
+ }.padding(20).frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
+ .accessibilityIdentifier("AdaEditor.Mobile.CloudPermission")
+ }
+ }
.onAppear {
#if os(Android)
AndroidStudioHost.refreshInsets()
@@ -175,7 +193,7 @@ struct MobileEditorRootView: View {
submit: {
guard let id = activeChatProjectID else { return false }
submitPrompt(for: id)
- return isAgentRunning
+ return isAgentRunning || cloudAdmitting
},
openKeyboard: {
isVoicePresented = false
@@ -417,7 +435,7 @@ struct MobileEditorRootView: View {
}
case .sessions(let id):
if let directory = try? MobileAdaScriptProjectService.projectURL(for: id) {
- MobileEditorSessionsScreen(workspaceURL: directory) { sessionID in
+ MobileEditorSessionsScreen(workspaceURL: directory, cloud: providerModels.selectedProvider == .ada) { sessionID in
navigationPath.append(MobileEditorDestination.session(id, sessionID))
}
.navigationTitle(L10n.text("Previous sessions"))
@@ -507,10 +525,16 @@ struct MobileEditorRootView: View {
submitPrompt(for: id)
}
- private func submitPrompt(for id: UUID, a2uiSubmission: EditorAgentA2UISubmission? = nil) {
- var started = false
+ private func resolveCloudPermission(_ choice: String) {
+ guard let permission = cloudPermission, let service = cloudAgent else { return }
+ cloudPermission = nil
+ Task { await service.resolvePermission(requestID: permission.id, optionID: choice) }
+ }
+
+ private func submitPrompt(for id: UUID, a2uiSubmission: EditorAgentA2UISubmission? = nil, cloudAdmitted: EditorCloudAITransport? = nil) {
+ var started = false, deferredAdmission = false
defer {
- if !started, let a2uiSubmission {
+ if !started, !deferredAdmission, let a2uiSubmission {
chatState.interfaces.controller.sessions[a2uiSubmission.sessionID]?.rejectSubmission(
agentStatus ?? "The agent could not start. Check its connection settings.", surfaceID: a2uiSubmission.surfaceID)
}
@@ -520,15 +544,48 @@ struct MobileEditorRootView: View {
let index = projects.firstIndex(where: { $0.id == id }) else {
return
}
- let credentials = MobileSloppyCredentialStore.load()
+ let credentials = MobileSloppyCredentialStore.load() ?? MobileSloppyCredentials(model: MobileEditorProviderModelStore.cloudModel.id, provider: .ada)
let codexCredentials = MobileCodexCredentialStore.load()
- let usesCodex = credentials?.provider.map { $0 == .codex } ?? (codexCredentials != nil)
- guard (usesCodex ? codexCredentials != nil : credentials?.apiKey.isEmpty == false),
- let credentials, !credentials.model.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
+ let usesAda = credentials.provider == .ada
+ let usesCodex = credentials.provider.map { $0 == .codex } ?? (codexCredentials != nil)
+ guard (usesAda || (usesCodex ? codexCredentials != nil : !credentials.apiKey.isEmpty)),
+ !credentials.model.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
agentStatus = nil
configureAgent()
return
}
+ if usesAda, cloudAdmitted == nil {
+ guard !cloudAdmitting else { return }
+ cloudAdmitting = true; deferredAdmission = true
+ let draft = promptDraft.text
+ let cloudOwner = EditorCloudAccount.shared.accountID, cloudServer = EditorCloudAccount.shared.server
+ Task { @MainActor in
+ var continued = false
+ defer {
+ cloudAdmitting = false
+ if !continued, let a2uiSubmission {
+ chatState.interfaces.controller.sessions[a2uiSubmission.sessionID]?.rejectSubmission(
+ "The agent could not start. Check its connection settings.", surfaceID: a2uiSubmission.surfaceID)
+ }
+ }
+ do {
+ let transport = try await EditorCloudAccount.shared.hostedAIGenerator()
+ guard EditorCloudAccount.shared.accountID == cloudOwner, EditorCloudAccount.shared.server == cloudServer,
+ activeChatProjectID == id, !isAgentRunning,
+ a2uiSubmission != nil || promptDraft.text == draft,
+ (MobileSloppyCredentialStore.load()?.provider ?? .ada) == .ada else { return }
+ continued = true
+ submitPrompt(for: id, a2uiSubmission: a2uiSubmission, cloudAdmitted: transport)
+ } catch {
+ guard providerModels.selectedProvider == .ada else { return }
+ agentStatus = L10n.tr(error.localizedDescription)
+ providerModels.reportCloudError(error.localizedDescription)
+ isVoicePresented = false
+ navigationPath.append(MobileEditorDestination.providers)
+ }
+ }
+ return
+ }
let workspaceURL: URL
do {
workspaceURL = try MobileAdaScriptProjectService.prepare(projects[index])
@@ -546,6 +603,9 @@ struct MobileEditorRootView: View {
return SloppyImageInput(data: try EditorImageAttachment.pngData(at: url), relativePath: path)
}
try SloppyRuntimeHost.validateImages(imageInputs)
+ if usesAda, !imageInputs.isEmpty {
+ throw EditorAgentServiceError.providerFailure(L10n.text("Coder Next supports text. Choose a vision model to send images."))
+ }
} catch {
agentStatus = error.localizedDescription
return
@@ -556,6 +616,7 @@ struct MobileEditorRootView: View {
} else {
outgoingPrompt = value + "\n\nAttached files in the project:\n" + attachmentPaths.map { "- \($0)" }.joined(separator: "\n")
}
+ chatState.selectProvider(cloud: usesAda, for: id)
projects[index].prompt = value
do {
try MobileAdaScriptProjectService.saveProjects(projects)
@@ -628,6 +689,33 @@ struct MobileEditorRootView: View {
harnessSettings: runSettings
)
defer { toolService.finish() }
+ if usesAda {
+ let service = EditorCloudAgentService(transportFactory: {
+ guard let cloudAdmitted else { throw EditorAIError.sessionChanged }
+ return cloudAdmitted
+ }, toolsFactory: { url in
+ try await MainActor.run { try EditorCloudToolHost(projectURL: url, mobile: toolService).tools() }
+ })
+ cloudAgent = service
+ let project = try ProjectSystem.loadProject(at: workspaceURL)
+ let request = EditorAgentRunRequest(project: project, projectURL: workspaceURL,
+ session: .init(id: sessionID, events: chatState.events(for: id)), mode: .build,
+ prompt: MobileEditorAgentContext.prompt(runSettings.prompt(outgoingPrompt), visiblePrompt: outgoingPrompt, a2uiAction: a2uiSubmission?.event),
+ attachments: [], sceneContext: nil, codeSelection: nil, skills: [], a2uiEnabled: true)
+ _ = try await service.send(request, onEvent: { event in
+ await MainActor.run {
+ chatState.record(event, for: id)
+ if let permission = event.permission {
+ isVoicePresented = false; isPromptPresented = false
+ cloudPermission = permission
+ }
+ if event.kind == .toolCall { operation.completedStep() }
+ }
+ }, onProjectFileChanged: { _ in })
+ try Task.checkCancellation()
+ let validation = await toolService.validatePrototype()
+ return .init(succeeded: validation.ok, detail: validation.ok ? "Done" : validation.payload)
+ }
sloppyRuntime = SloppyRuntimeHost(additionalTools: try MobileEditorAgentToolBridge.definitions())
if usesCodex, let codexCredentials {
try await sloppyRuntime.configureCodex(
@@ -731,7 +819,14 @@ struct MobileEditorRootView: View {
},
finished: { state, detail in
chatState.finish(id, succeeded: state == .completed, status: detail, cancelled: state == .cancelled || state == .interrupted, submission: a2uiSubmission)
- await loadChat(for: id, sessionID: sessionID)
+ if usesAda {
+ cloudPermission = nil; cloudAgent = nil
+ do {
+ var session = EditorAgentSession(id: sessionID, title: String(value.prefix(80)), events: chatState.events(for: id))
+ session.agentTargetIdentity = mobileAgentIdentity
+ try await EditorAgentSessionStore(projectURL: workspaceURL).saveSession(session, makeActive: true)
+ } catch { agentStatus = error.localizedDescription }
+ } else { await loadChat(for: id, sessionID: sessionID) }
if state != .completed, chatState.sessionID(for: id) == sessionID {
chatState.record(EditorAgentEvent(kind: .runStatus, title: detail, isSuccessful: false), for: id)
}
@@ -740,7 +835,8 @@ struct MobileEditorRootView: View {
}
private var mobileAgentIdentity: String? {
- guard let credentials = MobileSloppyCredentialStore.load() else { return nil }
+ let credentials = MobileSloppyCredentialStore.load() ?? MobileSloppyCredentials(model: MobileEditorProviderModelStore.cloudModel.id, provider: .ada)
+ if credentials.provider == .ada { return "ada-cloud:" + EditorCloudAccount.shared.server + ":" + (EditorCloudAccount.shared.accountID ?? "signedout") }
let encoder = JSONEncoder()
encoder.outputFormatting = .sortedKeys
guard let data = try? encoder.encode(credentials) else { return nil }
@@ -812,7 +908,7 @@ struct MobileEditorRootView: View {
do {
let directory = try MobileAdaScriptProjectService.projectURL(for: id)
let previous = try await MobileEditorSessionStore(workspaceURL: directory).list()
- .first { $0.id != sessionID && $0.messageCount > 0 }
+ .first { $0.id != sessionID && $0.messageCount > 0 && $0.id.hasPrefix("cloud-") == (providerModels.selectedProvider == .ada) }
chatState.setPreviousSession(previous.map { .init(id: $0.id, title: $0.title) }, for: id, sessionID: sessionID)
} catch {
if activeChatProjectID == id { agentStatus = error.localizedDescription }
diff --git a/Editor/Sources/AdaEditor/UI/Mobile/MobileEditorSessionsScreen.swift b/Editor/Sources/AdaEditor/UI/Mobile/MobileEditorSessionsScreen.swift
index cc109909f..2e079f922 100644
--- a/Editor/Sources/AdaEditor/UI/Mobile/MobileEditorSessionsScreen.swift
+++ b/Editor/Sources/AdaEditor/UI/Mobile/MobileEditorSessionsScreen.swift
@@ -19,19 +19,29 @@ actor MobileEditorSessionStore {
private var agentsURL: URL { workspaceURL.appendingPathComponent(".ada/workspace/agents", isDirectory: true) }
- func list() throws -> [MobileEditorSessionSummary] {
- guard FileManager.default.fileExists(atPath: agentsURL.appendingPathComponent("mobile").path) else { return [] }
- let store = AgentSessionFileStore(agentsRootURL: agentsURL)
- return try store.listSessions(agentID: "mobile").map { summary in
- let detail = try store.loadSession(agentID: "mobile", sessionID: summary.id)
- let messages = MobileSloppyChatAdapter.events(from: detail.events).compactMap(\.message)
- let title = messages.first(where: { $0.role == .user })?.segments.first?.text ?? summary.title
- let preview = messages.last?.segments.first?.text ?? ""
- return MobileEditorSessionSummary(
- id: summary.id, title: String(title.prefix(80)), updatedAt: summary.updatedAt,
- messageCount: messages.count, preview: String(preview.prefix(200))
- )
+ func list() async throws -> [MobileEditorSessionSummary] {
+ let native = EditorAgentSessionStore(projectURL: workspaceURL)
+ var result: [MobileEditorSessionSummary] = []
+ for summary in try await native.listSessions() where Self.cloudID(summary.id) {
+ let session = try await native.loadSession(id: summary.id)
+ let messages = session.events.compactMap(\.message)
+ result.append(.init(id: summary.id, title: summary.title, updatedAt: summary.updatedAt,
+ messageCount: messages.count, preview: String((messages.last?.segments.first?.text ?? "").prefix(200))))
}
+ if FileManager.default.fileExists(atPath: agentsURL.appendingPathComponent("mobile").path) {
+ let store = AgentSessionFileStore(agentsRootURL: agentsURL)
+ for summary in try store.listSessions(agentID: "mobile") {
+ let detail = try store.loadSession(agentID: "mobile", sessionID: summary.id)
+ let messages = await MobileSloppyChatAdapter.events(from: detail.events).compactMap(\.message)
+ let title = messages.first(where: { $0.role == .user })?.segments.first?.text ?? summary.title
+ result.append(.init(id: summary.id, title: String(title.prefix(80)), updatedAt: summary.updatedAt,
+ messageCount: messages.count, preview: String((messages.last?.segments.first?.text ?? "").prefix(200))))
+ }
+ }
+ return result.sorted { $0.updatedAt > $1.updatedAt }
+ }
+ private static func cloudID(_ id: String) -> Bool {
+ id.hasPrefix("cloud-") && id.range(of: "^[a-zA-Z0-9_-]{1,100}$", options: .regularExpression) != nil
}
func messages(id: String) throws -> [SloppyChatMessage] {
@@ -52,10 +62,14 @@ actor MobileEditorSessionStore {
}
}
- func events(id: String? = nil) throws -> [EditorAgentEvent] {
- guard let sessionID = try id ?? list().first?.id else { return [] }
+ func events(id: String? = nil) async throws -> [EditorAgentEvent] {
+ let sessionID: String
+ if let id { sessionID = id }
+ else if let first = try await list().first { sessionID = first.id }
+ else { return [] }
+ if Self.cloudID(sessionID) { return try await EditorAgentSessionStore(projectURL: workspaceURL).loadSession(id: sessionID).events }
let detail = try AgentSessionFileStore(agentsRootURL: agentsURL).loadSession(agentID: "mobile", sessionID: sessionID)
- return MobileSloppyChatAdapter.events(from: detail.events)
+ return await MobileSloppyChatAdapter.events(from: detail.events)
}
}
@@ -65,6 +79,7 @@ struct MobileEditorSessionsScreen: View {
@State private var status = "Loading sessions…"
@State private var loadTask: Task?
let workspaceURL: URL
+ var cloud = false
let open: (String) -> Void
var body: some View {
@@ -98,8 +113,8 @@ struct MobileEditorSessionsScreen: View {
do {
let loaded = try await MobileEditorSessionStore(workspaceURL: workspaceURL).list()
try Task.checkCancellation()
- sessions = loaded
- status = loaded.isEmpty ? "No sessions yet. Your conversations will appear here after the first request." : ""
+ sessions = loaded.filter { $0.id.hasPrefix("cloud-") == cloud }
+ status = sessions.isEmpty ? "No sessions yet. Your conversations will appear here after the first request." : ""
} catch { if !Task.isCancelled { status = error.localizedDescription } }
}
}
diff --git a/Editor/Sources/AdaEditor/UI/Mobile/MobileSloppyChatAdapter.swift b/Editor/Sources/AdaEditor/UI/Mobile/MobileSloppyChatAdapter.swift
index 4911dfae6..1e3e5b144 100644
--- a/Editor/Sources/AdaEditor/UI/Mobile/MobileSloppyChatAdapter.swift
+++ b/Editor/Sources/AdaEditor/UI/Mobile/MobileSloppyChatAdapter.swift
@@ -4,7 +4,7 @@ import Protocols
import SloppyRuntime
enum MobileSloppyChatAdapter {
- static func events(from sessionEvents: [AgentSessionEvent]) -> [EditorAgentEvent] {
+ @MainActor static func events(from sessionEvents: [AgentSessionEvent]) -> [EditorAgentEvent] {
sessionEvents.sorted { $0.createdAt < $1.createdAt }.compactMap { event in
if let call = event.toolCall {
return EditorAgentEvent(id: event.id, kind: .runStatus, createdAt: event.createdAt, title: L10n.text("Using \(call.tool)"))
diff --git a/Editor/Sources/AdaEditor/UI/Mobile/MobileSloppyCredentials.swift b/Editor/Sources/AdaEditor/UI/Mobile/MobileSloppyCredentials.swift
index f01d233a9..1a644133b 100644
--- a/Editor/Sources/AdaEditor/UI/Mobile/MobileSloppyCredentials.swift
+++ b/Editor/Sources/AdaEditor/UI/Mobile/MobileSloppyCredentials.swift
@@ -9,6 +9,7 @@ import UIKit
#endif
enum MobileAgentProvider: String, Codable, Sendable {
+ case ada
case codex
case api
}
diff --git a/Editor/Tests/AdaEditorTests/EditorAICreditsTests.swift b/Editor/Tests/AdaEditorTests/EditorAICreditsTests.swift
index c43480947..2d4ac054f 100644
--- a/Editor/Tests/AdaEditorTests/EditorAICreditsTests.swift
+++ b/Editor/Tests/AdaEditorTests/EditorAICreditsTests.swift
@@ -70,6 +70,24 @@ struct EditorAICreditsTests {
#expect(model.phase == ([404, 503].contains(status) ? .unavailable : .failed))
}
}
+ @Test("Cloud Pro purchase and exhausted allowance have different messages")
+ func cloudProAllowanceMessages() async {
+ var configured = catalog
+ configured["enabled"] = true
+ configured["availability"] = ["cloudServicesAvailable": true]
+ configured["capabilities"] = ["llm"]
+ let enabled = configured
+ var empty = wallet(available: 0, spent: 0, reserved: 0)
+ empty["allowance"] = 0
+ let noPlan = empty
+ let model = EditorAICreditsModel()
+ await model.refresh(client: EditorCloudAIClient(owner: owner) { path, _, _ in path == "/ai/catalog" ? enabled : noPlan })
+ #expect(model.summary == "Cloud Pro includes 500 AI credits per month. Subscribe to use Ada AI.")
+ await model.refresh(client: EditorCloudAIClient(owner: owner) { path, _, _ in
+ path == "/ai/catalog" ? enabled : wallet(available: 0, spent: 500_000_000, reserved: 0)
+ }, force: true)
+ #expect(model.summary == "Your AI credits are used up. They renew with your monthly Cloud Pro allowance.")
+ }
@Test("The counter opens account details through the real UI event path")
func badgeAction() throws {
diff --git a/Editor/Tests/AdaEditorTests/EditorCloudAgentTests.swift b/Editor/Tests/AdaEditorTests/EditorCloudAgentTests.swift
new file mode 100644
index 000000000..c2030b370
--- /dev/null
+++ b/Editor/Tests/AdaEditorTests/EditorCloudAgentTests.swift
@@ -0,0 +1,161 @@
+import Foundation
+import MCP
+import Testing
+
+@testable import AdaEditor
+
+@Suite("Hosted Cloud AI native agent")
+struct EditorCloudAgentTests {
+ @Test func nativeVerificationMetadataSurvivesMCPEncoding() throws {
+ let tool = Tool(name: "editor.build.start", inputSchema: .object([:]), _meta: Metadata(additionalFields: ["sloppy/verification": "build"]))
+ let data = try JSONEncoder().encode(tool)
+ let value = try JSONDecoder().decode(EditorCloudValue.self, from: data)
+ #expect(value["_meta"]["sloppy/verification"].string == "build")
+ #expect(try JSONDecoder().decode(Tool.self, from: data) == tool)
+ }
+ func request(mode: EditorAgentChatMode = .build) -> EditorAgentRunRequest {
+ let url = FileManager.default.temporaryDirectory.appendingPathComponent("cloud-agent-fixture")
+ var project = ProjectSystem.defaultProject(buildSystem: .adaScript)
+ project.ai.agent.enabled = true
+ return .init(
+ project: project, projectURL: url, session: .init(id: "session"), mode: mode,
+ prompt: "Create a game", attachments: [], sceneContext: nil, codeSelection: nil, skills: [])
+ }
+ @Test func normalizedStreamRequiresCompletionAndRejectsServerErrors() throws {
+ var parser = EditorCloudAIStream()
+ #expect(try parser.event(line: ": keepalive") == nil)
+ _ = try parser.event(line: #"data: {"type":"text","text":"Привет"}"#)
+ #expect(throws: (any Error).self) { try parser.finish() }
+ _ = try parser.event(line: #"data: {"type":"complete"}"#)
+ try parser.finish()
+ #expect(throws: (any Error).self) { _ = try parser.event(line: #"data: {"type":"tool"}"#) }
+ var failure = EditorCloudAIStream()
+ #expect(throws: (any Error).self) { _ = try failure.event(line: #"data: {"type":"error","message":"secret upstream body"}"#) }
+ }
+ @Test func toolExecutionWaitsForPermissionAndUsesAnotherMeteredCall() async throws {
+ let gateway = Gateway()
+ let count = Counter()
+ let tool = EditorCloudAgentTool(
+ name: "editor_file_write", definition: ["type": "function"], readOnly: false,
+ invoke: { _ in
+ await count.increment()
+ return "Saved Sources/Game.ada"
+ })
+ let service = EditorCloudAgentService(transportFactory: { { input, receive in try await gateway.respond(input, receive) } }, toolsFactory: { _ in [tool] })
+ let result = try await service.send(
+ request(),
+ onEvent: { event in
+ if let permission = event.permission { await service.resolvePermission(requestID: permission.id, optionID: "allow") }
+ }, onProjectFileChanged: { _ in })
+ #expect(result.assistantText == "Done")
+ #expect(await count.value == 1)
+ #expect(await gateway.operations.count == 2)
+ #expect(await Set(gateway.operations).count == 2)
+ }
+ @Test func deniedPermissionAndPlanModeNeverMutateFiles() async throws {
+ for plan in [false, true] {
+ let gateway = Gateway()
+ let count = Counter()
+ let tool = EditorCloudAgentTool(
+ name: "editor_file_write", definition: ["type": "function"], readOnly: false,
+ invoke: { _ in
+ await count.increment()
+ return "Saved"
+ })
+ let service = EditorCloudAgentService(transportFactory: { { input, receive in try await gateway.respond(input, receive) } }, toolsFactory: { _ in [tool] })
+ _ = try await service.send(
+ request(mode: plan ? .plan : .build),
+ onEvent: { event in
+ if let permission = event.permission { await service.resolvePermission(requestID: permission.id, optionID: "deny") }
+ }, onProjectFileChanged: { _ in })
+ #expect(await count.value == 0)
+ }
+ }
+ @Test func incompleteResponseNeverRunsToolsOrAutomaticallyRetries() async {
+ let count = Counter()
+ let service = EditorCloudAgentService(
+ transportFactory: {
+ { _, receive in
+ await count.increment()
+ try await receive(["type": "tool", "call": ["id": "call", "function": ["name": "editor_file_write", "arguments": "{}"]]])
+ }
+ }, toolsFactory: { _ in [] })
+ await #expect(throws: (any Error).self) { _ = try await service.send(request(), onEvent: { _ in }, onProjectFileChanged: { _ in }) }
+ #expect(await count.value == 1)
+ }
+ @Test func cancellingPendingPermissionNeverChangesTheProject() async throws {
+ let gateway = Gateway()
+ let count = Counter()
+ let gate = Gate()
+ let tool = EditorCloudAgentTool(
+ name: "editor_file_write", definition: ["type": "function"], readOnly: false,
+ invoke: { _ in
+ await count.increment()
+ return "Saved"
+ })
+ let service = EditorCloudAgentService(transportFactory: { { input, receive in try await gateway.respond(input, receive) } }, toolsFactory: { _ in [tool] })
+ let task = Task {
+ try await service.send(
+ request(),
+ onEvent: { event in
+ if event.permission != nil { await gate.open() }
+ }, onProjectFileChanged: { _ in })
+ }
+ await gate.wait()
+ await service.cancel(sessionID: "session")
+ await #expect(throws: CancellationError.self) { _ = try await task.value }
+ #expect(await count.value == 0)
+ #expect(await gateway.operations.count == 1)
+ }
+ @Test @MainActor func nativeFileToolsCannotEscapeOrOverwriteExistingFiles() async throws {
+ let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+ let escapedName = "escape-" + UUID().uuidString + ".ada"
+ let outside = root.deletingLastPathComponent().appendingPathComponent(escapedName)
+ try FileManager.default.createDirectory(at: root.appendingPathComponent("Sources"), withIntermediateDirectories: true)
+ defer {
+ try? FileManager.default.removeItem(at: root)
+ try? FileManager.default.removeItem(at: outside)
+ }
+ let tools = try EditorCloudToolHost(projectURL: root, mobile: EditorMobileAgentToolService(projectURL: root)).tools()
+ let write = try #require(tools.first { $0.name == "editor_file_write" })
+ let read = try #require(tools.first { $0.name == "editor_file_read" })
+ _ = try await write.invoke(["path": "Sources/Game.ada", "content": "original"])
+ #expect(try await read.invoke(["path": "Sources/Game.ada"]) == "original")
+ await #expect(throws: (any Error).self) { _ = try await write.invoke(["path": "Sources/Game.ada", "content": "overwrite"]) }
+ await #expect(throws: (any Error).self) { _ = try await write.invoke(["path": "../escape.ada", "content": "escape"]) }
+ try FileManager.default.createSymbolicLink(at: root.appendingPathComponent("Sources/link"), withDestinationURL: root.deletingLastPathComponent())
+ await #expect(throws: (any Error).self) { _ = try await write.invoke(["path": .string("Sources/link/" + escapedName), "content": "escape"]) }
+ #expect(!FileManager.default.fileExists(atPath: outside.path))
+ #expect(try await read.invoke(["path": "Sources/Game.ada"]) == "original")
+ }
+ private actor Counter {
+ var value = 0
+ func increment() { value += 1 }
+ }
+ private actor Gate {
+ var opened = false
+ var continuation: CheckedContinuation?
+ func wait() async { if !opened { await withCheckedContinuation { continuation = $0 } } }
+ func open() {
+ opened = true
+ continuation?.resume()
+ continuation = nil
+ }
+ }
+ private actor Gateway {
+ var operations: [String] = []
+ func respond(_ input: EditorCloudValue, _ receive: @escaping @Sendable (EditorCloudValue) async throws -> Void) async throws {
+ let operation = try #require(input["operationID"].string)
+ operations.append(operation)
+ #expect(input["model"].string == "qwen/qwen3-coder-next")
+ #expect(input["billingMode"].string == "ada")
+ #expect(input["apiKey"] == .null)
+ if operations.count == 1 {
+ try await receive(["type": "tool", "call": ["id": "call", "function": ["name": "editor_file_write", "arguments": #"{"path":"Sources/Game.ada"}"#]]])
+ } else {
+ try await receive(["type": "text", "text": "Done"])
+ }
+ try await receive(["type": "complete"])
+ }
+ }
+}
diff --git a/Editor/WebUI/.gitignore b/Editor/WebUI/.gitignore
index c19e9e50f..307d3bc77 100644
--- a/Editor/WebUI/.gitignore
+++ b/Editor/WebUI/.gitignore
@@ -1,3 +1,4 @@
node_modules/
dist/
.sites-runtime/
+.local/
diff --git a/Editor/WebUI/README.md b/Editor/WebUI/README.md
new file mode 100644
index 000000000..4d9fa0957
--- /dev/null
+++ b/Editor/WebUI/README.md
@@ -0,0 +1,195 @@
+# WebUI development
+
+Use the real local Cloud stack for development. A static HTTP server cannot create
+or save projects: WebUI sends same-origin requests to `/v1/*`.
+
+## Setup (macOS)
+
+The sibling `adaengine-service` checkout is required, together with Swift,
+PostgreSQL 17, Redis, Java 21 and Kafka. PostgreSQL and Redis default to their
+Homebrew paths. Set `KAFKA_HOME` to an unpacked Kafka distribution; the launcher
+also recognizes the existing Cloud checkout's `.local/ugc-tooling/kafka_2.13-4.1.2`.
+Python 3 with `venv`, Node and installed WebUI npm dependencies are required.
+
+```sh
+cd /path/to/AdaEngine/Editor/WebUI
+npm install
+npm run dev:setup
+npm run dev
+```
+
+For another Cloud location, pass `--cloud /absolute/path` to both commands after
+`--`. `npm run dev -- --check` checks prerequisites. The Cloud build uses its own
+scratch directory; it does not delete or modify the Cloud checkout's shared build.
+Cloud sign-in/account/publication panels are built from adawebsite and embedded
+in the same Playground shell. The existing adawebsite `dist` supplies legacy static assets; pass
+`--website /absolute/path/to/adawebsite/dist` if needed.
+
+- Playground:
+- Create:
+- My projects:
+- Sign-in/account:
+- Publications:
+- Local email codes:
+
+The local launcher adds an **Open test inbox** shortcut on the account page and
+next to the email code field. Its label follows the app language. This shortcut
+is injected only while serving local development HTML and is absent from exported
+frontend files.
+
+Use a disposable email such as `developer@example.test`. Sign-in uses the real
+Identity service, PKCE and its normal cookies. Draft development legal documents
+are shown inside Playground. No external email is sent. Sign-in restores a safe
+Playground destination automatically. The local OTP step links to the test inbox. The `127.0.0.1:8080` entry redirects to `localhost` so the Player
+can use a different hostname and never receive Cloud cookies.
+
+All ten Cloud executables run locally, with one PostgreSQL database per service,
+Redis and Kafka. S3 is emulated by Moto; acknowledged object writes are mirrored
+to disk before their success reaches Cloud and restored at the next start. This
+is development storage, not acceptance of an external S3 provider. Publishing
+and multiplayer are disabled. AI uses the actual gateway; no provider is
+configured by default. Set `STUDIO_PROVIDERS` and its named key environment
+variables to exercise a provider, following the Cloud `docs/studio-web.md` guide.
+The launcher never saves those keys.
+
+### Local Codex instead of an API key
+
+With Codex CLI installed and signed in through ChatGPT (`codex login`), start:
+
+```sh
+npm run dev -- --codex
+```
+
+Choose **Local Codex** in the assistant settings. The local edge advertises the
+CLI's model catalog and uses `codex app-server` with the CLI's existing sign-in.
+No API key is required and no Codex credential is sent to the browser. This
+option is exposed only by the opt-in local launcher; hosted Cloud is unchanged.
+Cloud sign-in is still required to use the editor and its private projects.
+
+Codex returns structured requests for the existing browser tools. The browser
+executes them with its current plan/build filtering, approvals, document history
+and Undo. Each provider step uses an ephemeral read-only Codex thread in an empty
+development directory; shell, MCP, plugins and host workflows are disabled.
+Provider text becomes visible when that structured step finishes. Stop cancels
+the local process when the stream disconnects. Model access and usage limits
+belong to the signed-in Codex account; a model catalog alone does not prove access.
+
+`npm run test:codex` is an explicit live check that consumes a small Codex turn.
+It verifies Cloud admission, origin rejection, a real Codex tool request, and
+execution/Undo through the production document tool. Unit tests do not call Codex.
+
+Data, secrets, logs, Python dependencies and build caches stay in ignored
+`.local/`. Ctrl-C stops the processes it started and keeps data. A busy port
+causes startup to fail before any service starts. No system services are installed.
+Use a separate `--state /absolute/path` and matching `--bin-dir` for disposable
+experiments; never delete a state directory containing projects you need.
+
+Ports: edge 8080, Player 8081, APIs 28081–28087/28089–28091, inbox 28088,
+PostgreSQL 25432, Redis 26379, Kafka 29092/29093, S3 29000/29001. All bind to
+loopback. PostgreSQL uses local trust auth inside this development-only cluster.
+
+## Scene and Play
+
+Export the real fixed Player with the matching Swift 6.3.2 WASM SDK and Tint:
+
+```sh
+python3 ../../script/export_studio_web.py \
+ --swift /path/to/swift-6.3.2-RELEASE.xctoolchain/usr/bin/swift \
+ --output "$PWD/.local/studio-web" \
+ --scratch-path "$PWD/.local/player-build" \
+ --studio-origin http://localhost:8080
+```
+
+Restart `npm run dev` after the export. The Player runs at
+, isolated from the Studio hostname.
+The launcher does not substitute a fake renderer if the Player is absent.
+`--player /absolute/path` selects another prepared export. UI-only changes need
+`npm run build` and a browser refresh; Cloud and Player do not need rebuilding.
+
+## Checks
+
+```sh
+npm test
+npm run test:dev
+npm run test:cloud
+# With no stack running, also exercise a complete stop/start:
+npm run test:cloud -- --restart
+```
+
+The dev suite exercises API routing and object persistence across real Moto
+restarts. The Cloud smoke uses a disposable account and the actual 3D template,
+checking authentication, create/save/read, idempotency, conflicts and origin
+rejection. `--restart` also owns the launcher and checks database, session and
+project persistence after a full stop/start. It stops its stack when finished.
+The default smoke expects no configured AI providers. Browser scene acceptance
+additionally requires WebGPU and completed Player frames.
+
+## Single frontend entry
+
+`/studio/` is a legacy URL; `/studio/*` remains the static asset prefix, not a
+second app. The HTML base keeps artwork, assistant resources and panel modules
+working from every deep link. Games and Create keep the original robot design.
+`/games/create` and `/cloud/projects/*` remain compatibility aliases. `/cloud`
+continues to accept OAuth and native desktop callbacks before normalization.
+
+Build `adawebsite` with `npm run build:cloud` for an assembled distribution, or
+build its panels with `npm run build:panels` then run WebUI's `npm run build`.
+`ADA_CLOUD_PANELS_DIST` selects a panels build in another worktree. The WebUI
+build fails with a clear setup error if panels are absent. Both the hosted edge
+and local launcher serve all public routes from the same WebUI HTML entry.
+Private workspaces and publication records retain separate IDs and APIs.
+
+Local browser acceptance (Maestro/Chromium, with the launcher already running):
+
+```sh
+maestro test --platform web --headless test/flows/playground-navigation.yaml
+maestro test --platform web --headless test/flows/playground-account.yaml
+```
+
+The account flow is restricted to localhost and asserts that legal documents are
+non-binding drafts. It uses a disposable email and the local mailbox, then creates
+a test workspace. It does not run a paid AI request, subscribe or issue a refund.
+
+## Interface language
+
+WebUI uses the native Studio `en.json` and `ru.json` catalogs and the same
+`AdaEditor.language` preference (`system`, `en`, `ru`). Settings changes refresh
+mounted panels, Cloud account forms and CodeMirror search without discarding
+input. Object names, source code, chat messages and other project content remain
+as authored. New template names use the language selected when created.
+
+Use `t()` for UI labels and `LocalizedError` for application-authored failures.
+The build checks source literals (including escaped Unicode) for hardcoded Russian
+and requires both translations. The embedded Cloud panels emit their message keys
+in `cloud-panel-messages.json`; rebuild panels before WebUI when changing them.
+Run `npm run check:localization`, `npm test`, `npm run build` and
+`python ../../script/check_studio_localization.py`. The DOM tests cover live
+language changes, input and search preservation; they do not replace visual QA.
+
+Create shows 2D/3D and single-player/multiplayer choices. Dimension selects the
+initial template; both choices travel with the first agent request. Multiplayer
+is a design request and does not enable a networking service by itself. Local
+English/Russian phrase completions appear after a short typing pause; Tab accepts,
+Escape dismisses, and Enter sends only the actual draft. These completions use
+bundled examples, make no provider calls and preserve the typed prefix.
+
+## Composer attachments
+
+Create stages up to four files locally before a project exists. Picking multiple
+files, pasting images/files and dropping files use the same atomic import path.
+The first Send creates a snapshot containing the imported assets and attachment
+metadata; admission or Cloud failures retain the draft. Removing a chip removes
+its chat context while keeping an already imported project asset.
+
+Images: PNG/JPEG and WebP (converted to PNG); models: GLB/VOX; audio: WAV/OGG,
+plus MP3/M4A/AAC/FLAC decoded by the browser into PCM16 WAV. Browser codec support
+is required for conversion. Limits: 16 MiB per resulting asset, 100 KB for text,
+63 MiB total before project creation. Audio is never shortened to fit.
+Large images retain original pixels in the asset; a bounded JPEG thumbnail is
+sent to vision-capable models. Model and audio files stay in the project for
+inspection and use through the existing agent tools. Sending attachments alone
+uses a localized default instruction. Unsaved staged files protect tab closing.
+
+Tests exercise the production document/ZIP and agent request paths plus mounted
+DOM picker/paste/drop/removal. Image/codec browser APIs are simulated in unit
+tests; those checks do not establish live browser decode or native playback.
diff --git a/Editor/WebUI/assets/playground/README.md b/Editor/WebUI/assets/playground/README.md
new file mode 100644
index 000000000..3ed10a62b
--- /dev/null
+++ b/Editor/WebUI/assets/playground/README.md
@@ -0,0 +1,19 @@
+# Playground tag artwork
+
+These original transparent PNG sheets were generated for the approved Playground
+prototype with ImageGen. Each sheet contains a central icon and four themed props.
+The files are copied unchanged; `src/playground-tag-art.json` stores crop rectangles
+used by CSS backgrounds, rather than rewriting the images. Hover and keyboard
+focus reveal the props behind the capsule; reduced motion keeps them hidden.
+
+`npm run build` copies this directory to `dist/playground`. The Studio frontend
+export includes the directory recursively, alongside `studio.js` and `studio.css`.
+
+`mascot-guide-v1.png` is copied byte for byte from the pointing-pose asset in
+`Documentation/Brand/Assets/PlaygroundMascot`. Create and My projects reuse the
+canonical mascot described in `Documentation/Brand/PlaygroundMascot.md`.
+
+`mascot-builder-v1.png` is the Create-page variation generated with the built-in
+ImageGen tool from `mascot-guide-v1.png`: the same robot assembles a small grassy
+platformer level with a tree and a collectible star. Transparent alpha is kept.
+The guide pose remains on My projects. Prompt: `mascot-builder-v1.prompt.txt`.
diff --git a/Editor/WebUI/assets/playground/hero-robots-v1.png b/Editor/WebUI/assets/playground/hero-robots-v1.png
new file mode 100644
index 000000000..97ed5ed21
Binary files /dev/null and b/Editor/WebUI/assets/playground/hero-robots-v1.png differ
diff --git a/Editor/WebUI/assets/playground/hero-robots-v1.prompt.txt b/Editor/WebUI/assets/playground/hero-robots-v1.prompt.txt
new file mode 100644
index 000000000..7411af827
--- /dev/null
+++ b/Editor/WebUI/assets/playground/hero-robots-v1.prompt.txt
@@ -0,0 +1,4 @@
+Generated with the built-in image_gen tool (stylized-concept), 2026-10-19.
+Original PNG retained without pixel editing, 2172 x 724.
+
+Use case: stylized-concept. Asset type: wide atmospheric website header background for Ada Playground, a dark interface for creating and playing games. Generate a polished 3D game illustration, wide panoramic composition roughly 3:1. Two charming original small explorer robots with rounded cream ceramic armor, sky-blue illuminated faces, a few warm yellow details, discovering a tiny playful garden world with low-poly grass, sculptural trees and floating game-world islands. The robots and interesting scenery belong in the RIGHT HALF of the composition, with both complete faces comfortably inside the rightmost 45 percent, vertically centered. The LEFT HALF is calm nearly black charcoal (#121314) negative space for live HTML text, with only faint atmospheric blue haze. Restrained cool blue lighting, gentle warm highlights, tactile high-quality stylized game art, inviting and playful, not photorealistic, not mechanical or threatening. The right-side scene should gradually recede into charcoal on the left and along the entire bottom edge so it can blend seamlessly into a dark UI with a CSS gradient. Compact header art rather than a busy full-screen scene. No lettering, no logos, no UI, no frame, no watermark. Opaque dark background.
diff --git a/Editor/WebUI/assets/playground/mascot-builder-v1.png b/Editor/WebUI/assets/playground/mascot-builder-v1.png
new file mode 100644
index 000000000..f4b55022e
Binary files /dev/null and b/Editor/WebUI/assets/playground/mascot-builder-v1.png differ
diff --git a/Editor/WebUI/assets/playground/mascot-builder-v1.prompt.txt b/Editor/WebUI/assets/playground/mascot-builder-v1.prompt.txt
new file mode 100644
index 000000000..4fefef684
--- /dev/null
+++ b/Editor/WebUI/assets/playground/mascot-builder-v1.prompt.txt
@@ -0,0 +1,4 @@
+Built-in ImageGen edit; transparent_background=true.
+Reference: mascot-guide-v1.png (edit target).
+
+Use case: precise-object-edit. Asset type: transparent mascot illustration for the Create game page of Ada Playground. Edit target: the supplied mascot. Keep the same recognizable friendly cream-colored compact explorer robot, black glass face with happy cyan eyes, warm amber details, brown backpack, polished 3D materials and proportions. Change the pose and scene: the robot is actively building a tiny video game world with both hands, kneeling beside a small floating grassy platform and carefully placing a chunky game-level block. On the tiny platform include a simple tree, two stepping-stone blocks and a small golden collectible star, visibly resembling a playable platformer level. Focused, playful maker pose, looking toward its work. One cohesive compact composition, robot and game pieces fully visible with generous transparent margins, three-quarter view, readable at 320px. Actual transparent background, no floor or rectangular backdrop. Match original quality and lighting, gentle contact shadows only around the platform and character. No text, letters, logos, watermark, UI or additional characters.
diff --git a/Editor/WebUI/assets/playground/mascot-guide-v1.png b/Editor/WebUI/assets/playground/mascot-guide-v1.png
new file mode 100644
index 000000000..6ed893b11
Binary files /dev/null and b/Editor/WebUI/assets/playground/mascot-guide-v1.png differ
diff --git a/Editor/WebUI/assets/playground/tags/2d-v1.png b/Editor/WebUI/assets/playground/tags/2d-v1.png
new file mode 100644
index 000000000..038816572
Binary files /dev/null and b/Editor/WebUI/assets/playground/tags/2d-v1.png differ
diff --git a/Editor/WebUI/assets/playground/tags/3d-v1.png b/Editor/WebUI/assets/playground/tags/3d-v1.png
new file mode 100644
index 000000000..7d2f331ec
Binary files /dev/null and b/Editor/WebUI/assets/playground/tags/3d-v1.png differ
diff --git a/Editor/WebUI/assets/playground/tags/adventure-v1.png b/Editor/WebUI/assets/playground/tags/adventure-v1.png
new file mode 100644
index 000000000..94a7bc5c0
Binary files /dev/null and b/Editor/WebUI/assets/playground/tags/adventure-v1.png differ
diff --git a/Editor/WebUI/assets/playground/tags/all-v1.png b/Editor/WebUI/assets/playground/tags/all-v1.png
new file mode 100644
index 000000000..e792fc7c9
Binary files /dev/null and b/Editor/WebUI/assets/playground/tags/all-v1.png differ
diff --git a/Editor/WebUI/assets/playground/tags/arcade-v1.png b/Editor/WebUI/assets/playground/tags/arcade-v1.png
new file mode 100644
index 000000000..c4ba1616b
Binary files /dev/null and b/Editor/WebUI/assets/playground/tags/arcade-v1.png differ
diff --git a/Editor/WebUI/assets/playground/tags/browser-v1.png b/Editor/WebUI/assets/playground/tags/browser-v1.png
new file mode 100644
index 000000000..a7498b613
Binary files /dev/null and b/Editor/WebUI/assets/playground/tags/browser-v1.png differ
diff --git a/Editor/WebUI/assets/playground/tags/exploration-v1.png b/Editor/WebUI/assets/playground/tags/exploration-v1.png
new file mode 100644
index 000000000..8e861802d
Binary files /dev/null and b/Editor/WebUI/assets/playground/tags/exploration-v1.png differ
diff --git a/Editor/WebUI/assets/playground/tags/platformer-v1.png b/Editor/WebUI/assets/playground/tags/platformer-v1.png
new file mode 100644
index 000000000..240779afd
Binary files /dev/null and b/Editor/WebUI/assets/playground/tags/platformer-v1.png differ
diff --git a/Editor/WebUI/assets/playground/tags/sandbox-v1.png b/Editor/WebUI/assets/playground/tags/sandbox-v1.png
new file mode 100644
index 000000000..b358c770d
Binary files /dev/null and b/Editor/WebUI/assets/playground/tags/sandbox-v1.png differ
diff --git a/Editor/WebUI/assets/playground/tags/single-player-v1.png b/Editor/WebUI/assets/playground/tags/single-player-v1.png
new file mode 100644
index 000000000..b22822511
Binary files /dev/null and b/Editor/WebUI/assets/playground/tags/single-player-v1.png differ
diff --git a/Editor/WebUI/assets/playground/tags/studio-v1.png b/Editor/WebUI/assets/playground/tags/studio-v1.png
new file mode 100644
index 000000000..c5954bdb0
Binary files /dev/null and b/Editor/WebUI/assets/playground/tags/studio-v1.png differ
diff --git a/Editor/WebUI/index.html b/Editor/WebUI/index.html
index 0b76e7966..b65edcf6a 100644
--- a/Editor/WebUI/index.html
+++ b/Editor/WebUI/index.html
@@ -1,10 +1,11 @@
-
+
+
- Ada Studio
+ Ada Playground
- Ada Studio
+ Ada Playground
diff --git a/Editor/WebUI/package.json b/Editor/WebUI/package.json
index 0fb54a67e..6b999d3f6 100644
--- a/Editor/WebUI/package.json
+++ b/Editor/WebUI/package.json
@@ -6,7 +6,13 @@
"packageManager": "pnpm@11.25.0",
"scripts": {
"build": "node scripts/build.mjs",
- "test": "node --test test/*.test.mjs"
+ "test": "node --test test/*.test.mjs",
+ "dev:setup": "python3 scripts/dev-setup.py",
+ "dev": ".local/venv/bin/python scripts/dev-cloud.py",
+ "test:dev": ".local/venv/bin/python -m unittest discover -s scripts -p \"test_dev_*.py\"",
+ "test:cloud": ".local/venv/bin/python scripts/smoke-dev-cloud.py",
+ "test:codex": ".local/venv/bin/python scripts/smoke-dev-codex.py",
+ "check:localization": "node scripts/check-localization.mjs"
},
"dependencies": {
"@codemirror/commands": "^6.11.1",
@@ -22,6 +28,8 @@
},
"devDependencies": {
"esbuild": "0.25.5",
- "prettier": "3.6.2"
+ "happy-dom": "21.0.3",
+ "prettier": "3.6.2",
+ "typescript": "5.9.3"
}
}
diff --git a/Editor/WebUI/pnpm-lock.yaml b/Editor/WebUI/pnpm-lock.yaml
index 31114fbb8..7a07e24c2 100644
--- a/Editor/WebUI/pnpm-lock.yaml
+++ b/Editor/WebUI/pnpm-lock.yaml
@@ -42,195 +42,218 @@ importers:
esbuild:
specifier: 0.25.5
version: 0.25.5
+ happy-dom:
+ specifier: 21.0.3
+ version: 21.0.3
prettier:
specifier: 3.6.2
version: 3.6.2
+ typescript:
+ specifier: 5.9.3
+ version: 5.9.3
packages:
'@codemirror/commands@6.11.1':
- resolution: {integrity: sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==}
+ resolution: {integrity: sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==, tarball: https://npm.yandex-team.ru/@codemirror%2fcommands/-/commands-6.11.1.tgz}
'@codemirror/language@6.13.1':
- resolution: {integrity: sha512-zfvU2r3TyylQLMj/te+5CnQ0rSqZi8B1oTCBbQkagcLW4Xc6imab6qN+pEJhsuNOLaJqiRyBXp9UhWA1DhVvDw==}
+ resolution: {integrity: sha512-zfvU2r3TyylQLMj/te+5CnQ0rSqZi8B1oTCBbQkagcLW4Xc6imab6qN+pEJhsuNOLaJqiRyBXp9UhWA1DhVvDw==, tarball: https://npm.yandex-team.ru/@codemirror%2flanguage/-/language-6.13.1.tgz}
'@codemirror/search@6.7.2':
- resolution: {integrity: sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==}
+ resolution: {integrity: sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==, tarball: https://npm.yandex-team.ru/@codemirror%2fsearch/-/search-6.7.2.tgz}
'@codemirror/state@6.7.6':
- resolution: {integrity: sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==}
+ resolution: {integrity: sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==, tarball: https://npm.yandex-team.ru/@codemirror%2fstate/-/state-6.7.6.tgz}
'@codemirror/streamparser@6.0.0':
- resolution: {integrity: sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w==}
+ resolution: {integrity: sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w==, tarball: https://npm.yandex-team.ru/@codemirror%2fstreamparser/-/streamparser-6.0.0.tgz}
'@codemirror/view@6.43.14':
- resolution: {integrity: sha512-hC45YnmJNR1rz42OF57BLoQNFMV12/13drnvibDsR2quNDmldcb4WvYVV81hFQg56i9lcqQMEDqilhX2QDcGQQ==}
+ resolution: {integrity: sha512-hC45YnmJNR1rz42OF57BLoQNFMV12/13drnvibDsR2quNDmldcb4WvYVV81hFQg56i9lcqQMEDqilhX2QDcGQQ==, tarball: https://npm.yandex-team.ru/@codemirror%2fview/-/view-6.43.14.tgz}
'@esbuild/aix-ppc64@0.25.5':
- resolution: {integrity: sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==}
+ resolution: {integrity: sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==, tarball: https://npm.yandex-team.ru/@esbuild%2faix-ppc64/-/aix-ppc64-0.25.5.tgz}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.25.5':
- resolution: {integrity: sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==}
+ resolution: {integrity: sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==, tarball: https://npm.yandex-team.ru/@esbuild%2fandroid-arm64/-/android-arm64-0.25.5.tgz}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.25.5':
- resolution: {integrity: sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==}
+ resolution: {integrity: sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==, tarball: https://npm.yandex-team.ru/@esbuild%2fandroid-arm/-/android-arm-0.25.5.tgz}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.25.5':
- resolution: {integrity: sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==}
+ resolution: {integrity: sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==, tarball: https://npm.yandex-team.ru/@esbuild%2fandroid-x64/-/android-x64-0.25.5.tgz}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.25.5':
- resolution: {integrity: sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==}
+ resolution: {integrity: sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==, tarball: https://npm.yandex-team.ru/@esbuild%2fdarwin-arm64/-/darwin-arm64-0.25.5.tgz}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.25.5':
- resolution: {integrity: sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==}
+ resolution: {integrity: sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==, tarball: https://npm.yandex-team.ru/@esbuild%2fdarwin-x64/-/darwin-x64-0.25.5.tgz}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.25.5':
- resolution: {integrity: sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==}
+ resolution: {integrity: sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==, tarball: https://npm.yandex-team.ru/@esbuild%2ffreebsd-arm64/-/freebsd-arm64-0.25.5.tgz}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.25.5':
- resolution: {integrity: sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==}
+ resolution: {integrity: sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==, tarball: https://npm.yandex-team.ru/@esbuild%2ffreebsd-x64/-/freebsd-x64-0.25.5.tgz}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.25.5':
- resolution: {integrity: sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==}
+ resolution: {integrity: sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==, tarball: https://npm.yandex-team.ru/@esbuild%2flinux-arm64/-/linux-arm64-0.25.5.tgz}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.25.5':
- resolution: {integrity: sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==}
+ resolution: {integrity: sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==, tarball: https://npm.yandex-team.ru/@esbuild%2flinux-arm/-/linux-arm-0.25.5.tgz}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.25.5':
- resolution: {integrity: sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==}
+ resolution: {integrity: sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==, tarball: https://npm.yandex-team.ru/@esbuild%2flinux-ia32/-/linux-ia32-0.25.5.tgz}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.25.5':
- resolution: {integrity: sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==}
+ resolution: {integrity: sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==, tarball: https://npm.yandex-team.ru/@esbuild%2flinux-loong64/-/linux-loong64-0.25.5.tgz}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.25.5':
- resolution: {integrity: sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==}
+ resolution: {integrity: sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==, tarball: https://npm.yandex-team.ru/@esbuild%2flinux-mips64el/-/linux-mips64el-0.25.5.tgz}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.25.5':
- resolution: {integrity: sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==}
+ resolution: {integrity: sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==, tarball: https://npm.yandex-team.ru/@esbuild%2flinux-ppc64/-/linux-ppc64-0.25.5.tgz}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.25.5':
- resolution: {integrity: sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==}
+ resolution: {integrity: sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==, tarball: https://npm.yandex-team.ru/@esbuild%2flinux-riscv64/-/linux-riscv64-0.25.5.tgz}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.25.5':
- resolution: {integrity: sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==}
+ resolution: {integrity: sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==, tarball: https://npm.yandex-team.ru/@esbuild%2flinux-s390x/-/linux-s390x-0.25.5.tgz}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.25.5':
- resolution: {integrity: sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==}
+ resolution: {integrity: sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==, tarball: https://npm.yandex-team.ru/@esbuild%2flinux-x64/-/linux-x64-0.25.5.tgz}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.25.5':
- resolution: {integrity: sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==}
+ resolution: {integrity: sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==, tarball: https://npm.yandex-team.ru/@esbuild%2fnetbsd-arm64/-/netbsd-arm64-0.25.5.tgz}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.25.5':
- resolution: {integrity: sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==}
+ resolution: {integrity: sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==, tarball: https://npm.yandex-team.ru/@esbuild%2fnetbsd-x64/-/netbsd-x64-0.25.5.tgz}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.25.5':
- resolution: {integrity: sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==}
+ resolution: {integrity: sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==, tarball: https://npm.yandex-team.ru/@esbuild%2fopenbsd-arm64/-/openbsd-arm64-0.25.5.tgz}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.25.5':
- resolution: {integrity: sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==}
+ resolution: {integrity: sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==, tarball: https://npm.yandex-team.ru/@esbuild%2fopenbsd-x64/-/openbsd-x64-0.25.5.tgz}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/sunos-x64@0.25.5':
- resolution: {integrity: sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==}
+ resolution: {integrity: sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==, tarball: https://npm.yandex-team.ru/@esbuild%2fsunos-x64/-/sunos-x64-0.25.5.tgz}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.25.5':
- resolution: {integrity: sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==}
+ resolution: {integrity: sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==, tarball: https://npm.yandex-team.ru/@esbuild%2fwin32-arm64/-/win32-arm64-0.25.5.tgz}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.25.5':
- resolution: {integrity: sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==}
+ resolution: {integrity: sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==, tarball: https://npm.yandex-team.ru/@esbuild%2fwin32-ia32/-/win32-ia32-0.25.5.tgz}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.25.5':
- resolution: {integrity: sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==}
+ resolution: {integrity: sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==, tarball: https://npm.yandex-team.ru/@esbuild%2fwin32-x64/-/win32-x64-0.25.5.tgz}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@lezer/common@1.5.3':
- resolution: {integrity: sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==}
+ resolution: {integrity: sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==, tarball: https://npm.yandex-team.ru/@lezer%2fcommon/-/common-1.5.3.tgz}
'@lezer/highlight@1.2.5':
- resolution: {integrity: sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==}
+ resolution: {integrity: sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==, tarball: https://npm.yandex-team.ru/@lezer%2fhighlight/-/highlight-1.2.5.tgz}
'@lezer/lr@1.4.11':
- resolution: {integrity: sha512-UwgZcl1R66H2hDzBrA7kWyYmX6s8p5TQ+e/t7CuvgCtBEPxbEPaHYHu+pJBvTvDotEJpuQkJjbXuHiw5Cixxgw==}
+ resolution: {integrity: sha512-UwgZcl1R66H2hDzBrA7kWyYmX6s8p5TQ+e/t7CuvgCtBEPxbEPaHYHu+pJBvTvDotEJpuQkJjbXuHiw5Cixxgw==, tarball: https://npm.yandex-team.ru/@lezer%2flr/-/lr-1.4.11.tgz}
'@marijn/find-cluster-break@1.0.4':
- resolution: {integrity: sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==}
+ resolution: {integrity: sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==, tarball: https://npm.yandex-team.ru/@marijn%2ffind-cluster-break/-/find-cluster-break-1.0.4.tgz}
+
+ '@types/node@26.6.5':
+ resolution: {integrity: sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==, tarball: https://npm.yandex-team.ru/@types%2fnode/-/node-26.6.5.tgz}
+
+ '@types/whatwg-mimetype@3.0.2':
+ resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==, tarball: https://npm.yandex-team.ru/@types%2fwhatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz}
+
+ '@types/ws@8.18.2':
+ resolution: {integrity: sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==, tarball: https://npm.yandex-team.ru/@types%2fws/-/ws-8.18.2.tgz}
+
+ buffer-image-size@0.6.4:
+ resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==}
+ engines: {node: '>=4.0'}
crelt@1.0.7:
resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==}
+ entities@7.0.1:
+ resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
+ engines: {node: '>=0.12'}
+
esbuild@0.25.5:
resolution: {integrity: sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==}
engines: {node: '>=18'}
@@ -239,6 +262,10 @@ packages:
fflate@0.8.2:
resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
+ happy-dom@21.0.3:
+ resolution: {integrity: sha512-PesNgu5BnIEmAwzKGewB3K1ORFeznFZJ4JrRz89ea1wkHnxskdpzX9sN/+zb4KO6k8oSMhyJSj/cwXAFrYBBNA==}
+ engines: {node: '>=20.0.0'}
+
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -266,9 +293,33 @@ packages:
style-mod@4.1.4:
resolution: {integrity: sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==}
+ typescript@5.9.3:
+ resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
+ engines: {node: '>=14.17'}
+ hasBin: true
+
+ undici-types@8.9.0:
+ resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==}
+
w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
+ whatwg-mimetype@3.0.0:
+ resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
+ engines: {node: '>=12'}
+
+ ws@8.22.0:
+ resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==}
+ engines: {node: '>=10.0.0'}
+ peerDependencies:
+ bufferutil: ^4.0.1
+ utf-8-validate: '>=5.0.2'
+ peerDependenciesMeta:
+ bufferutil:
+ optional: true
+ utf-8-validate:
+ optional: true
+
yaml@2.8.1:
resolution: {integrity: sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==}
engines: {node: '>= 14.6'}
@@ -399,8 +450,24 @@ snapshots:
'@marijn/find-cluster-break@1.0.4': {}
+ '@types/node@26.6.5':
+ dependencies:
+ undici-types: 8.9.0
+
+ '@types/whatwg-mimetype@3.0.2': {}
+
+ '@types/ws@8.18.2':
+ dependencies:
+ '@types/node': 26.6.5
+
+ buffer-image-size@0.6.4:
+ dependencies:
+ '@types/node': 26.6.5
+
crelt@1.0.7: {}
+ entities@7.0.1: {}
+
esbuild@0.25.5:
optionalDependencies:
'@esbuild/aix-ppc64': 0.25.5
@@ -431,6 +498,19 @@ snapshots:
fflate@0.8.2: {}
+ happy-dom@21.0.3:
+ dependencies:
+ '@types/node': 26.6.5
+ '@types/whatwg-mimetype': 3.0.2
+ '@types/ws': 8.18.2
+ buffer-image-size: 0.6.4
+ entities: 7.0.1
+ whatwg-mimetype: 3.0.0
+ ws: 8.22.0
+ transitivePeerDependencies:
+ - bufferutil
+ - utf-8-validate
+
js-tokens@4.0.0: {}
loose-envify@1.4.0:
@@ -455,6 +535,14 @@ snapshots:
style-mod@4.1.4: {}
+ typescript@5.9.3: {}
+
+ undici-types@8.9.0: {}
+
w3c-keyname@2.2.8: {}
+ whatwg-mimetype@3.0.0: {}
+
+ ws@8.22.0: {}
+
yaml@2.8.1: {}
diff --git a/Editor/WebUI/scripts/agent-resources.mjs b/Editor/WebUI/scripts/agent-resources.mjs
index ee6b831f0..d258a8e9d 100644
--- a/Editor/WebUI/scripts/agent-resources.mjs
+++ b/Editor/WebUI/scripts/agent-resources.mjs
@@ -2,12 +2,16 @@ import { readFile, readdir, mkdir, writeFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import path from "node:path";
import { parse } from "yaml";
-const root = fileURLToPath(new URL("../../..", import.meta.url));
-const read = (p) => readFile(path.join(root, p), "utf8");
-export async function agentResources(output) {
+const repositoryRoot = fileURLToPath(new URL("../../..", import.meta.url));
+export async function agentResources(output, root = repositoryRoot) {
+ const read = (p) => readFile(path.join(root, p), "utf8");
const skills = [];
const base = "Editor/Sources/AdaEditor/Assets/AgentSkills";
- for (const dir of await readdir(path.join(root, base))) {
+ for (const entry of await readdir(path.join(root, base), {
+ withFileTypes: true,
+ })) {
+ if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
+ const dir = entry.name;
const content = await read(`${base}/${dir}/SKILL.md`);
const front = parse(content.split("---")[1] || "") || {};
skills.push({
@@ -74,15 +78,19 @@ export async function agentResources(output) {
for (const e of await readdir(path.join(root, folder), {
withFileTypes: true,
})) {
+ if (e.name === ".DS_Store") continue;
const p = `${folder}/${e.name}`;
if (e.isDirectory()) Object.assign(files, await walk(p, base));
- else files[p.slice(base.length + 1)] = await read(p);
+ else if (e.isFile()) files[p.slice(base.length + 1)] = await read(p);
}
return files;
};
- for (const id of await readdir(
+ for (const entry of await readdir(
path.join(root, "Editor/Sources/AdaEditor/Assets/Examples"),
+ { withFileTypes: true },
)) {
+ if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
+ const id = entry.name;
const files = await walk("Editor/Sources/AdaEditor/Assets/Examples/" + id);
examples.push({
id,
diff --git a/Editor/WebUI/scripts/build.mjs b/Editor/WebUI/scripts/build.mjs
index 6491d7529..321a37930 100644
--- a/Editor/WebUI/scripts/build.mjs
+++ b/Editor/WebUI/scripts/build.mjs
@@ -1,16 +1,29 @@
import { build } from "esbuild";
-import { mkdir, copyFile, readFile, readdir } from "node:fs/promises";
+import { resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { createHash } from "node:crypto";
+import { mkdir, copyFile, readFile, cp, writeFile } from "node:fs/promises";
import { agentResources } from "./agent-resources.mjs";
+import { inspectLocalization } from "./check-localization.mjs";
await mkdir("dist", { recursive: true });
await agentResources("dist");
-const keys = new Set();
-for (const folder of ["src", "src/ui"])
- for (const name of await readdir(folder)) {
- if (!/\.jsx?$/.test(name)) continue;
- const source = await readFile(`${folder}/${name}`, "utf8");
- for (const match of source.matchAll(/\bt\(\s*"([^"\\]+)"/g))
- keys.add(match[1]);
- }
+const panels =
+ process.env.ADA_CLOUD_PANELS_DIST ||
+ fileURLToPath(
+ new URL("../../../../My Projects/adawebsite/dist-panels", import.meta.url),
+ );
+const { keys } = await inspectLocalization();
+try {
+ for (const key of JSON.parse(
+ await readFile(resolve(panels, "cloud-panel-messages.json"), "utf8"),
+ ))
+ keys.add(key);
+} catch (error) {
+ throw new Error(
+ "Build adawebsite with npm run build:panels first, or set ADA_CLOUD_PANELS_DIST",
+ { cause: error },
+ );
+}
const locales = {};
for (const language of ["en", "ru"]) {
const catalog = JSON.parse(
@@ -38,7 +51,17 @@ await build({
legalComments: "eof",
define: { __STUDIO_AGENT_LOCALES__: JSON.stringify(locales) },
});
-await copyFile("index.html", "dist/index.html");
+// Keep the export's stable filenames, but invalidate browser asset caches on rebuild.
+let html = await readFile("index.html", "utf8");
+for (const name of ["studio.js", "studio.css"]) {
+ const version = createHash("sha256")
+ .update(await readFile(`dist/${name}`))
+ .digest("hex")
+ .slice(0, 12);
+ html = html.replace(`./${name}`, `./${name}?v=${version}`);
+}
+await writeFile("dist/index.html", html);
+await cp("assets/playground", "dist/playground", { recursive: true });
// Copy the canonical brand assets unchanged; do not redraw the AdaEngine mark.
await copyFile("../../Assets/logo_dark.svg", "dist/ada-logo.svg");
await copyFile("../../Assets/logo_light.svg", "dist/ada-logo-light.svg");
@@ -47,3 +70,25 @@ await copyFile(
"dist/recipes.md",
);
console.log("Ada Studio Web → dist/ (no editor WASM)");
+
+try {
+ await copyFile(resolve(panels, "cloud-panels.js"), "dist/cloud-panels.js");
+ await copyFile(resolve(panels, "style.css"), "dist/cloud-panels.css");
+} catch (error) {
+ throw new Error(
+ "Build adawebsite with npm run build:panels first, or set ADA_CLOUD_PANELS_DIST",
+ { cause: error },
+ );
+}
+await writeFile(
+ "dist/playground-build.json",
+ JSON.stringify({
+ formatVersion: 1,
+ shell: createHash("sha256")
+ .update(await readFile("dist/studio.js"))
+ .digest("hex"),
+ panels: createHash("sha256")
+ .update(await readFile("dist/cloud-panels.js"))
+ .digest("hex"),
+ }),
+);
diff --git a/Editor/WebUI/scripts/check-localization.mjs b/Editor/WebUI/scripts/check-localization.mjs
new file mode 100644
index 000000000..bf6c5585a
--- /dev/null
+++ b/Editor/WebUI/scripts/check-localization.mjs
@@ -0,0 +1,75 @@
+import ts from "typescript";
+import { readFile, readdir } from "node:fs/promises";
+import { fileURLToPath } from "node:url";
+import path from "node:path";
+
+export async function inspectLocalization(
+ root = fileURLToPath(new URL("../", import.meta.url)),
+) {
+ const files = [];
+ async function walk(directory) {
+ for (const item of await readdir(directory, { withFileTypes: true })) {
+ const file = path.join(directory, item.name);
+ if (item.isDirectory()) await walk(file);
+ else if (/\.jsx?$/.test(file)) files.push(file);
+ }
+ }
+ await walk(path.join(root, "src"));
+ const keys = new Set(),
+ violations = [];
+ for (const file of files) {
+ const source = await readFile(file, "utf8");
+ const ast = ts.createSourceFile(
+ file,
+ source,
+ ts.ScriptTarget.Latest,
+ true,
+ file.endsWith(".jsx") ? ts.ScriptKind.JSX : ts.ScriptKind.JS,
+ );
+ const visit = (node) => {
+ if (
+ ts.isStringLiteralLike(node) ||
+ ts.isTemplateHead(node) ||
+ ts.isTemplateMiddle(node) ||
+ ts.isTemplateTail(node) ||
+ ts.isJsxText(node)
+ ) {
+ const value = node.text;
+ // Language names are native autonyms, not UI prose to translate.
+ const autonym =
+ ts.isJsxText(node) &&
+ value.trim() === "Русский" &&
+ node.parent.openingElement?.attributes.properties.some(
+ (p) => p.name?.text === "value" && p.initializer?.text === "ru",
+ );
+ if (/[А-Яа-яЁё]/u.test(value) && !autonym)
+ violations.push(
+ `${path.relative(root, file)}:${ast.getLineAndCharacterOfPosition(node.getStart(ast)).line + 1}: ${value.slice(0, 100)}`,
+ );
+ }
+ if (
+ (ts.isCallExpression(node) || ts.isNewExpression(node)) &&
+ ["t", "LocalizedError", "translatedText"].includes(node.expression.getText(ast))
+ ) {
+ const key = node.arguments?.[0];
+ if (key && ts.isStringLiteralLike(key)) keys.add(key.text);
+ }
+ ts.forEachChild(node, visit);
+ };
+ visit(ast);
+ }
+ if (violations.length)
+ throw new Error(
+ `Hardcoded Russian outside localization catalogs:\n${violations.join("\n")}`,
+ );
+ return { keys };
+}
+if (
+ process.argv[1] &&
+ path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
+) {
+ const { keys } = await inspectLocalization();
+ console.log(
+ `WebUI localization source check OK: ${keys.size} explicit messages`,
+ );
+}
diff --git a/Editor/WebUI/scripts/dev-cloud.py b/Editor/WebUI/scripts/dev-cloud.py
new file mode 100644
index 000000000..47f7bb67a
--- /dev/null
+++ b/Editor/WebUI/scripts/dev-cloud.py
@@ -0,0 +1,358 @@
+#!/usr/bin/env python3
+"""Run the real Cloud services with isolated, persistent loopback development data."""
+import argparse
+import http.client
+from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
+from io import BytesIO
+import json
+import logging
+import os
+from pathlib import Path
+import secrets
+import shutil
+import signal
+import socket
+import subprocess
+import sys
+import threading
+import time
+from urllib.parse import urlsplit, unquote
+import urllib.error
+import urllib.request
+
+from playground_static import redirect as frontend_redirect, target as frontend_target
+from dev_inbox import inject_html, script as inbox_script
+
+UI = Path(__file__).resolve().parents[1]
+ENGINE = UI.parents[1]
+SERVICES = {'Identity': 28081, 'Settings': 28082, 'Billing': 28083,
+ 'Publishing': 28084, 'Catalog': 28085, 'Delivery': 28086,
+ 'BuildWorker': 28087, 'MultiplayerRelay': 28089,
+ 'AIService': 28090, 'Workspace': 28091}
+HOP_HEADERS = {'connection', 'transfer-encoding', 'keep-alive', 'upgrade', 'host'}
+
+
+def route(path):
+ """Only public API routes are reachable through the browser edge."""
+ path = urlsplit(path).path
+ for prefix, service in [('/v1/ai', 'AIService'), ('/v1/workspaces', 'Workspace'),
+ ('/v1/settings', 'Settings'), ('/v1/billing', 'Billing'),
+ ('/v1/uploads', 'Publishing'), ('/v1/publications', 'Publishing'),
+ ('/v1/game-releases', 'Publishing'), ('/v1/multiplayer', 'MultiplayerRelay'),
+ ('/v1/catalog', 'Catalog'), ('/v1/pages', 'Catalog'),
+ ('/v1/media', 'Catalog'), ('/v1/auth', 'Identity'),
+ ('/v1/me', 'Identity'), ('/v1/legal', 'Identity'),
+ ('/v1/availability', 'Identity')]:
+ if path == prefix or path.startswith(prefix + '/'):
+ return SERVICES[service]
+ return None
+
+
+def wait_http(url, children):
+ deadline = time.monotonic() + 90
+ while time.monotonic() < deadline:
+ for name, child in children:
+ if child.poll() is not None:
+ raise RuntimeError(f'{name} stopped; inspect its log in the state directory')
+ try:
+ with urllib.request.urlopen(url, timeout=2) as response:
+ if response.status < 300:
+ return
+ except (OSError, urllib.error.URLError):
+ time.sleep(.25)
+ raise RuntimeError('Not ready: ' + url)
+
+
+def forward(handler, port, headers=None):
+ length = int(handler.headers.get('Content-Length', '0'))
+ if length < 0 or length > 96 * 1024 * 1024:
+ handler.send_error(413)
+ return
+ body = handler.rfile.read(length) if length else None
+ outgoing = {k: v for k, v in handler.headers.items()
+ if k.lower() not in HOP_HEADERS and not k.lower().startswith('x-forwarded-')}
+ outgoing.update(headers or {})
+ connection = http.client.HTTPConnection('127.0.0.1', port, timeout=180)
+ try:
+ connection.request(handler.command, handler.path, body, outgoing)
+ response = connection.getresponse()
+ handler.send_response(response.status)
+ for key, value in response.getheaders():
+ if key.lower() not in HOP_HEADERS:
+ handler.send_header(key, value)
+ handler.send_header('Connection', 'close')
+ handler.end_headers()
+ if handler.command != 'HEAD':
+ while chunk := response.read1(65536):
+ handler.wfile.write(chunk)
+ handler.wfile.flush()
+ finally:
+ connection.close()
+ handler.close_connection = True
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--cloud', type=Path, default=ENGINE.parent / 'adaengine-service')
+ parser.add_argument('--website', type=Path, default=ENGINE.parent / 'My Projects/adawebsite/dist')
+ parser.add_argument('--state', type=Path, default=UI / '.local/cloud')
+ parser.add_argument('--bin-dir', type=Path)
+ parser.add_argument('--player', type=Path, default=UI / '.local/studio-web/player')
+ parser.add_argument('--port', type=int, default=8080)
+ parser.add_argument('--codex', action='store_true', help='Offer the local Codex CLI sign-in as a test provider')
+ parser.add_argument('--pg-bin', type=Path, default=Path(os.getenv('PG_BIN', '/opt/homebrew/opt/postgresql@17/bin')))
+ parser.add_argument('--redis', default=os.getenv('REDIS_SERVER', '/opt/homebrew/opt/redis/bin/redis-server'))
+ parser.add_argument('--kafka', type=Path, default=Path(os.getenv('KAFKA_HOME', str(ENGINE.parent / 'adaengine-service/.local/ugc-tooling/kafka_2.13-4.1.2'))))
+ parser.add_argument('--check', action='store_true', help='Check prerequisites without starting services')
+ args = parser.parse_args()
+ state = args.state.resolve()
+ binaries = (args.bin_dir or state / 'build/debug').resolve()
+ required = [args.cloud / 'Package.swift', args.website / 'index.html', UI / 'dist/studio.js',
+ args.pg_bin / 'initdb', args.pg_bin / 'postgres', Path(args.redis),
+ args.kafka / 'bin/kafka-server-start.sh'] + [binaries / name for name in SERVICES]
+ missing = [str(path) for path in required if not path.exists()]
+ try:
+ import boto3
+ from moto.server import ThreadedMotoServer
+ except ImportError:
+ missing.append('Python packages: pip install -r scripts/dev-requirements.txt')
+ if missing:
+ parser.error('Missing prerequisites:\n' + '\n'.join(missing))
+ if args.codex and not shutil.which('codex'):
+ parser.error('Install Codex CLI and run codex login before using --codex')
+ if args.check:
+ print('Cloud tools, binaries, frontend and website are present.')
+ print('Player: ' + ('present' if (args.player / 'AdaWebPlayer.wasm').exists() else 'missing; export it before scene/Play testing'))
+ return
+ # Preflight every owned port before launching anything. Never adopt another stack.
+ for port in [args.port, 8081, 25432, 26379, 29092, 29093, 29000, 29001, 28088, *SERVICES.values()]:
+ with socket.socket() as sock:
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ try:
+ sock.bind(('127.0.0.1', port))
+ except OSError as error:
+ parser.error(f'Port {port} is occupied: {error}')
+ state.mkdir(parents=True, exist_ok=True, mode=0o700)
+ os.chmod(state, 0o700)
+ config = state / 'configuration.json'
+ if not config.exists():
+ private = {key: secrets.token_hex(32) for key in
+ ['ACCESS_TOKEN_SECRET', 'INTERNAL_SECRET', 'EMAIL_CODE_SECRET', 'PROXY_SHARED_SECRET', 'S3_SECRET_KEY']}
+ with os.fdopen(os.open(config, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), 'w') as file:
+ json.dump(private, file)
+ private = json.loads(config.read_text())
+ origin = f'http://localhost:{args.port}'
+ codex_bridge = codex_provider = None
+ if args.codex:
+ from dev_codex import Bridge
+ codex_bridge = Bridge(shutil.which('codex'), state / 'codex-workspace')
+ codex_provider = codex_bridge.catalog()
+ env = {k: v for k, v in os.environ.items() if k in ('PATH', 'HOME', 'USER', 'TMPDIR', 'LANG', 'JAVA_HOME', 'DYLD_LIBRARY_PATH')}
+ env.update(private)
+ env.update(BIND_ADDRESS='127.0.0.1', DEPLOYMENT_ENV='development', CLOUD_DEV_COUNTRY='RU',
+ CLOUD_ROLLOUT_MODE='regional', REDIS_URL='redis://127.0.0.1:26379/0',
+ KAFKA_BROKERS='127.0.0.1:29092', WEB_ORIGIN=origin, API_ORIGIN=origin,
+ MAIL_URL='http://127.0.0.1:28088', LEGAL_STATUS='draft',
+ S3_ENDPOINT='http://127.0.0.1:29000', S3_REGION='us-east-1',
+ S3_BUCKET='studio-development', S3_ACCESS_KEY='local-development-only',
+ STUDIO_WEB_ENABLED='true', STUDIO_PUBLISHING_ENABLED='false',
+ STUDIO_PREVIEW_URL='http://127.0.0.1:8081/studio-preview.html',
+ STUDIO_PROVIDERS=os.getenv('STUDIO_PROVIDERS', '[]'),
+ GAMES_DOMAIN='games.localhost', GAMES_PORT_SUFFIX=':28086',
+ PYTHON_EXECUTABLE=sys.executable, MULTIPLAYER_ENABLED='false',
+ ARCHIVE_VALIDATOR=str(args.cloud / 'scripts/validate_archive.py'),
+ IMAGE_SANITIZER=str(args.cloud / 'scripts/sanitize_image.py'))
+ # Provider keys are opt-in and never persisted by the launcher.
+ for provider in json.loads(env['STUDIO_PROVIDERS']):
+ key = provider.get('apiKeyEnvironment', '')
+ if key and key in os.environ:
+ env[key] = os.environ[key]
+ for name in ['Billing', 'Publishing', 'Catalog']:
+ env[name.upper() + '_URL'] = f'http://127.0.0.1:{SERVICES[name]}'
+ if not env.get('JAVA_HOME') and Path('/opt/homebrew/opt/openjdk@21').exists():
+ env['JAVA_HOME'] = '/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home'
+ children, servers, logs = [], [], []
+ moto = None
+
+ def launch(name, command, extra=None):
+ log = (state / (name + '.log')).open('a')
+ logs.append(log)
+ child = subprocess.Popen([str(x) for x in command], cwd=args.cloud, env=dict(env, **(extra or {})), stdout=log, stderr=subprocess.STDOUT)
+ children.append((name, child))
+
+ def sql(database, statement):
+ return subprocess.check_output([str(args.pg_bin / 'psql'), '-h', '127.0.0.1', '-p', '25432', '-d', database, '-v', 'ON_ERROR_STOP=1', '-Atc', statement], text=True).strip()
+
+ def serve(port, handler):
+ server = ThreadingHTTPServer(('127.0.0.1', port), handler)
+ server.daemon_threads = True
+ servers.append(server)
+ threading.Thread(target=server.serve_forever, daemon=True).start()
+
+ def stop_signal(*_):
+ raise KeyboardInterrupt
+
+ signal.signal(signal.SIGTERM, stop_signal)
+ signal.signal(signal.SIGINT, stop_signal)
+ try:
+ pgdata = state / 'postgres'
+ if not (pgdata / 'PG_VERSION').exists():
+ subprocess.run([str(args.pg_bin / 'initdb'), '-D', str(pgdata), '-A', 'trust', '--no-locale', '--encoding=UTF8'], check=True, stdout=subprocess.DEVNULL)
+ # Fluent pools reserve connections per event loop in each service.
+ connections = max(200, (os.cpu_count() or 8) * len(SERVICES) * 2 + 32)
+ launch('postgres', [args.pg_bin / 'postgres', '-D', pgdata, '-h', '127.0.0.1', '-p', '25432',
+ '-c', 'unix_socket_directories=', '-c', f'max_connections={connections}', '-c', 'shared_buffers=128MB'])
+ for _ in range(120):
+ if subprocess.run([str(args.pg_bin / 'pg_isready'), '-h', '127.0.0.1', '-p', '25432', '-d', 'postgres'], stdout=subprocess.DEVNULL).returncode == 0:
+ break
+ time.sleep(.25)
+ user = sql('postgres', 'SELECT current_user')
+ launch('redis', [args.redis, '--bind', '127.0.0.1', '--port', '26379', '--dir', state, '--appendonly', 'yes'])
+ kafka_config = state / 'kafka.properties'
+ kafka_config.write_text(f'''process.roles=broker,controller
+node.id=1
+controller.quorum.voters=1@127.0.0.1:29093
+listeners=PLAINTEXT://127.0.0.1:29092,CONTROLLER://127.0.0.1:29093
+advertised.listeners=PLAINTEXT://127.0.0.1:29092
+controller.listener.names=CONTROLLER
+listener.security.protocol.map=CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT
+log.dirs={state / 'kafka'}
+offsets.topic.replication.factor=1
+transaction.state.log.replication.factor=1
+transaction.state.log.min.isr=1
+num.partitions=1
+''')
+ if not (state / 'kafka/meta.properties').exists():
+ subprocess.run([str(args.kafka / 'bin/kafka-storage.sh'), 'format', '-t', 'MkU3OEVBNTcwNTJENDM2Qk', '-c', str(kafka_config)], env=env, check=True, stdout=subprocess.DEVNULL)
+ launch('kafka', [args.kafka / 'bin/kafka-server-start.sh', kafka_config], {'KAFKA_HEAP_OPTS': '-Xms128m -Xmx512m'})
+ # Werkzeug otherwise logs presigned S3 query strings.
+ logging.getLogger('werkzeug').setLevel(logging.ERROR)
+ moto = ThreadedMotoServer(ip_address='127.0.0.1', port=29001, verbose=False)
+ moto.start()
+ s3 = boto3.client('s3', endpoint_url='http://127.0.0.1:29001', aws_access_key_id=env['S3_ACCESS_KEY'], aws_secret_access_key=env['S3_SECRET_KEY'], region_name='us-east-1')
+ from dev_storage import persistent_s3
+ storage_handler = persistent_s3(s3, env['S3_BUCKET'], state / 'objects', forward)
+ serve(29000, storage_handler)
+ inbox = state / 'inbox'
+ inbox.mkdir(exist_ok=True)
+
+ class Mailbox(SimpleHTTPRequestHandler):
+ def log_message(self, *_): pass
+ def do_POST(self):
+ if self.headers.get('Authorization') != 'Bearer ' + private['INTERNAL_SECRET']:
+ self.send_error(401)
+ return
+ size = int(self.headers.get('Content-Length', '0'))
+ if not 0 < size <= 4096:
+ self.send_error(413)
+ return
+ import hashlib
+ value = json.loads(self.rfile.read(size))
+ value['createdAt'] = time.time()
+ (inbox / (hashlib.sha256(value['to'].encode()).hexdigest() + '.json')).write_text(json.dumps(value))
+ self.send_response(204)
+ self.end_headers()
+ def do_GET(self):
+ import html
+ rows = [json.loads(p.read_text()) for p in inbox.glob('*.json')]
+ body = ('