Skip to content

Commit 3d8bf7c

Browse files
dsarnoclaude
andcommitted
refactor(animation): error on typed-coercion failure; relax play/stop gating
Three follow-ups from PR review: 1. `_coerce_value_for_track` now returns `{ok|error}`. Callers pre-coerce keyframe values and surface failures as INVALID_PARAMS. Previously, passing an unparseable Color string silently stored the raw text as the keyframe, producing garbage at play time. The authoring-time fallbacks are preserved: if the target node isn't in the scene yet, or the target exists but the property isn't present (retargeting case — user may set AnimationPlayer.root_node later), the raw value passes through unchanged. Errors only fire when the property resolves to a known type and the value can't be parsed. 2. `animation_play` / `animation_stop` no longer call `require_writable`. They're transient preview ops ("Runtime playback state — not saved with scene") and belong on the read side alongside `animation_list` and `animation_get`. 3. `animation_create_simple` now delegates per-track insertion to the shared `_do_add_property_track` helper — the inline track-building block is gone. Coercion logic now has a single caller path. Tests: two Python unit tests lock in play/stop-as-read-only; one GDScript test exercises the new error path (unparseable color on a Sprite2D's modulate). Four existing tests updated to use Vector3 dicts (`{x,y,z}`) instead of Vector2 dicts for `.:position` on the Node3D scene root — they passed before only because the old coercer silently fell through. Live smoke test: all 312 GDScript tests pass, all 375 Python tests pass, ruff clean. Exercised coerce → play → stop → unparseable-color rejection → create_simple(color strings) → get roundtrip against the live editor. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e23c1f7 commit 3d8bf7c

4 files changed

Lines changed: 149 additions & 60 deletions

File tree

plugin/addons/godot_ai/handlers/animation_handler.gd

Lines changed: 76 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -177,19 +177,29 @@ func add_property_track(params: Dictionary) -> Dictionary:
177177
return anim_resolved
178178
var anim: Animation = anim_resolved.animation
179179

180-
# Validate keyframe structure before mutating.
180+
# Validate + pre-coerce keyframes before mutating. Coercion errors
181+
# surface as INVALID_PARAMS rather than silently inserting garbage keys.
182+
var coerced_keyframes: Array = []
181183
for kf in keyframes:
182184
if typeof(kf) != TYPE_DICTIONARY:
183185
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "Each keyframe must be a dictionary")
184186
if not "time" in kf:
185187
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "Each keyframe must have a 'time' field")
186188
if not "value" in kf:
187189
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "Each keyframe must have a 'value' field")
190+
var coerce_result := _coerce_value_for_track(kf.get("value"), track_path, player)
191+
if coerce_result.has("error"):
192+
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, coerce_result.error)
193+
coerced_keyframes.append({
194+
"time": kf.get("time"),
195+
"value": coerce_result.ok,
196+
"transition": kf.get("transition", "linear"),
197+
})
188198

189199
var baseline := anim.get_track_count()
190200

191201
_undo_redo.create_action("MCP: Add property track %s to %s" % [track_path, anim_name])
192-
_undo_redo.add_do_method(self, "_do_add_property_track", anim, track_path, interp_str, keyframes, player)
202+
_undo_redo.add_do_method(self, "_do_add_property_track", anim, track_path, interp_str, coerced_keyframes)
193203
_undo_redo.add_undo_method(anim, "remove_track", baseline)
194204
_undo_redo.commit_action()
195205

@@ -206,22 +216,23 @@ func add_property_track(params: Dictionary) -> Dictionary:
206216
}
207217

208218

219+
## Insert a pre-coerced track into the animation. Callers must coerce
220+
## values against the target property before calling this (see
221+
## _coerce_value_for_track) — this method runs inside the undo do-method
222+
## path where error propagation isn't possible.
209223
func _do_add_property_track(
210224
anim: Animation,
211225
track_path: String,
212226
interp_str: String,
213227
keyframes: Array,
214-
player: AnimationPlayer,
215228
) -> void:
216229
var idx := anim.add_track(Animation.TYPE_VALUE)
217230
anim.track_set_path(idx, NodePath(track_path))
218231
anim.track_set_interpolation_type(idx, _INTERP_MODES.get(interp_str, Animation.INTERPOLATION_LINEAR))
219232
for kf in keyframes:
220233
var t: float = float(kf.get("time", 0.0))
221-
var raw_value = kf.get("value")
222234
var trans: float = _parse_transition(kf.get("transition", "linear"))
223-
var coerced = _coerce_value_for_track(raw_value, track_path, player)
224-
anim.track_insert_key(idx, t, coerced, trans)
235+
anim.track_insert_key(idx, t, kf.get("value"), trans)
225236

226237

227238
# ============================================================================
@@ -554,30 +565,37 @@ func create_simple(params: Dictionary) -> Dictionary:
554565
if computed_length <= 0.0:
555566
computed_length = 1.0
556567

557-
# Build the animation fully in memory before touching the undo stack.
558-
var anim := Animation.new()
559-
anim.length = computed_length
560-
anim.loop_mode = _LOOP_MODES[loop_mode_str]
561-
568+
# Pre-coerce all tween values before touching the anim — coercion errors
569+
# surface as INVALID_PARAMS, not silent garbage keyframes.
570+
var per_track_keyframes: Array = []
562571
for spec in tweens:
563572
var target: String = str(spec.get("target", ""))
564573
var property: String = str(spec.get("property", ""))
565-
var from_val = spec.get("from")
566-
var to_val = spec.get("to")
574+
var track_path: String = target + ":" + property
567575
var duration: float = float(spec.get("duration", 1.0))
568576
var delay: float = float(spec.get("delay", 0.0))
569577
var trans_str = spec.get("transition", "linear")
570-
var trans: float = _parse_transition(trans_str)
578+
var from_result := _coerce_value_for_track(spec.get("from"), track_path, player)
579+
if from_result.has("error"):
580+
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "tween '%s': %s" % [track_path, from_result.error])
581+
var to_result := _coerce_value_for_track(spec.get("to"), track_path, player)
582+
if to_result.has("error"):
583+
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "tween '%s': %s" % [track_path, to_result.error])
584+
per_track_keyframes.append({
585+
"track_path": track_path,
586+
"keyframes": [
587+
{"time": delay, "value": from_result.ok, "transition": trans_str},
588+
{"time": delay + duration, "value": to_result.ok, "transition": trans_str},
589+
],
590+
})
571591

572-
var track_path: String = target + ":" + property
573-
var idx := anim.add_track(Animation.TYPE_VALUE)
574-
anim.track_set_path(idx, NodePath(track_path))
575-
anim.track_set_interpolation_type(idx, Animation.INTERPOLATION_LINEAR)
592+
# Build the animation fully in memory before touching the undo stack.
593+
var anim := Animation.new()
594+
anim.length = computed_length
595+
anim.loop_mode = _LOOP_MODES[loop_mode_str]
576596

577-
var coerced_from = _coerce_value_for_track(from_val, track_path, player)
578-
var coerced_to = _coerce_value_for_track(to_val, track_path, player)
579-
anim.track_insert_key(idx, delay, coerced_from, trans)
580-
anim.track_insert_key(idx, delay + duration, coerced_to, trans)
597+
for entry in per_track_keyframes:
598+
_do_add_property_track(anim, entry.track_path, "linear", entry.keyframes)
581599

582600
# One atomic undo action.
583601
_undo_redo.create_action("MCP: Create animation %s (%d tracks)" % [anim_name, anim.get_track_count()])
@@ -675,18 +693,19 @@ func _resolve_animation(player: AnimationPlayer, anim_name: String) -> Dictionar
675693
# ============================================================================
676694

677695
## Coerce a JSON value to match the expected Godot type for the given
678-
## track_path. Resolves the target node and property type via reflection.
679-
## Falls back to raw value if the target cannot be found at author time.
680-
static func _coerce_value_for_track(value: Variant, track_path: String, player: AnimationPlayer) -> Variant:
681-
# Parse track_path "NodeName:property" or ".:property"
696+
## track_path. Returns {"ok": value} or {"error": msg}.
697+
## Passes the raw value through when the target node isn't in the scene
698+
## yet (authoring-time path). Errors when the target exists but the
699+
## property doesn't, or when parsing a typed value (Color/Vector2/Vector3)
700+
## clearly fails — better to reject than silently store garbage.
701+
static func _coerce_value_for_track(value: Variant, track_path: String, player: AnimationPlayer) -> Dictionary:
682702
var colon := track_path.rfind(":")
683703
if colon < 0:
684-
return value
704+
return {"ok": value}
685705

686706
var node_part := track_path.substr(0, colon)
687707
var prop_part := track_path.substr(colon + 1)
688708

689-
# Resolve node relative to AnimationPlayer's root (defaults to parent).
690709
var root_node: Node = null
691710
if player.is_inside_tree():
692711
var rn := player.root_node
@@ -695,56 +714,62 @@ static func _coerce_value_for_track(value: Variant, track_path: String, player:
695714
if root_node == null:
696715
root_node = player.get_parent()
697716
if root_node == null:
698-
return value
717+
return {"ok": value}
699718

700719
var target: Node = root_node.get_node_or_null(node_part)
701720
if target == null:
702-
return value # Target not in scene yet; pass through raw value.
721+
return {"ok": value}
703722

704-
# Inspect target property type.
705723
for p in target.get_property_list():
706724
if p.name == prop_part:
707-
return _coerce_for_type(value, p.get("type", TYPE_NIL))
725+
return _coerce_for_type(value, p.get("type", TYPE_NIL), prop_part)
708726

709-
return value
727+
# Property not found on current target — pass through. The caller may
728+
# plan to retarget the AnimationPlayer (set root_node) before playback.
729+
return {"ok": value}
710730

711731

712-
## Coerce a single value to the given Godot variant type.
713-
static func _coerce_for_type(value: Variant, prop_type: int) -> Variant:
732+
## Coerce a single value to the given Godot variant type. Returns
733+
## {"ok": coerced} or {"error": msg}. Unknown types pass through.
734+
static func _coerce_for_type(value: Variant, prop_type: int, prop_name: String) -> Dictionary:
714735
match prop_type:
715736
TYPE_COLOR:
716737
if value is Color:
717-
return value
738+
return {"ok": value}
718739
if value is String:
719-
var a := Color.from_string(value, Color(0, 0, 0, 0))
720-
var b := Color.from_string(value, Color(1, 1, 1, 1))
740+
var s := value as String
741+
var a := Color.from_string(s, Color(0, 0, 0, 0))
742+
var b := Color.from_string(s, Color(1, 1, 1, 1))
721743
if a == b:
722-
return a
723-
return value # Unparseable — pass through, fail at play time.
744+
return {"ok": a}
745+
return {"error": "Cannot parse '%s' as Color for property '%s'" % [s, prop_name]}
724746
if value is Dictionary and value.has("r") and value.has("g") and value.has("b"):
725-
return Color(float(value.r), float(value.g), float(value.b), float(value.get("a", 1.0)))
747+
return {"ok": Color(float(value.r), float(value.g), float(value.b), float(value.get("a", 1.0)))}
748+
return {"error": "Cannot coerce value to Color for property '%s' (expected string, {r,g,b}, or Color)" % prop_name}
726749
TYPE_VECTOR2:
727750
if value is Vector2:
728-
return value
751+
return {"ok": value}
729752
if value is Dictionary and value.has("x") and value.has("y"):
730-
return Vector2(float(value.x), float(value.y))
753+
return {"ok": Vector2(float(value.x), float(value.y))}
731754
if value is Array and value.size() >= 2:
732-
return Vector2(float(value[0]), float(value[1]))
755+
return {"ok": Vector2(float(value[0]), float(value[1]))}
756+
return {"error": "Cannot coerce value to Vector2 for property '%s' (expected {x,y}, [x,y], or Vector2)" % prop_name}
733757
TYPE_VECTOR3:
734758
if value is Vector3:
735-
return value
759+
return {"ok": value}
736760
if value is Dictionary and value.has("x") and value.has("y") and value.has("z"):
737-
return Vector3(float(value.x), float(value.y), float(value.z))
761+
return {"ok": Vector3(float(value.x), float(value.y), float(value.z))}
762+
return {"error": "Cannot coerce value to Vector3 for property '%s' (expected {x,y,z} or Vector3)" % prop_name}
738763
TYPE_FLOAT:
739764
if value is int or value is float:
740-
return float(value)
765+
return {"ok": float(value)}
741766
TYPE_INT:
742767
if value is float or value is int:
743-
return int(value)
768+
return {"ok": int(value)}
744769
TYPE_BOOL:
745-
if value is int or value is float:
746-
return bool(value)
747-
return value
770+
if value is int or value is float or value is bool:
771+
return {"ok": bool(value)}
772+
return {"ok": value}
748773

749774

750775
# ============================================================================

src/godot_ai/handlers/animation.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,6 @@ async def animation_play(
9696
player_path: str,
9797
animation_name: str = "",
9898
) -> dict:
99-
require_writable(runtime)
10099
return await runtime.send_command(
101100
"animation_play",
102101
{"player_path": player_path, "animation_name": animation_name},
@@ -107,7 +106,6 @@ async def animation_stop(
107106
runtime: Runtime,
108107
player_path: str,
109108
) -> dict:
110-
require_writable(runtime)
111109
return await runtime.send_command(
112110
"animation_stop",
113111
{"player_path": player_path},

test_project/tests/test_animation.gd

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -255,8 +255,8 @@ func test_add_property_track_transition_named() -> void:
255255
"animation_name": "anim",
256256
"track_path": ".:position",
257257
"keyframes": [
258-
{"time": 0.0, "value": {"x": 0.0, "y": 0.0}, "transition": "ease_out"},
259-
{"time": 1.0, "value": {"x": 100.0, "y": 0.0}, "transition": "ease_out"},
258+
{"time": 0.0, "value": {"x": 0.0, "y": 0.0, "z": 0.0}, "transition": "ease_out"},
259+
{"time": 1.0, "value": {"x": 100.0, "y": 0.0, "z": 0.0}, "transition": "ease_out"},
260260
],
261261
})
262262
assert_has_key(result, "data")
@@ -323,6 +323,36 @@ func test_create_simple_coerces_vector3() -> void:
323323
_remove_node(player_path)
324324

325325

326+
func test_add_property_track_rejects_unparseable_color() -> void:
327+
# When the target property exists and has a known type (here, Color on
328+
# Sprite2D.modulate), an unparseable string value should fail at author
329+
# time rather than silently ending up as raw text in the keyframe.
330+
var scene_root := EditorInterface.get_edited_scene_root()
331+
var sprite := Sprite2D.new()
332+
sprite.name = "ColorSprite"
333+
scene_root.add_child(sprite)
334+
sprite.owner = scene_root
335+
336+
var player_path := _add_player("TestBadColor")
337+
if player_path.is_empty():
338+
sprite.get_parent().remove_child(sprite)
339+
sprite.queue_free()
340+
return
341+
_handler.create_animation({"player_path": player_path, "name": "anim", "length": 1.0})
342+
var result := _handler.add_property_track({
343+
"player_path": player_path,
344+
"animation_name": "anim",
345+
"track_path": "ColorSprite:modulate",
346+
"keyframes": [
347+
{"time": 0.0, "value": "not_a_color"},
348+
],
349+
})
350+
assert_is_error(result, "", "expected INVALID_PARAMS for unparseable color string")
351+
_remove_node(player_path)
352+
sprite.get_parent().remove_child(sprite)
353+
sprite.queue_free()
354+
355+
326356
func test_add_property_track_transition_raw_float() -> void:
327357
var player_path := _add_player("TestTransFloat")
328358
if player_path.is_empty():
@@ -333,8 +363,8 @@ func test_add_property_track_transition_raw_float() -> void:
333363
"animation_name": "anim",
334364
"track_path": ".:position",
335365
"keyframes": [
336-
{"time": 0.0, "value": {"x": 0.0, "y": 0.0}, "transition": 3.0},
337-
{"time": 1.0, "value": {"x": 100.0, "y": 0.0}, "transition": 3.0},
366+
{"time": 0.0, "value": {"x": 0.0, "y": 0.0, "z": 0.0}, "transition": 3.0},
367+
{"time": 1.0, "value": {"x": 100.0, "y": 0.0, "z": 0.0}, "transition": 3.0},
338368
],
339369
})
340370
assert_has_key(result, "data")
@@ -535,8 +565,8 @@ func test_create_simple_auto_length() -> void:
535565
{
536566
"target": ".",
537567
"property": "position",
538-
"from": {"x": -400.0, "y": 0.0},
539-
"to": {"x": 0.0, "y": 0.0},
568+
"from": {"x": -400.0, "y": 0.0, "z": 0.0},
569+
"to": {"x": 0.0, "y": 0.0, "z": 0.0},
540570
"duration": 0.4,
541571
"delay": 0.1,
542572
}
@@ -582,7 +612,7 @@ func test_create_simple_multiple_tweens() -> void:
582612
"name": "combo",
583613
"tweens": [
584614
{"target": ".", "property": "modulate", "from": {"r":1,"g":1,"b":1,"a":0}, "to": {"r":1,"g":1,"b":1,"a":1}, "duration": 0.5},
585-
{"target": ".", "property": "position", "from": {"x": -200.0, "y": 0.0}, "to": {"x": 0.0, "y": 0.0}, "duration": 0.3, "delay": 0.1},
615+
{"target": ".", "property": "position", "from": {"x": -200.0, "y": 0.0, "z": 0.0}, "to": {"x": 0.0, "y": 0.0, "z": 0.0}, "duration": 0.3, "delay": 0.1},
586616
],
587617
})
588618
assert_has_key(result, "data")

tests/unit/test_runtime_handlers.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2604,6 +2604,42 @@ async def test_animation_list_does_not_require_writable():
26042604
assert result["count"] == 2
26052605

26062606

2607+
async def test_animation_play_does_not_require_writable():
2608+
"""animation_play is a preview op — it must not call require_writable."""
2609+
from godot_ai.sessions.registry import Session
2610+
2611+
client = StubClient()
2612+
session = Session(
2613+
session_id="s1",
2614+
godot_version="4.4",
2615+
project_path="/tmp/p",
2616+
plugin_version="0.1",
2617+
readiness="playing",
2618+
)
2619+
registry = SessionRegistry()
2620+
registry.register(session)
2621+
runtime = DirectRuntime(registry=registry, client=client)
2622+
await animation_handlers.animation_play(runtime, player_path="/Main/AP", animation_name="idle")
2623+
2624+
2625+
async def test_animation_stop_does_not_require_writable():
2626+
"""animation_stop is a preview op — it must not call require_writable."""
2627+
from godot_ai.sessions.registry import Session
2628+
2629+
client = StubClient()
2630+
session = Session(
2631+
session_id="s1",
2632+
godot_version="4.4",
2633+
project_path="/tmp/p",
2634+
plugin_version="0.1",
2635+
readiness="playing",
2636+
)
2637+
registry = SessionRegistry()
2638+
registry.register(session)
2639+
runtime = DirectRuntime(registry=registry, client=client)
2640+
await animation_handlers.animation_stop(runtime, player_path="/Main/AP")
2641+
2642+
26072643
async def test_animation_player_create_requires_writable():
26082644
"""Write tools must raise EDITOR_NOT_READY when editor is importing."""
26092645
from godot_ai.godot_client.client import GodotCommandError

0 commit comments

Comments
 (0)