Expand gameplay systems and interface controls

This commit is contained in:
Alexander Sellite 2026-08-20 17:53:22 -04:00
parent 09b7a78533
commit 2710544070
46 changed files with 2072 additions and 153 deletions

View file

@ -4,7 +4,10 @@ extends Node
const BURST_COUNT: int = 3
const WINDOW_COUNT: int = 5
const WINDOW_SECONDS: float = 10.0
const CALL_COOLDOWN_MILLISECONDS: int = 180
const CALL_COOLDOWN_MILLISECONDS: int = 90
const DEDICATED_CHAT_WARNING: String = (
"Privacy notice: This dedicated server records and retains chat messages."
)
const CALL_PITCH_VARIANTS: Array[float] = [
0.96,
1.03,
@ -38,6 +41,32 @@ var _call_variant_indices: Dictionary[int, int] = {}
var _sequence: int = 0
var _peer_names: Dictionary[int, String] = {}
var _relationships: PlayerRelationshipStore
var _dedicated_history_enabled: bool = false
var _chat_log_path: String = ""
func configure_dedicated_history(enabled: bool, log_path: String) -> bool:
_dedicated_history_enabled = false
_chat_log_path = ""
if not enabled:
return true
var normalized_path: String = log_path.strip_edges()
if normalized_path.is_empty() or not normalized_path.is_absolute_path():
return false
var directory: String = normalized_path.get_base_dir()
if DirAccess.make_dir_recursive_absolute(directory) != OK:
return false
var file: FileAccess
if FileAccess.file_exists(normalized_path):
file = FileAccess.open(normalized_path, FileAccess.READ_WRITE)
else:
file = FileAccess.open(normalized_path, FileAccess.WRITE)
if file == null:
return false
file.close()
_dedicated_history_enabled = true
_chat_log_path = normalized_path
return true
func setup(session: NetworkSession) -> void:
@ -288,7 +317,8 @@ func _handle_request(peer_id: int, data: Dictionary) -> void:
var request_id: String = data["request_id"]
var ledger: Dictionary = _request_ledgers.get(peer_id, {})
if ledger.has(request_id):
_send_message(peer_id, ledger[request_id])
if typeof(ledger[request_id]) == TYPE_DICTIONARY:
_send_message(peer_id, ledger[request_id])
return
if not _consume_rate(peer_id):
_send_rejection(peer_id, "Slow down.")
@ -308,7 +338,11 @@ func _handle_request(peer_id: int, data: Dictionary) -> void:
message["request_id"] = request_id
message["sender_fingerprint"] = data["sender_fingerprint"]
message["sender_signature"] = data["sender_signature"]
ledger[request_id] = message.duplicate(true)
ledger[request_id] = (
message.duplicate(true)
if _should_store_host_history()
else true
)
while ledger.size() > 64:
ledger.erase(ledger.keys().front())
_request_ledgers[peer_id] = ledger
@ -404,9 +438,12 @@ func _apply_message(
kind == NetworkChatProtocol.Kind.PLAYER
and is_sender_filtered(str(data.get("sender_fingerprint", "")))
)
_history.append(stored_message)
while _history.size() > NetworkChatProtocol.MAX_HISTORY:
_history.pop_front()
if _should_store_local_history():
_history.append(stored_message)
while _history.size() > NetworkChatProtocol.MAX_HISTORY:
_history.pop_front()
if _should_log_message(stored_message):
_append_chat_log(stored_message)
if emit_live_signals and _message_is_visible(stored_message):
message_received.emit(stored_message.duplicate(true))
if (
@ -433,8 +470,26 @@ func _on_peer_authenticated(peer_id: int, display_name: String) -> void:
if not _session.is_host():
return
_peer_names[peer_id] = display_name
var start := maxi(0, _history.size() - NetworkChatProtocol.LATE_JOIN_HISTORY)
receive_chat_history.rpc_id(peer_id, _history.slice(start))
var history: Array[Dictionary] = []
if _dedicated_history_enabled and _session.is_dedicated_host():
var start := maxi(
0,
_history.size() - NetworkChatProtocol.LATE_JOIN_HISTORY,
)
history = _history.slice(start)
# An explicit empty replacement is intentional for player-hosted rooms and
# dedicated servers without logging. It destroys any stale client scrollback
# instead of replaying another player's live-session chat on reconnect.
receive_chat_history.rpc_id(peer_id, history)
if _dedicated_history_enabled and _session.is_dedicated_host():
# Deliver the disclosure as a live system message after scrollback. This
# raises the normal unread indicator without creating speech or audio.
_send_message(peer_id, _make_message(
NetworkChatProtocol.Kind.SYSTEM,
0,
"",
DEDICATED_CHAT_WARNING,
))
_broadcast(_make_message(
NetworkChatProtocol.Kind.SYSTEM, 0, "", "%s joined." % display_name
))
@ -494,6 +549,44 @@ func _consume_rate(peer_id: int) -> bool:
return true
func _should_store_host_history() -> bool:
return (
_session == null
or not _session.is_dedicated_host()
or _dedicated_history_enabled
)
func _should_store_local_history() -> bool:
return _should_store_host_history()
func _should_log_message(message: Dictionary) -> bool:
return (
_dedicated_history_enabled
and not _chat_log_path.is_empty()
and _session != null
and _session.is_dedicated_host()
and int(message.get("kind", -1)) == NetworkChatProtocol.Kind.PLAYER
)
func _append_chat_log(message: Dictionary) -> void:
var file := FileAccess.open(_chat_log_path, FileAccess.READ_WRITE)
if file == null:
push_error("The configured dedicated-server chat log is unavailable.")
return
file.seek_end()
file.store_line(JSON.stringify({
"recorded_at_utc": (
Time.get_datetime_string_from_system(true, false) + "Z"
),
"sender_display_name": str(message.get("sender_display_name", "")),
"body": str(message.get("body", "")),
}))
file.close()
func _on_session_state_changed(state: NetworkSession.State) -> void:
if state in [
NetworkSession.State.INACTIVE,

View file

@ -188,7 +188,7 @@ func activate_process_root(path: String) -> bool:
func path_for(store_owner: StringName) -> String:
var relative: String = {
&"player_save": "player/player_save.json",
&"player_save": "player/player_save.nfsave",
&"network_profile": "player/network_profile.json",
&"player_appearance": "player/player_appearance.json",
&"saved_servers": "social/saved_servers.json",
@ -208,6 +208,10 @@ func identity_backup_directory() -> String:
return root_path.path_join("identity-backups")
func progression_backup_directory() -> String:
return root_path.path_join("progression-backups")
func migration_backup_directory() -> String:
return root_path.path_join("backups/migrations")
@ -347,7 +351,7 @@ func _test_writable(path: String) -> bool:
func _create_layout(path: String, id: String) -> bool:
for relative: String in [
"player", "social", "backups/saves", "backups/migrations",
"backups/conflicts", "identity-backups",
"backups/conflicts", "identity-backups", "progression-backups",
]:
if DirAccess.make_dir_recursive_absolute(path.path_join(relative)) != OK:
return false
@ -369,6 +373,7 @@ func _create_layout(path: String, id: String) -> bool:
+ "This folder is safe to synchronize with tools such as Syncthing.\n"
+ "Active private identity keys remain device-local.\n"
+ "Encrypted identity backups require their passphrase.\n"
+ "Progression exports do not contain identity keys or social data.\n"
+ "Chat and Session Mail are not stored here.\n"
+ "Do not play the same profile on two devices at the same time.\n"
+ "Conflicting edits are preserved under backups/conflicts; they are not merged.\n"

View file

@ -122,6 +122,7 @@ static func migrate_active_to(
_remove_tree(staging)
return {"ok": false, "message": str(created.get("message", ""))}
for relative: String in [
"player/player_save.nfsave",
"player/player_save.json",
"player/network_profile.json",
"player/player_appearance.json",
@ -134,7 +135,11 @@ static func migrate_active_to(
var source: String = data_root.root_path.path_join(relative)
if not FileAccess.file_exists(source):
continue
if not bool(_copy_verified_json(source, staging.path_join(relative)).get("ok", false)):
if not bool(
_copy_verified_owned_file(
source, staging.path_join(relative)
).get("ok", false)
):
_remove_tree(staging)
return {"ok": false, "message": "Migration validation failed for %s." % relative}
if DirAccess.dir_exists_absolute(normalized):
@ -248,6 +253,30 @@ static func _copy_verified_json(source: String, destination: String) -> Dictiona
}
static func _copy_verified_owned_file(
source: String,
destination: String,
) -> Dictionary:
if source.get_file() != "player_save.nfsave":
return _copy_verified_json(source, destination)
var decoded: Dictionary = ProgressionSaveCodec.read_local_save(source)
if not bool(decoded.get("ok", false)):
return {"ok": false}
var save_data: Dictionary = decoded["data"]
var version: int = int(save_data.get("save_version", -1))
if version < 1 or version > PlayerSaveManager.SAVE_VERSION:
return {"ok": false}
var bytes: PackedByteArray = PortableFileGuard.read_bytes(source)
if bytes.is_empty() or not _write_bytes(destination, bytes):
return {"ok": false}
return {
"ok": (
PortableFileGuard.hash_file(destination)
== PortableFileGuard.hash_bytes(bytes)
)
}
static func _valid_owned_data(filename: String, data: Dictionary) -> bool:
if filename == "player_save.json":
var version: int = int(data.get("save_version", -1))