2026-08-01 22:55:39 -04:00
|
|
|
class_name SurfaceDrawingProtocol
|
|
|
|
|
extends RefCounted
|
|
|
|
|
|
2026-08-02 00:45:07 -04:00
|
|
|
const CAPABILITY: StringName = &"surface_drawing_v2"
|
feat: expand progression and multiplayer systems
Add named save slots, progression import/export, and a unified play flow. Add live friend requests, presence, invitations, and relationship controls without durable discovery-server social storage. Advance the network protocol with isolated channels, movement reconciliation, late-join recovery, fishing replication, and animation synchronization. Preserve per-species catch totals, refine generated-world startup and water recovery, and complete the related input and interface improvements.
2026-08-23 20:48:38 -04:00
|
|
|
const RELIABLE_CHANNEL: int = NetworkProtocol.DRAWING_RELIABLE_CHANNEL
|
2026-08-02 00:45:07 -04:00
|
|
|
const GRID_SIZES: Array[int] = [16, 32, 64, 128]
|
|
|
|
|
const DEFAULT_GRID_SIZE: int = 16
|
|
|
|
|
const MAX_GRID_SIZE: int = 128
|
|
|
|
|
# Compatibility aliases for callers that only need the default dimensions.
|
|
|
|
|
const GRID_WIDTH: int = DEFAULT_GRID_SIZE
|
|
|
|
|
const GRID_HEIGHT: int = DEFAULT_GRID_SIZE
|
2026-08-01 22:55:39 -04:00
|
|
|
const CELL_SIZE: float = 0.075
|
|
|
|
|
const MAX_ACTIVE_CANVASES: int = 24
|
|
|
|
|
const MAX_CANVASES: int = 48
|
2026-08-02 00:45:07 -04:00
|
|
|
const MAX_SESSION_GRID_CELLS: int = 49152
|
2026-08-01 22:55:39 -04:00
|
|
|
const MAX_EDITS_PER_REQUEST: int = 16
|
|
|
|
|
const MAX_CANVAS_ID_LENGTH: int = 64
|
|
|
|
|
const MAX_REQUEST_ID_LENGTH: int = 64
|
|
|
|
|
const MAX_STROKE_ID_LENGTH: int = 64
|
|
|
|
|
const MAX_SESSION_ID_LENGTH: int = 96
|
2026-08-21 21:01:21 -04:00
|
|
|
# Sessions support up to 128 simultaneous players. Keep enough bounded history
|
|
|
|
|
# for a complete room turnover while never accepting unattributed edits.
|
|
|
|
|
const MAX_PARTICIPANTS: int = 256
|
2026-08-01 22:55:39 -04:00
|
|
|
const MAX_COORDINATE: float = 10000.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static func validate_canvas_request(data: Variant) -> bool:
|
|
|
|
|
if typeof(data) != TYPE_DICTIONARY:
|
|
|
|
|
return false
|
|
|
|
|
var value: Dictionary = data
|
|
|
|
|
return (
|
|
|
|
|
_valid_common(value)
|
|
|
|
|
and _valid_vector(value.get("origin"))
|
|
|
|
|
and _valid_vector(value.get("normal"))
|
|
|
|
|
and _valid_vector(value.get("tangent"))
|
|
|
|
|
and typeof(value.get("width")) == TYPE_INT
|
2026-08-02 00:45:07 -04:00
|
|
|
and int(value["width"]) in GRID_SIZES
|
2026-08-01 22:55:39 -04:00
|
|
|
and typeof(value.get("height")) == TYPE_INT
|
2026-08-02 00:45:07 -04:00
|
|
|
and int(value["height"]) == int(value["width"])
|
2026-08-01 22:55:39 -04:00
|
|
|
and typeof(value.get("cell_size")) in [TYPE_FLOAT, TYPE_INT]
|
|
|
|
|
and is_equal_approx(float(value["cell_size"]), CELL_SIZE)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static func validate_edit_request(data: Variant) -> bool:
|
|
|
|
|
if typeof(data) != TYPE_DICTIONARY:
|
|
|
|
|
return false
|
|
|
|
|
var value: Dictionary = data
|
|
|
|
|
if (
|
|
|
|
|
not _valid_common(value)
|
|
|
|
|
or typeof(value.get("canvas_id")) != TYPE_STRING
|
|
|
|
|
or str(value["canvas_id"]).is_empty()
|
|
|
|
|
or str(value["canvas_id"]).length() > MAX_CANVAS_ID_LENGTH
|
|
|
|
|
or not _valid_stroke_id(value.get("stroke_id"))
|
2026-08-02 00:45:07 -04:00
|
|
|
or typeof(value.get("brush_size")) != TYPE_INT
|
|
|
|
|
or int(value["brush_size"]) < 1
|
|
|
|
|
or int(value["brush_size"]) > 4
|
2026-08-01 22:55:39 -04:00
|
|
|
or typeof(value.get("edits")) != TYPE_ARRAY
|
|
|
|
|
):
|
|
|
|
|
return false
|
|
|
|
|
var edits: Array = value["edits"]
|
|
|
|
|
if edits.is_empty() or edits.size() > MAX_EDITS_PER_REQUEST:
|
|
|
|
|
return false
|
|
|
|
|
for edit_value: Variant in edits:
|
|
|
|
|
if not validate_cell_edit(edit_value):
|
|
|
|
|
return false
|
|
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
|
2026-08-21 21:01:21 -04:00
|
|
|
static func validate_stamp_request(data: Variant) -> bool:
|
|
|
|
|
if typeof(data) != TYPE_DICTIONARY:
|
|
|
|
|
return false
|
|
|
|
|
var value: Dictionary = data
|
|
|
|
|
if (
|
|
|
|
|
not _valid_common(value)
|
|
|
|
|
or not _valid_vector(value.get("origin"))
|
|
|
|
|
or not _valid_vector(value.get("normal"))
|
|
|
|
|
or not _valid_vector(value.get("tangent"))
|
|
|
|
|
or typeof(value.get("width")) != TYPE_INT
|
|
|
|
|
or int(value["width"]) not in GRID_SIZES
|
|
|
|
|
or typeof(value.get("height")) != TYPE_INT
|
|
|
|
|
or int(value["height"]) != int(value["width"])
|
|
|
|
|
or typeof(value.get("cell_size")) not in [TYPE_FLOAT, TYPE_INT]
|
|
|
|
|
or not is_equal_approx(float(value["cell_size"]), CELL_SIZE)
|
|
|
|
|
or typeof(value.get("pixels")) != TYPE_PACKED_BYTE_ARRAY
|
|
|
|
|
):
|
|
|
|
|
return false
|
|
|
|
|
var pixels: PackedByteArray = value["pixels"]
|
|
|
|
|
var expected_size: int = int(value["width"]) * int(value["height"])
|
|
|
|
|
if pixels.size() != expected_size:
|
|
|
|
|
return false
|
|
|
|
|
var maximum_palette_index: int = SurfaceDrawingPalette.COLORS.size()
|
|
|
|
|
var painted_count: int = 0
|
|
|
|
|
for palette_index: int in pixels:
|
|
|
|
|
if palette_index > maximum_palette_index:
|
|
|
|
|
return false
|
|
|
|
|
if palette_index > 0:
|
|
|
|
|
painted_count += 1
|
|
|
|
|
return painted_count > 0
|
|
|
|
|
|
|
|
|
|
|
2026-08-01 22:55:39 -04:00
|
|
|
static func validate_guide_request(data: Variant) -> bool:
|
|
|
|
|
if typeof(data) != TYPE_DICTIONARY:
|
|
|
|
|
return false
|
|
|
|
|
var value: Dictionary = data
|
|
|
|
|
return (
|
|
|
|
|
_valid_common(value)
|
|
|
|
|
and _valid_canvas_id(value.get("canvas_id"))
|
|
|
|
|
and typeof(value.get("guide_visible")) == TYPE_BOOL
|
|
|
|
|
and typeof(value.get("finalized")) == TYPE_BOOL
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static func validate_undo_request(data: Variant) -> bool:
|
|
|
|
|
if typeof(data) != TYPE_DICTIONARY:
|
|
|
|
|
return false
|
|
|
|
|
var value: Dictionary = data
|
|
|
|
|
return _valid_common(value) and _valid_stroke_id(value.get("stroke_id"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static func validate_canvas_state(data: Variant) -> bool:
|
|
|
|
|
if typeof(data) != TYPE_DICTIONARY:
|
|
|
|
|
return false
|
|
|
|
|
var value: Dictionary = data
|
|
|
|
|
if (
|
|
|
|
|
typeof(value.get("session_id")) != TYPE_STRING
|
|
|
|
|
or str(value["session_id"]).is_empty()
|
|
|
|
|
or str(value["session_id"]).length() > MAX_SESSION_ID_LENGTH
|
|
|
|
|
or typeof(value.get("canvas_id")) != TYPE_STRING
|
|
|
|
|
or str(value["canvas_id"]).is_empty()
|
|
|
|
|
or str(value["canvas_id"]).length() > MAX_CANVAS_ID_LENGTH
|
|
|
|
|
or not _valid_vector(value.get("origin"))
|
|
|
|
|
or not _valid_vector(value.get("normal"))
|
|
|
|
|
or not _valid_vector(value.get("tangent"))
|
|
|
|
|
or typeof(value.get("width")) != TYPE_INT
|
2026-08-02 00:45:07 -04:00
|
|
|
or int(value["width"]) not in GRID_SIZES
|
2026-08-01 22:55:39 -04:00
|
|
|
or typeof(value.get("height")) != TYPE_INT
|
2026-08-02 00:45:07 -04:00
|
|
|
or int(value["height"]) != int(value["width"])
|
2026-08-01 22:55:39 -04:00
|
|
|
or typeof(value.get("cell_size")) not in [TYPE_FLOAT, TYPE_INT]
|
|
|
|
|
or not is_equal_approx(float(value["cell_size"]), CELL_SIZE)
|
|
|
|
|
or typeof(value.get("revision")) != TYPE_INT
|
|
|
|
|
or int(value["revision"]) < 0
|
|
|
|
|
or typeof(value.get("guide_visible", true)) != TYPE_BOOL
|
|
|
|
|
or typeof(value.get("finalized", false)) != TYPE_BOOL
|
|
|
|
|
or typeof(value.get("layer", 0)) != TYPE_INT
|
|
|
|
|
or int(value.get("layer", 0)) < 0
|
|
|
|
|
or not NetworkIdentityCrypto.valid_fingerprint(
|
|
|
|
|
value.get("creator_fingerprint")
|
|
|
|
|
)
|
2026-08-21 21:01:21 -04:00
|
|
|
or not _valid_participant_fingerprints(
|
|
|
|
|
value.get("participant_fingerprints", [])
|
|
|
|
|
)
|
2026-08-01 22:55:39 -04:00
|
|
|
or typeof(value.get("cells")) != TYPE_ARRAY
|
|
|
|
|
):
|
|
|
|
|
return false
|
|
|
|
|
var cells: Array = value["cells"]
|
2026-08-02 00:45:07 -04:00
|
|
|
var grid_width: int = int(value["width"])
|
|
|
|
|
var grid_height: int = int(value["height"])
|
|
|
|
|
if cells.size() > grid_width * grid_height:
|
2026-08-01 22:55:39 -04:00
|
|
|
return false
|
|
|
|
|
for cell_value: Variant in cells:
|
2026-08-02 00:45:07 -04:00
|
|
|
if (
|
|
|
|
|
not validate_authoritative_cell(cell_value)
|
|
|
|
|
or int((cell_value as Dictionary)["x"]) >= grid_width
|
|
|
|
|
or int((cell_value as Dictionary)["y"]) >= grid_height
|
|
|
|
|
):
|
2026-08-01 22:55:39 -04:00
|
|
|
return false
|
2026-08-21 21:01:21 -04:00
|
|
|
var participants: Array = value.get("participant_fingerprints", [])
|
|
|
|
|
if not participants.is_empty():
|
|
|
|
|
if str(value["creator_fingerprint"]) not in participants:
|
|
|
|
|
return false
|
|
|
|
|
for cell_value: Variant in cells:
|
|
|
|
|
if str((cell_value as Dictionary)["author_fingerprint"]) not in participants:
|
|
|
|
|
return false
|
2026-08-01 22:55:39 -04:00
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static func validate_guide_update(data: Variant) -> bool:
|
|
|
|
|
if typeof(data) != TYPE_DICTIONARY:
|
|
|
|
|
return false
|
|
|
|
|
var value: Dictionary = data
|
|
|
|
|
return (
|
|
|
|
|
typeof(value.get("session_id")) == TYPE_STRING
|
|
|
|
|
and not str(value["session_id"]).is_empty()
|
|
|
|
|
and str(value["session_id"]).length() <= MAX_SESSION_ID_LENGTH
|
|
|
|
|
and _valid_canvas_id(value.get("canvas_id"))
|
|
|
|
|
and typeof(value.get("revision")) == TYPE_INT
|
|
|
|
|
and int(value["revision"]) >= 1
|
|
|
|
|
and typeof(value.get("guide_visible")) == TYPE_BOOL
|
|
|
|
|
and typeof(value.get("finalized")) == TYPE_BOOL
|
2026-08-21 21:01:21 -04:00
|
|
|
and _valid_participant_fingerprints(
|
|
|
|
|
value.get("participant_fingerprints", [])
|
|
|
|
|
)
|
2026-08-01 22:55:39 -04:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static func validate_canvas_update(data: Variant) -> bool:
|
|
|
|
|
if typeof(data) != TYPE_DICTIONARY:
|
|
|
|
|
return false
|
|
|
|
|
var value: Dictionary = data
|
|
|
|
|
if (
|
|
|
|
|
typeof(value.get("session_id")) != TYPE_STRING
|
|
|
|
|
or str(value["session_id"]).is_empty()
|
|
|
|
|
or str(value["session_id"]).length() > MAX_SESSION_ID_LENGTH
|
|
|
|
|
or typeof(value.get("canvas_id")) != TYPE_STRING
|
|
|
|
|
or str(value["canvas_id"]).is_empty()
|
|
|
|
|
or str(value["canvas_id"]).length() > MAX_CANVAS_ID_LENGTH
|
|
|
|
|
or typeof(value.get("revision")) != TYPE_INT
|
|
|
|
|
or int(value["revision"]) < 1
|
|
|
|
|
or typeof(value.get("edits")) != TYPE_ARRAY
|
2026-08-21 21:01:21 -04:00
|
|
|
or not _valid_participant_fingerprints(
|
|
|
|
|
value.get("participant_fingerprints", [])
|
|
|
|
|
)
|
2026-08-01 22:55:39 -04:00
|
|
|
):
|
|
|
|
|
return false
|
|
|
|
|
var edits: Array = value["edits"]
|
|
|
|
|
if edits.is_empty() or edits.size() > MAX_EDITS_PER_REQUEST:
|
|
|
|
|
return false
|
|
|
|
|
for edit_value: Variant in edits:
|
|
|
|
|
if not validate_authoritative_cell(edit_value, true):
|
|
|
|
|
return false
|
2026-08-21 21:01:21 -04:00
|
|
|
var participants: Array = value.get("participant_fingerprints", [])
|
|
|
|
|
if not participants.is_empty():
|
|
|
|
|
for edit_value: Variant in edits:
|
|
|
|
|
var edit: Dictionary = edit_value
|
|
|
|
|
var author_fingerprint: String = str(
|
|
|
|
|
edit.get("author_fingerprint", "")
|
|
|
|
|
)
|
|
|
|
|
if (
|
|
|
|
|
not str(edit.get("color_id", "")).is_empty()
|
|
|
|
|
and author_fingerprint not in participants
|
|
|
|
|
):
|
|
|
|
|
return false
|
2026-08-01 22:55:39 -04:00
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static func validate_cell_edit(data: Variant) -> bool:
|
|
|
|
|
if typeof(data) != TYPE_DICTIONARY:
|
|
|
|
|
return false
|
|
|
|
|
var value: Dictionary = data
|
|
|
|
|
if (
|
|
|
|
|
typeof(value.get("x")) != TYPE_INT
|
|
|
|
|
or int(value["x"]) < 0
|
2026-08-02 00:45:07 -04:00
|
|
|
or int(value["x"]) >= MAX_GRID_SIZE
|
2026-08-01 22:55:39 -04:00
|
|
|
or typeof(value.get("y")) != TYPE_INT
|
|
|
|
|
or int(value["y"]) < 0
|
2026-08-02 00:45:07 -04:00
|
|
|
or int(value["y"]) >= MAX_GRID_SIZE
|
2026-08-01 22:55:39 -04:00
|
|
|
or typeof(value.get("color_id")) not in [TYPE_STRING, TYPE_STRING_NAME]
|
|
|
|
|
):
|
|
|
|
|
return false
|
|
|
|
|
var color_id := StringName(str(value["color_id"]))
|
|
|
|
|
return color_id.is_empty() or SurfaceDrawingPalette.has_color(color_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static func validate_authoritative_cell(
|
|
|
|
|
data: Variant,
|
|
|
|
|
allow_erased: bool = false,
|
|
|
|
|
) -> bool:
|
|
|
|
|
if not validate_cell_edit(data):
|
|
|
|
|
return false
|
|
|
|
|
var value: Dictionary = data
|
|
|
|
|
var color_id := StringName(str(value["color_id"]))
|
|
|
|
|
if color_id.is_empty():
|
|
|
|
|
return allow_erased and str(value.get("author_fingerprint", "")).is_empty()
|
|
|
|
|
return NetworkIdentityCrypto.valid_fingerprint(
|
|
|
|
|
value.get("author_fingerprint")
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static func vector_to_array(value: Vector3) -> Array[float]:
|
|
|
|
|
return [value.x, value.y, value.z]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static func array_to_vector(value: Variant) -> Vector3:
|
|
|
|
|
if not _valid_vector(value):
|
|
|
|
|
return Vector3.ZERO
|
|
|
|
|
var array: Array = value
|
|
|
|
|
return Vector3(float(array[0]), float(array[1]), float(array[2]))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static func _valid_common(value: Dictionary) -> bool:
|
|
|
|
|
return (
|
|
|
|
|
typeof(value.get("request_id")) == TYPE_STRING
|
|
|
|
|
and not str(value["request_id"]).is_empty()
|
|
|
|
|
and str(value["request_id"]).length() <= MAX_REQUEST_ID_LENGTH
|
|
|
|
|
and typeof(value.get("session_id")) == TYPE_STRING
|
|
|
|
|
and not str(value["session_id"]).is_empty()
|
|
|
|
|
and str(value["session_id"]).length() <= MAX_SESSION_ID_LENGTH
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static func _valid_canvas_id(value: Variant) -> bool:
|
|
|
|
|
return (
|
|
|
|
|
typeof(value) == TYPE_STRING
|
|
|
|
|
and not str(value).is_empty()
|
|
|
|
|
and str(value).length() <= MAX_CANVAS_ID_LENGTH
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static func _valid_stroke_id(value: Variant) -> bool:
|
|
|
|
|
return (
|
|
|
|
|
typeof(value) == TYPE_STRING
|
|
|
|
|
and not str(value).is_empty()
|
|
|
|
|
and str(value).length() <= MAX_STROKE_ID_LENGTH
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-08-21 21:01:21 -04:00
|
|
|
static func _valid_participant_fingerprints(value: Variant) -> bool:
|
|
|
|
|
if typeof(value) != TYPE_ARRAY:
|
|
|
|
|
return false
|
|
|
|
|
var fingerprints: Array = value
|
|
|
|
|
if fingerprints.size() > MAX_PARTICIPANTS:
|
|
|
|
|
return false
|
|
|
|
|
var seen: Dictionary[String, bool] = {}
|
|
|
|
|
for fingerprint_value: Variant in fingerprints:
|
|
|
|
|
if typeof(fingerprint_value) != TYPE_STRING:
|
|
|
|
|
return false
|
|
|
|
|
var fingerprint: String = str(fingerprint_value)
|
|
|
|
|
if (
|
|
|
|
|
not NetworkIdentityCrypto.valid_fingerprint(fingerprint)
|
|
|
|
|
or seen.has(fingerprint)
|
|
|
|
|
):
|
|
|
|
|
return false
|
|
|
|
|
seen[fingerprint] = true
|
|
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
|
2026-08-01 22:55:39 -04:00
|
|
|
static func _valid_vector(value: Variant) -> bool:
|
|
|
|
|
if typeof(value) != TYPE_ARRAY:
|
|
|
|
|
return false
|
|
|
|
|
var array: Array = value
|
|
|
|
|
if array.size() != 3:
|
|
|
|
|
return false
|
|
|
|
|
for component: Variant in array:
|
|
|
|
|
if typeof(component) not in [TYPE_FLOAT, TYPE_INT]:
|
|
|
|
|
return false
|
|
|
|
|
var number: float = float(component)
|
|
|
|
|
if not is_finite(number) or absf(number) > MAX_COORDINATE:
|
|
|
|
|
return false
|
|
|
|
|
return true
|