Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,9 @@ Property names and file references are included so each item is easy to find.

## Gameplay
- [x] World saving/loading — `WorldStorage` stores immutable worldgen metadata and deterministic sparse final block edits in ZSTD region files; `VoxelWorld` hydrates edits on the main thread before worker snapshots and evicts unloaded disk-backed edit buckets. Continue Last World restores the most recent save, and autosave/exit/new-world flows flush dirty regions
- [ ] World management UI — Continue Last World is available, but a browsable world list with rename/duplicate/delete/manual-backup controls and an autosave-interval setting remains
- [x] Versioned save format and migration — metadata and region files carry independent magic/version/layout fields; unsupported future or malformed data is rejected, writes use temp/backup replacement, and corrupt primaries recover without discarding the only valid backup. The clean-region cache is bounded to 16 entries and dirty regions are never evicted
- [x] Persist time, weather, inventory, and player state — save state includes player position/flying, inventory, selected slot, spawn, day/night time, moon phase, and weather
- [x] World management UI — the Play hub lists every saved world and supports create/load, rename, independent duplication, deletion with confirmation, and timestamped manual backups under `user://world_backups`; Gameplay settings provide Off/30 s/1/2/5/10 min autosave intervals while leave/quit saves remain unconditional
- [x] Versioned save format and migration — metadata and region files carry independent magic/version headers, metadata freezes chunk/world/region layout, the region v1 reader migrates legacy edit records into the current v2 palette/RLE representation on rewrite, and gameplay state now has an explicit schema revision with a v0 migration. Unsupported future or malformed data is rejected, writes use temp/backup replacement, corrupt primaries recover without discarding the only valid backup, and an unreadable region rejects edits to that region without blocking unrelated regions or session state. The clean-region cache is bounded to 16 entries and dirty regions are never evicted
- [x] Persist time, weather, inventory, and player state — versioned save state includes validated player position/flying/spawn, inventory, selected slot, day/night time, moon phase, weather target and rain-transition progress; missing legacy inventory retains defaults and restored weather grading/audio is applied immediately
- [ ] Survival layer — health/hunger/damage with respawn and damage sources (fall, drowning, lava/fire); `Player` has no health, falling below `FALL_RESET_Y` just teleports
- [ ] Tools, hardness, and durability — per-block hardness/break time and tool tiers in `BlockRegistry.BLOCK_DEFS`, tool stacks with durability; mining is instant today (`Player._break_target()` -> `VoxelWorld.break_block()`)
- [ ] Item inventory and crafting — stack grid with drag/drop beyond the fixed `Main.HOTBAR`, crafting recipes/table, furnaces, and chests; `Main.inventory` is only an id->count dictionary rendered by `InventoryOverlay`
Expand Down
12 changes: 12 additions & 0 deletions autoload/game_config.gd
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const DEFAULT_SETTINGS := {
"input_bindings": {},
"ui_scale": 1.0,
"text_scale": 1.0,
"autosave_interval": 30,
}

# Interface scale. UI scale drives the window's canvas-item content scale, so
Expand Down Expand Up @@ -48,6 +49,8 @@ const DYNAMIC_RESOLUTION_UP_MARGIN := 0.85
const LOD_MODE_FULL := 0
const LOD_MODE_BALANCED := 1
const LOD_MODE_NAMES := ["Full Detail", "Balanced LOD"]
const AUTOSAVE_INTERVAL_VALUES := [0, 30, 60, 120, 300, 600]
const AUTOSAVE_INTERVAL_NAMES := ["Off", "30 Seconds", "1 Minute", "2 Minutes", "5 Minutes", "10 Minutes"]

# Rebindable input actions in menu order. The InputMap's non-`ui_*` actions are
# the source of truth: anything missing from this table still gets a row with a
Expand Down Expand Up @@ -228,6 +231,9 @@ func set_setting(key: String, value: Variant) -> void:
apply_frame_pacing()
elif key == "ui_scale" or key == "text_scale":
apply_ui_scale()
elif key == "autosave_interval":
settings[key] = nearest_option(value, AUTOSAVE_INTERVAL_VALUES)
save_settings()


## Applies the interface scale. The window's canvas-item content scale resizes
Expand Down Expand Up @@ -484,6 +490,11 @@ func get_mouse_sensitivity() -> float:
return float(settings.get("mouse_sensitivity", DEFAULT_SETTINGS["mouse_sensitivity"]))


func get_autosave_interval() -> float:
return float(nearest_option(settings.get("autosave_interval", DEFAULT_SETTINGS["autosave_interval"]),
AUTOSAVE_INTERVAL_VALUES))


func is_fullscreen() -> bool:
return bool(settings.get("fullscreen", DEFAULT_SETTINGS["fullscreen"]))

Expand Down Expand Up @@ -531,6 +542,7 @@ func load_settings() -> void:
settings["dynamic_resolution_target"] = nearest_option(settings.get("dynamic_resolution_target", DEFAULT_SETTINGS["dynamic_resolution_target"]), DYNAMIC_RESOLUTION_TARGET_VALUES)
settings["ui_scale"] = nearest_option(settings.get("ui_scale", DEFAULT_SETTINGS["ui_scale"]), UI_SCALE_VALUES)
settings["text_scale"] = nearest_option(settings.get("text_scale", DEFAULT_SETTINGS["text_scale"]), TEXT_SCALE_VALUES)
settings["autosave_interval"] = nearest_option(settings.get("autosave_interval", DEFAULT_SETTINGS["autosave_interval"]), AUTOSAVE_INTERVAL_VALUES)
_sanitize_input_bindings()
apply_window_mode()
apply_frame_pacing()
Expand Down
49 changes: 38 additions & 11 deletions game/main.gd
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ const UNDERWATER_DEEP_COLOR := Color(0.02, 0.08, 0.16)
const CAVE_FADE_SECONDS := 0.65
const DYNAMIC_RESOLUTION_INTERVAL := 0.5
const DYNAMIC_RESOLUTION_SMOOTHING := 0.2
const AUTOSAVE_INTERVAL := 30.0
const SESSION_STATE_VERSION := 1
const AUTOSAVE_RETRY_SECONDS := 30.0

@onready var world: VoxelWorld = $World
@onready var player: Player = $Player
Expand Down Expand Up @@ -70,6 +71,7 @@ var _dynamic_resolution_timer := 0.0
var _dynamic_resolution_active := false
var _world_storage: WorldStorage
var _autosave_time := 0.0
var _storage_warning_shown := false


func _ready() -> void:
Expand All @@ -86,7 +88,9 @@ func _ready() -> void:
_inventory_overlay.opened.connect(_on_inventory_opened)
_inventory_overlay.closed.connect(_on_inventory_closed)
_inventory_overlay.time_selected.connect(_on_inventory_time_selected)
var resumed := not saved_state.is_empty() and player.restore_persistent_state(saved_state.get("player", {}))
var player_state: Variant = saved_state.get("player", {})
var resumed := typeof(player_state) == TYPE_DICTIONARY \
and player.restore_persistent_state(player_state)
if resumed:
world.setup_player(player, true)
# Recover saves written after the player had already entered unloaded or
Expand All @@ -95,7 +99,6 @@ func _ready() -> void:
if not world.is_player_volume_clear(player.global_position):
var recovered_spawn := world.find_safe_spawn(player.global_position)
player.global_position = recovered_spawn
player.spawn_position = recovered_spawn
player.velocity = Vector3.ZERO
else:
var spawn := world.get_spawn_position()
Expand Down Expand Up @@ -228,9 +231,12 @@ func _unhandled_input(event: InputEvent) -> void:
func _process(delta: float) -> void:
if not get_tree().paused:
_autosave_time += delta
if _autosave_time >= AUTOSAVE_INTERVAL:
_autosave_time = 0.0
_flush_world_save()
var autosave_interval := GameConfig.get_autosave_interval()
if autosave_interval > 0.0 and _autosave_time >= autosave_interval:
if _flush_world_save():
_autosave_time = 0.0
else:
_autosave_time = maxf(autosave_interval - minf(AUTOSAVE_RETRY_SECONDS, autosave_interval), 0.0)
if status_time > 0.0:
status_time -= delta
if status_time <= 0.0:
Expand Down Expand Up @@ -339,14 +345,27 @@ func _prepare_world_storage() -> Dictionary:
GameConfig.clear_active_world()
return {}
GameConfig.activate_world(metadata)
return (metadata.get("state", {}) as Dictionary).duplicate(true)
return _migrate_session_state(metadata.get("state", {}))


func _migrate_session_state(value: Variant) -> Dictionary:
if typeof(value) != TYPE_DICTIONARY:
return {}
var state: Dictionary = value.duplicate(true)
var version := int(state.get("state_version", 0))
if version > SESSION_STATE_VERSION:
push_warning("Save uses unsupported session state version %d" % version)
return {}
# Version 0 saves used the same fields but did not carry an explicit tag.
state["state_version"] = SESSION_STATE_VERSION
return state


func _restore_session_state(state: Dictionary) -> void:
if state.is_empty():
return
var saved_inventory: Variant = state.get("inventory", [])
if typeof(saved_inventory) == TYPE_ARRAY:
var saved_inventory: Variant = state.get("inventory", null)
if state.has("inventory") and typeof(saved_inventory) == TYPE_ARRAY:
var restored_inventory: Dictionary = {}
for entry in saved_inventory:
if typeof(entry) != TYPE_ARRAY or entry.size() != 2:
Expand All @@ -363,6 +382,8 @@ func _restore_session_state(state: Dictionary) -> void:
var weather_state: Variant = state.get("weather", {})
if typeof(weather_state) == TYPE_DICTIONARY:
_weather.restore_persistent_state(weather_state)
_day_night.set_time(_day_night.time_hours)
_on_weather_changed(_weather.state)


func _build_persistent_state() -> Dictionary:
Expand All @@ -374,6 +395,7 @@ func _build_persistent_state() -> Dictionary:
for item_id in item_ids:
inventory_rows.append([item_id, int(inventory[item_id])])
return {
"state_version": SESSION_STATE_VERSION,
"player": player.persistent_state(),
"inventory": inventory_rows,
"selected_slot": selected_slot,
Expand All @@ -382,17 +404,22 @@ func _build_persistent_state() -> Dictionary:
}


func _flush_world_save() -> void:
func _flush_world_save() -> bool:
if _world_storage == null or player == null:
return
return false
var save_error := world.flush_edit_store()
if save_error == OK:
save_error = _world_storage.flush(_build_persistent_state())
if save_error == OK:
GameConfig.active_world_metadata = _world_storage.metadata.duplicate(true)
if not _storage_warning_shown and _world_storage.unreadable_region_count() > 0:
_storage_warning_shown = true
set_status("A damaged world region is read-only; edits there cannot be saved")
return true
else:
push_warning("World save failed with error %d" % save_error)
set_status("Save failed - progress remains in memory")
return false


func _apply_graphics() -> void:
Expand Down
5 changes: 4 additions & 1 deletion player/player.gd
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ func restore_persistent_state(state: Dictionary) -> bool:
var restored := Vector3(float(position_value[0]), float(position_value[1]), float(position_value[2]))
if not is_finite(restored.x) or not is_finite(restored.y) or not is_finite(restored.z):
return false
if restored.y < 0.0:
return false
global_position = restored
rotation.y = float(state.get("yaw", 0.0))
if head != null:
Expand All @@ -105,7 +107,8 @@ func restore_persistent_state(state: Dictionary) -> bool:
var spawn_value: Variant = state.get("spawn", [])
if typeof(spawn_value) == TYPE_ARRAY and spawn_value.size() == 3:
var restored_spawn := Vector3(float(spawn_value[0]), float(spawn_value[1]), float(spawn_value[2]))
if is_finite(restored_spawn.x) and is_finite(restored_spawn.y) and is_finite(restored_spawn.z):
if is_finite(restored_spawn.x) and is_finite(restored_spawn.y) and is_finite(restored_spawn.z) \
and restored_spawn.y >= 0.0:
spawn_position = restored_spawn
else:
spawn_position = restored
Expand Down
8 changes: 8 additions & 0 deletions tools/player_target_verify.gd
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,14 @@ func _ready() -> void:
add_child(flight_player)
flight_player.set_physics_process(false)
flight_player.world = world
if not flight_player.restore_persistent_state({
"position": [15.75, 210.0, 8.0],
"spawn": [15.75, 205.0, 8.0],
"flying": true,
}) or not is_equal_approx(flight_player.global_position.y, 210.0) \
or not is_equal_approx(flight_player.spawn_position.y, 205.0):
push_error("player_target_verify: legitimate above-ceiling flight state was rejected")
failed = true
flight_player.flying = true
flight_player.global_position = Vector3(15.75, 70.0, 8.0)
flight_player.velocity = Vector3(10.0, -10.0, 0.0)
Expand Down
30 changes: 30 additions & 0 deletions tools/ui_flow_verify.gd
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ func _run() -> void:
"incompatible world incorrectly enabled loading")
_expect(not play.get_node("Center/Panel/Box/LoadBox/LoadFooter/DeleteButton").disabled,
"incompatible world did not enable deletion")
_expect(play._rename_button.disabled and play._duplicate_button.disabled and play._backup_button.disabled,
"incompatible world enabled management operations")

# Disconnect the menu's scene-changing handler while verifying the panel's
# public signal in isolation.
Expand All @@ -86,6 +88,27 @@ func _run() -> void:
var beta_card: Button = play._cards.get("beta")
if beta_card != null:
beta_card.pressed.emit()
_expect(not play._rename_button.disabled and not play._duplicate_button.disabled and not play._backup_button.disabled,
"compatible world did not enable management operations")
play._rename_button.pressed.emit()
await process_frame
_expect(play._editing_rename and root.gui_get_focus_owner() == play._rename_field,
"rename did not open its editor and focus the name field")
play._rename_field.text = "Renamed Shore"
play._rename_confirm_button.pressed.emit()
await process_frame
_expect(String((WorldStorage.new(_library_root).open_world("beta")).get("name", "")) == "Renamed Shore",
"world rename did not persist through the management UI")
play._select_world("beta")
play._duplicate_button.pressed.emit()
await process_frame
var copy_id: String = play._selected_world_id
_expect(not copy_id.is_empty() and copy_id != "beta" and play._cards.size() == 4,
"world duplication did not refresh and select the new copy")
if not copy_id.is_empty() and copy_id != "beta":
_expect(WorldStorage.delete_world(copy_id, _library_root), "could not clean up duplicated UI fixture")
play._refresh_load_view()
play._select_world("beta")
play.get_node("Center/Panel/Box/LoadBox/LoadFooter/LoadButton").pressed.emit()
_expect(loaded_ids == ["beta"], "selected compatible world did not emit load_requested")

Expand Down Expand Up @@ -149,7 +172,14 @@ func _run() -> void:
_expect(settings.visible, "Settings did not open the hub")
var display: Node = settings.get_node("DisplayCategory")
var graphics: Node = settings.get_node("GraphicsCategory")
var gameplay: Node = settings.get_node("GameplayCategory")
var advanced: Node = settings.get_node("GraphicsPanel")
var gameplay_rows: Array = gameplay._category_definition()["rows"]
var has_autosave := false
for row in gameplay_rows:
if String(row.get("key", "")) == "autosave_interval":
has_autosave = true
_expect(has_autosave, "Gameplay settings omitted the autosave interval")
display.open_panel()
await process_frame
_expect(display.visible and settings.visible, "display category did not open over the hub")
Expand Down
15 changes: 15 additions & 0 deletions tools/weather_verify.gd
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ func _initialize() -> void:
func _verify() -> void:
_check_biome_classification()
_check_particles()
_check_persistence()
_check_lightning_schedule()
_check_lightning_flash()
_check_wind_wiring()
Expand Down Expand Up @@ -76,6 +77,20 @@ func _check_particles() -> void:
weather.free()


func _check_persistence() -> void:
var source := WeatherSystemScript.new()
source.state = WeatherSystemScript.State.RAIN
source.rain_amount = 0.42
var restored := WeatherSystemScript.new()
restored.restore_persistent_state(source.persistent_state())
_expect(restored.state == WeatherSystemScript.State.RAIN,
"weather target state did not round-trip")
_expect(is_equal_approx(restored.rain_amount, 0.42),
"weather transition progress did not round-trip")
source.free()
restored.free()


func _check_lightning_schedule() -> void:
var weather := WeatherSystemScript.new()
weather.name = "LightningVerify"
Expand Down
49 changes: 49 additions & 0 deletions tools/world_storage_verify.gd
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ func _initialize() -> void:
_verify_round_trip_and_regions()
_verify_future_metadata_rejection()
_verify_library_listing_and_delete()
_verify_world_management()
_verify_v2_compression_round_trip_and_corruption()
_verify_generation_edit_priority()
if _failures.is_empty():
Expand Down Expand Up @@ -144,6 +145,15 @@ func _verify_round_trip_and_regions() -> void:
var rejected := WorldStorage.new(_root)
rejected.open_world("verify-world")
_expect(rejected.load_chunk_edits(chunk_a).is_empty(), "future region version was not rejected")
var unreadable_bytes := FileAccess.get_file_as_bytes(negative_region_path)
_expect(rejected.unreadable_region_count() == 1,
"unreadable region was not exposed for one-time player feedback")
rejected.stage_chunk_edits(chunk_a, edits_a)
rejected.stage_chunk_edits(chunk_c, edits_c)
_expect(rejected.flush_dirty_regions() == OK,
"an unreadable region blocked unrelated region saves")
_expect(FileAccess.get_file_as_bytes(negative_region_path) == unreadable_bytes,
"an unreadable region was overwritten after staging an edit")

# Metadata also recovers from a syntactically valid but structurally corrupt primary.
var metadata_path := _root + "/verify-world/metadata.json"
Expand Down Expand Up @@ -200,6 +210,45 @@ func _verify_library_listing_and_delete() -> void:
"deleting the last-played world left a stale pointer")


func _verify_world_management() -> void:
var storage := WorldStorage.new(_root)
var metadata := storage.create_world({"seed": 2468}, {"inventory": [[1, 7]]}, "manage-world")
_expect(not metadata.is_empty(), "could not create world-management fixture")
storage.stage_chunk_edits(Vector2i.ZERO, {Vector3i(1, 2, 3): BlockRegistry.BLOCK_STONE})
_expect(storage.flush(metadata.get("state", {})) == OK, "could not flush world-management fixture")
_expect(WorldStorage.rename_world("manage-world", "Managed World", _root), "world rename failed")
var renamed := WorldStorage.new(_root).open_world("manage-world")
_expect(String(renamed.get("name", "")) == "Managed World", "renamed title did not persist")
_expect(not WorldStorage.rename_world("manage-world", "", _root), "empty world name was accepted")
var maximum_name := "W".repeat(64)
_expect(WorldStorage.rename_world("manage-world", maximum_name, _root),
"maximum-length world name was rejected")
_expect(WorldStorage.new(_root).create_world({"seed": 1}, {}, "manage-world").is_empty(),
"world creation overwrote an existing requested id")

var duplicated := WorldStorage.duplicate_world("manage-world", _root)
var duplicate_id := String(duplicated.get("id", ""))
_expect(not duplicate_id.is_empty() and duplicate_id != "manage-world", "world duplication did not create a new id")
_expect(String(WorldStorage.latest_world_metadata(_root).get("id", "")) == "manage-world",
"management operation changed Continue Last World to an unplayed duplicate")
var duplicate_storage := WorldStorage.new(_root)
_expect(not duplicate_storage.open_world(duplicate_id).is_empty(), "duplicated world could not be opened")
_expect(duplicate_storage.load_chunk_edits(Vector2i.ZERO) == {
Vector3i(1, 2, 3): BlockRegistry.BLOCK_STONE,
}, "duplicated world lost region edits")
_expect(String(duplicated.get("name", "")) == "%s Copy" % maximum_name.substr(0, 59),
"duplicated world name did not stay within the rename limit")

var backup_root := _root + "_backups"
var backup_path := WorldStorage.backup_world("manage-world", _root, backup_root)
_expect(not backup_path.is_empty() and FileAccess.file_exists(backup_path + "/metadata.json"),
"manual world backup did not copy metadata")
_expect(FileAccess.file_exists(backup_path + "/regions/r.0.0.rcregion"),
"manual world backup did not copy region data")
_expect(WorldStorage.new(_root).open_world("../manage-world").is_empty(),
"world opening accepted a traversal id")


func _verify_v2_compression_round_trip_and_corruption() -> void:
var storage := WorldStorage.new(_root)
var config := WorldGenConfig.new({"seed": 13579}).to_dictionary()
Expand Down
Loading
Loading