Adding interactable object spawning
This commit is contained in:
parent
932f7be66b
commit
1aefd5ba88
@ -1,6 +1,7 @@
|
|||||||
extends Node3D
|
extends Node3D
|
||||||
|
|
||||||
const CHARACTER_API_URL := "https://pchar.ranaze.com/api/Characters"
|
const CHARACTER_API_URL := "https://pchar.ranaze.com/api/Characters"
|
||||||
|
const LOCATION_API_URL := "https://ploc.ranaze.com/api/Locations"
|
||||||
|
|
||||||
@export var tile_size := 16.0
|
@export var tile_size := 16.0
|
||||||
@export var block_height := 1.0
|
@export var block_height := 1.0
|
||||||
@ -31,6 +32,7 @@ var _coord_sync_in_flight := false
|
|||||||
var _queued_coord_sync: Variant = null
|
var _queued_coord_sync: Variant = null
|
||||||
var _locations_refresh_in_flight := false
|
var _locations_refresh_in_flight := false
|
||||||
var _queued_locations_refresh := false
|
var _queued_locations_refresh := false
|
||||||
|
var _interact_in_flight := false
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
@ -69,6 +71,11 @@ func _process(_delta: float) -> void:
|
|||||||
_queue_locations_refresh()
|
_queue_locations_refresh()
|
||||||
|
|
||||||
|
|
||||||
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
|
if event.is_action_pressed("interact"):
|
||||||
|
_try_interact_current_tile()
|
||||||
|
|
||||||
|
|
||||||
func _get_stream_position() -> Vector3:
|
func _get_stream_position() -> Vector3:
|
||||||
if _tracked_node:
|
if _tracked_node:
|
||||||
return _tracked_node.global_position
|
return _tracked_node.global_position
|
||||||
@ -102,9 +109,11 @@ func _rebuild_tiles(center: Vector2i) -> void:
|
|||||||
if not _known_locations.has(coord):
|
if not _known_locations.has(coord):
|
||||||
continue
|
continue
|
||||||
wanted_keys[coord] = true
|
wanted_keys[coord] = true
|
||||||
|
var location_data := _known_locations[coord] as Dictionary
|
||||||
if _tile_nodes.has(coord):
|
if _tile_nodes.has(coord):
|
||||||
|
_update_tile(coord, location_data)
|
||||||
continue
|
continue
|
||||||
_spawn_tile(coord, String(_known_locations[coord]))
|
_spawn_tile(coord, location_data)
|
||||||
|
|
||||||
for key in _tile_nodes.keys():
|
for key in _tile_nodes.keys():
|
||||||
if wanted_keys.has(key):
|
if wanted_keys.has(key):
|
||||||
@ -115,7 +124,7 @@ func _rebuild_tiles(center: Vector2i) -> void:
|
|||||||
_tile_nodes.erase(key)
|
_tile_nodes.erase(key)
|
||||||
|
|
||||||
|
|
||||||
func _spawn_tile(coord: Vector2i, location_name: String) -> void:
|
func _spawn_tile(coord: Vector2i, location_data: Dictionary) -> void:
|
||||||
var tile_root := Node3D.new()
|
var tile_root := Node3D.new()
|
||||||
tile_root.name = "Tile_%d_%d" % [coord.x, coord.y]
|
tile_root.name = "Tile_%d_%d" % [coord.x, coord.y]
|
||||||
tile_root.position = _coord_to_world(coord)
|
tile_root.position = _coord_to_world(coord)
|
||||||
@ -138,11 +147,60 @@ func _spawn_tile(coord: Vector2i, location_name: String) -> void:
|
|||||||
|
|
||||||
tile.add_child(_create_tile_border())
|
tile.add_child(_create_tile_border())
|
||||||
if show_tile_labels:
|
if show_tile_labels:
|
||||||
tile_root.add_child(_create_tile_label(location_name))
|
tile_root.add_child(_create_tile_label(String(location_data.get("name", ""))))
|
||||||
|
_update_tile_object(tile_root, location_data)
|
||||||
|
|
||||||
_tile_nodes[coord] = tile_root
|
_tile_nodes[coord] = tile_root
|
||||||
|
|
||||||
|
|
||||||
|
func _update_tile(coord: Vector2i, location_data: Dictionary) -> void:
|
||||||
|
var tile_root := _tile_nodes.get(coord) as Node3D
|
||||||
|
if tile_root == null:
|
||||||
|
return
|
||||||
|
|
||||||
|
if show_tile_labels:
|
||||||
|
var label := tile_root.get_node_or_null("LocationNameLabel") as Label3D
|
||||||
|
if label:
|
||||||
|
label.text = String(location_data.get("name", ""))
|
||||||
|
|
||||||
|
_update_tile_object(tile_root, location_data)
|
||||||
|
|
||||||
|
|
||||||
|
func _update_tile_object(tile_root: Node3D, location_data: Dictionary) -> void:
|
||||||
|
var existing := tile_root.get_node_or_null("LocationObject")
|
||||||
|
if existing:
|
||||||
|
existing.queue_free()
|
||||||
|
|
||||||
|
var object_data_variant: Variant = location_data.get("locationObject", {})
|
||||||
|
if typeof(object_data_variant) != TYPE_DICTIONARY:
|
||||||
|
return
|
||||||
|
|
||||||
|
var object_data := object_data_variant as Dictionary
|
||||||
|
if object_data.is_empty():
|
||||||
|
return
|
||||||
|
|
||||||
|
var object_root := Node3D.new()
|
||||||
|
object_root.name = "LocationObject"
|
||||||
|
object_root.position = Vector3(0.0, (block_height * 0.5) + 0.6, 0.0)
|
||||||
|
tile_root.add_child(object_root)
|
||||||
|
|
||||||
|
var object_mesh := MeshInstance3D.new()
|
||||||
|
object_mesh.name = "ObjectMesh"
|
||||||
|
object_mesh.mesh = SphereMesh.new()
|
||||||
|
object_mesh.scale = Vector3(0.6, 0.4, 0.6)
|
||||||
|
object_mesh.material_override = _create_object_material(String(object_data.get("objectKey", "")))
|
||||||
|
object_root.add_child(object_mesh)
|
||||||
|
|
||||||
|
var object_label := Label3D.new()
|
||||||
|
object_label.name = "ObjectLabel"
|
||||||
|
object_label.text = _build_object_label(object_data)
|
||||||
|
object_label.position = Vector3(0.0, 0.6, 0.0)
|
||||||
|
object_label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||||||
|
object_label.pixel_size = 0.01
|
||||||
|
object_label.outline_size = 10
|
||||||
|
object_root.add_child(object_label)
|
||||||
|
|
||||||
|
|
||||||
func _create_tile_border() -> MeshInstance3D:
|
func _create_tile_border() -> MeshInstance3D:
|
||||||
var top_y := 0.5 + border_height_bias
|
var top_y := 0.5 + border_height_bias
|
||||||
var corners := [
|
var corners := [
|
||||||
@ -193,10 +251,42 @@ func _create_tile_label(location_name: String) -> Label3D:
|
|||||||
return label
|
return label
|
||||||
|
|
||||||
|
|
||||||
|
func _create_object_material(object_key: String) -> StandardMaterial3D:
|
||||||
|
var material := StandardMaterial3D.new()
|
||||||
|
material.roughness = 1.0
|
||||||
|
material.metallic = 0.0
|
||||||
|
|
||||||
|
if object_key.contains("grass"):
|
||||||
|
material.albedo_color = Color(0.28, 0.68, 0.25, 1.0)
|
||||||
|
elif object_key.contains("wood"):
|
||||||
|
material.albedo_color = Color(0.54, 0.36, 0.18, 1.0)
|
||||||
|
elif object_key.contains("stone"):
|
||||||
|
material.albedo_color = Color(0.55, 0.57, 0.6, 1.0)
|
||||||
|
else:
|
||||||
|
material.albedo_color = Color(0.85, 0.75, 0.3, 1.0)
|
||||||
|
|
||||||
|
return material
|
||||||
|
|
||||||
|
|
||||||
|
func _build_object_label(object_data: Dictionary) -> String:
|
||||||
|
var object_name := String(object_data.get("name", "")).strip_edges()
|
||||||
|
var state_variant: Variant = object_data.get("state", {})
|
||||||
|
var remaining_quantity := 0
|
||||||
|
if typeof(state_variant) == TYPE_DICTIONARY:
|
||||||
|
remaining_quantity = int((state_variant as Dictionary).get("remainingQuantity", 0))
|
||||||
|
if object_name.is_empty():
|
||||||
|
object_name = "Object"
|
||||||
|
return "%s x%d" % [object_name, remaining_quantity]
|
||||||
|
|
||||||
|
|
||||||
func _ensure_selected_location_exists(coord: Vector2i) -> void:
|
func _ensure_selected_location_exists(coord: Vector2i) -> void:
|
||||||
if _known_locations.has(coord):
|
if _known_locations.has(coord):
|
||||||
return
|
return
|
||||||
_known_locations[coord] = _selected_location_name(coord)
|
_known_locations[coord] = {
|
||||||
|
"id": "",
|
||||||
|
"name": _selected_location_name(coord),
|
||||||
|
"locationObject": {}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
func _selected_location_name(coord: Vector2i) -> String:
|
func _selected_location_name(coord: Vector2i) -> String:
|
||||||
@ -217,6 +307,7 @@ func _load_existing_locations() -> void:
|
|||||||
if _character_id.is_empty():
|
if _character_id.is_empty():
|
||||||
push_warning("Selected character is missing an id; cannot load visible locations.")
|
push_warning("Selected character is missing an id; cannot load visible locations.")
|
||||||
_locations_loaded = true
|
_locations_loaded = true
|
||||||
|
_locations_refresh_in_flight = false
|
||||||
return
|
return
|
||||||
|
|
||||||
var request := HTTPRequest.new()
|
var request := HTTPRequest.new()
|
||||||
@ -231,6 +322,7 @@ func _load_existing_locations() -> void:
|
|||||||
push_warning("Failed to request visible locations: %s" % err)
|
push_warning("Failed to request visible locations: %s" % err)
|
||||||
request.queue_free()
|
request.queue_free()
|
||||||
_locations_loaded = true
|
_locations_loaded = true
|
||||||
|
_locations_refresh_in_flight = false
|
||||||
return
|
return
|
||||||
|
|
||||||
var result: Array = await request.request_completed
|
var result: Array = await request.request_completed
|
||||||
@ -242,12 +334,14 @@ func _load_existing_locations() -> void:
|
|||||||
if result_code != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
|
if result_code != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
|
||||||
push_warning("Failed to load visible locations (%s/%s): %s" % [result_code, response_code, response_body])
|
push_warning("Failed to load visible locations (%s/%s): %s" % [result_code, response_code, response_body])
|
||||||
_locations_loaded = true
|
_locations_loaded = true
|
||||||
|
_locations_refresh_in_flight = false
|
||||||
return
|
return
|
||||||
|
|
||||||
var parsed: Variant = JSON.parse_string(response_body)
|
var parsed: Variant = JSON.parse_string(response_body)
|
||||||
if typeof(parsed) != TYPE_ARRAY:
|
if typeof(parsed) != TYPE_ARRAY:
|
||||||
push_warning("Visible locations response was not an array.")
|
push_warning("Visible locations response was not an array.")
|
||||||
_locations_loaded = true
|
_locations_loaded = true
|
||||||
|
_locations_refresh_in_flight = false
|
||||||
return
|
return
|
||||||
|
|
||||||
var loaded_count := 0
|
var loaded_count := 0
|
||||||
@ -263,7 +357,15 @@ func _load_existing_locations() -> void:
|
|||||||
var location_name := String(location.get("name", "")).strip_edges()
|
var location_name := String(location.get("name", "")).strip_edges()
|
||||||
if location_name.is_empty():
|
if location_name.is_empty():
|
||||||
location_name = "Location %d,%d" % [coord.x, coord.y]
|
location_name = "Location %d,%d" % [coord.x, coord.y]
|
||||||
_known_locations[coord] = location_name
|
var location_object := {}
|
||||||
|
var object_variant: Variant = location.get("locationObject", {})
|
||||||
|
if typeof(object_variant) == TYPE_DICTIONARY:
|
||||||
|
location_object = (object_variant as Dictionary).duplicate(true)
|
||||||
|
_known_locations[coord] = {
|
||||||
|
"id": String(location.get("id", "")).strip_edges(),
|
||||||
|
"name": location_name,
|
||||||
|
"locationObject": location_object
|
||||||
|
}
|
||||||
loaded_count += 1
|
loaded_count += 1
|
||||||
|
|
||||||
print("LocationLevel loaded %d visible locations for character %s." % [loaded_count, _character_id])
|
print("LocationLevel loaded %d visible locations for character %s." % [loaded_count, _character_id])
|
||||||
@ -295,6 +397,102 @@ func _refresh_visible_locations_async() -> void:
|
|||||||
await _load_existing_locations()
|
await _load_existing_locations()
|
||||||
|
|
||||||
|
|
||||||
|
func _try_interact_current_tile() -> void:
|
||||||
|
if _interact_in_flight:
|
||||||
|
return
|
||||||
|
if _character_id.is_empty():
|
||||||
|
return
|
||||||
|
|
||||||
|
var location_data: Dictionary = _known_locations.get(_center_coord, {})
|
||||||
|
if location_data.is_empty():
|
||||||
|
push_warning("No known location data for %s." % _center_coord)
|
||||||
|
return
|
||||||
|
|
||||||
|
var location_id := String(location_data.get("id", "")).strip_edges()
|
||||||
|
if location_id.is_empty():
|
||||||
|
push_warning("Current location is missing an id.")
|
||||||
|
return
|
||||||
|
|
||||||
|
var object_data: Dictionary = location_data.get("locationObject", {})
|
||||||
|
if object_data.is_empty():
|
||||||
|
push_warning("Current location has no interactable object.")
|
||||||
|
return
|
||||||
|
|
||||||
|
var object_id := String(object_data.get("id", "")).strip_edges()
|
||||||
|
if object_id.is_empty():
|
||||||
|
push_warning("Current location object is missing an id.")
|
||||||
|
return
|
||||||
|
|
||||||
|
_interact_in_flight = true
|
||||||
|
_interact_with_location_async(location_id, object_id)
|
||||||
|
|
||||||
|
|
||||||
|
func _interact_with_location_async(location_id: String, object_id: String) -> void:
|
||||||
|
var request := HTTPRequest.new()
|
||||||
|
add_child(request)
|
||||||
|
|
||||||
|
var headers := PackedStringArray()
|
||||||
|
if not AuthState.access_token.is_empty():
|
||||||
|
headers.append("Authorization: Bearer %s" % AuthState.access_token)
|
||||||
|
headers.append("Content-Type: application/json")
|
||||||
|
|
||||||
|
var body := JSON.stringify({
|
||||||
|
"characterId": _character_id,
|
||||||
|
"objectId": object_id
|
||||||
|
})
|
||||||
|
var err := request.request("%s/%s/interact" % [LOCATION_API_URL, location_id], headers, HTTPClient.METHOD_POST, body)
|
||||||
|
if err != OK:
|
||||||
|
push_warning("Failed to send location interaction request: %s" % err)
|
||||||
|
request.queue_free()
|
||||||
|
_interact_in_flight = false
|
||||||
|
return
|
||||||
|
|
||||||
|
var result: Array = await request.request_completed
|
||||||
|
request.queue_free()
|
||||||
|
|
||||||
|
var result_code: int = result[0]
|
||||||
|
var response_code: int = result[1]
|
||||||
|
var response_body: String = result[3].get_string_from_utf8()
|
||||||
|
if result_code != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
|
||||||
|
push_warning("Location interaction failed (%s/%s): %s" % [result_code, response_code, response_body])
|
||||||
|
_interact_in_flight = false
|
||||||
|
return
|
||||||
|
|
||||||
|
var parsed: Variant = JSON.parse_string(response_body)
|
||||||
|
if typeof(parsed) != TYPE_DICTIONARY:
|
||||||
|
push_warning("Location interaction response was not an object.")
|
||||||
|
_interact_in_flight = false
|
||||||
|
return
|
||||||
|
|
||||||
|
var interaction := parsed as Dictionary
|
||||||
|
_apply_interaction_result(location_id, interaction)
|
||||||
|
_interact_in_flight = false
|
||||||
|
|
||||||
|
|
||||||
|
func _apply_interaction_result(location_id: String, interaction: Dictionary) -> void:
|
||||||
|
var consumed := bool(interaction.get("consumed", false))
|
||||||
|
var remaining_quantity := int(interaction.get("remainingQuantity", 0))
|
||||||
|
|
||||||
|
for coord_variant in _known_locations.keys():
|
||||||
|
var coord: Vector2i = coord_variant
|
||||||
|
var location_data: Dictionary = _known_locations[coord]
|
||||||
|
if String(location_data.get("id", "")) != location_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
var updated_location := location_data.duplicate(true)
|
||||||
|
if consumed:
|
||||||
|
updated_location["locationObject"] = {}
|
||||||
|
else:
|
||||||
|
var object_data: Dictionary = updated_location.get("locationObject", {})
|
||||||
|
var state: Dictionary = object_data.get("state", {})
|
||||||
|
state["remainingQuantity"] = remaining_quantity
|
||||||
|
object_data["state"] = state
|
||||||
|
updated_location["locationObject"] = object_data
|
||||||
|
_known_locations[coord] = updated_location
|
||||||
|
_update_tile(coord, updated_location)
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
func _queue_coord_sync(coord: Vector2i) -> void:
|
func _queue_coord_sync(coord: Vector2i) -> void:
|
||||||
if coord == _persisted_coord:
|
if coord == _persisted_coord:
|
||||||
return
|
return
|
||||||
|
|||||||
@ -15,4 +15,10 @@ public class VisibleLocation
|
|||||||
|
|
||||||
[BsonElement("coord")]
|
[BsonElement("coord")]
|
||||||
public LocationCoord Coord { get; set; } = new();
|
public LocationCoord Coord { get; set; } = new();
|
||||||
|
|
||||||
|
[BsonElement("biomeKey")]
|
||||||
|
public string BiomeKey { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("locationObject")]
|
||||||
|
public VisibleLocationObject? LocationObject { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
21
microservices/CharacterApi/Models/VisibleLocationObject.cs
Normal file
21
microservices/CharacterApi/Models/VisibleLocationObject.cs
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
using MongoDB.Bson.Serialization.Attributes;
|
||||||
|
|
||||||
|
namespace CharacterApi.Models;
|
||||||
|
|
||||||
|
public class VisibleLocationObject
|
||||||
|
{
|
||||||
|
[BsonElement("id")]
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("objectType")]
|
||||||
|
public string ObjectType { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("objectKey")]
|
||||||
|
public string ObjectKey { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("name")]
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("state")]
|
||||||
|
public VisibleLocationObjectState State { get; set; } = new();
|
||||||
|
}
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
using MongoDB.Bson.Serialization.Attributes;
|
||||||
|
|
||||||
|
namespace CharacterApi.Models;
|
||||||
|
|
||||||
|
public class VisibleLocationObjectState
|
||||||
|
{
|
||||||
|
[BsonElement("itemKey")]
|
||||||
|
public string ItemKey { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("remainingQuantity")]
|
||||||
|
public int RemainingQuantity { get; set; }
|
||||||
|
|
||||||
|
[BsonElement("gatherQuantity")]
|
||||||
|
public int GatherQuantity { get; set; } = 1;
|
||||||
|
}
|
||||||
@ -70,6 +70,12 @@ public class CharacterStore
|
|||||||
);
|
);
|
||||||
|
|
||||||
var documents = await _locations.Find(filter).ToListAsync();
|
var documents = await _locations.Find(filter).ToListAsync();
|
||||||
|
if (ensureGenerated)
|
||||||
|
{
|
||||||
|
foreach (var document in documents)
|
||||||
|
await EnsureLocationObjectAsync(document);
|
||||||
|
documents = await _locations.Find(filter).ToListAsync();
|
||||||
|
}
|
||||||
return documents.Select(MapVisibleLocation).ToList();
|
return documents.Select(MapVisibleLocation).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -95,6 +101,9 @@ public class CharacterStore
|
|||||||
{ "x", x },
|
{ "x", x },
|
||||||
{ "y", y }
|
{ "y", y }
|
||||||
})
|
})
|
||||||
|
.SetOnInsert("biomeKey", DetermineBiomeKey(x, y))
|
||||||
|
.SetOnInsert("locationObject", CreateLocationObjectValueForBiome(DetermineBiomeKey(x, y), x, y))
|
||||||
|
.SetOnInsert("locationObjectResolved", true)
|
||||||
.SetOnInsert("createdUtc", DateTime.UtcNow);
|
.SetOnInsert("createdUtc", DateTime.UtcNow);
|
||||||
|
|
||||||
try
|
try
|
||||||
@ -123,6 +132,7 @@ public class CharacterStore
|
|||||||
private static VisibleLocation MapVisibleLocation(BsonDocument document)
|
private static VisibleLocation MapVisibleLocation(BsonDocument document)
|
||||||
{
|
{
|
||||||
var coord = document.GetValue("coord", new BsonDocument()).AsBsonDocument;
|
var coord = document.GetValue("coord", new BsonDocument()).AsBsonDocument;
|
||||||
|
var locationObject = MapVisibleLocationObject(document.GetValue("locationObject", BsonNull.Value));
|
||||||
var idValue = document.GetValue("_id", BsonNull.Value);
|
var idValue = document.GetValue("_id", BsonNull.Value);
|
||||||
string? id = null;
|
string? id = null;
|
||||||
if (!idValue.IsBsonNull)
|
if (!idValue.IsBsonNull)
|
||||||
@ -140,10 +150,180 @@ public class CharacterStore
|
|||||||
{
|
{
|
||||||
X = coord.GetValue("x", 0).ToInt32(),
|
X = coord.GetValue("x", 0).ToInt32(),
|
||||||
Y = coord.GetValue("y", 0).ToInt32()
|
Y = coord.GetValue("y", 0).ToInt32()
|
||||||
|
},
|
||||||
|
BiomeKey = document.GetValue("biomeKey", "").AsString,
|
||||||
|
LocationObject = locationObject
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EnsureLocationObjectAsync(BsonDocument document)
|
||||||
|
{
|
||||||
|
var hasBiome = document.TryGetValue("biomeKey", out var biomeValue) &&
|
||||||
|
biomeValue.BsonType == BsonType.String &&
|
||||||
|
!string.IsNullOrWhiteSpace(biomeValue.AsString);
|
||||||
|
var resolved = document.TryGetValue("locationObjectResolved", out var resolvedValue) &&
|
||||||
|
resolvedValue.ToBoolean();
|
||||||
|
if (hasBiome && resolved)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var idValue = document.GetValue("_id", BsonNull.Value);
|
||||||
|
if (idValue.IsBsonNull)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var coord = document.GetValue("coord", new BsonDocument()).AsBsonDocument;
|
||||||
|
var x = coord.GetValue("x", 0).ToInt32();
|
||||||
|
var y = coord.GetValue("y", 0).ToInt32();
|
||||||
|
var biomeKey = hasBiome ? biomeValue.AsString : DetermineBiomeKey(x, y);
|
||||||
|
var locationObject = TryMigrateLegacyResource(document) ?? CreateLocationObjectValueForBiome(biomeKey, x, y);
|
||||||
|
var filter = Builders<BsonDocument>.Filter.And(
|
||||||
|
Builders<BsonDocument>.Filter.Eq("_id", idValue)
|
||||||
|
);
|
||||||
|
var update = Builders<BsonDocument>.Update
|
||||||
|
.Set("biomeKey", biomeKey)
|
||||||
|
.Set("locationObjectResolved", true)
|
||||||
|
.Set("locationObject", locationObject);
|
||||||
|
await _locations.UpdateOneAsync(filter, update);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BsonDocument? TryMigrateLegacyResource(BsonDocument document)
|
||||||
|
{
|
||||||
|
if (!document.TryGetValue("resources", out var resourcesValue) || resourcesValue is not BsonArray resources)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
foreach (var resourceValue in resources)
|
||||||
|
{
|
||||||
|
if (resourceValue is not BsonDocument resource)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var remainingQuantity = resource.GetValue("remainingQuantity", 0).ToInt32();
|
||||||
|
if (remainingQuantity <= 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var itemKey = NormalizeItemKey(resource.GetValue("itemKey", "").AsString);
|
||||||
|
var gatherQuantity = Math.Max(1, resource.GetValue("gatherQuantity", 1).ToInt32());
|
||||||
|
return CreateGatherableObjectDocument(itemKey, remainingQuantity, gatherQuantity);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static VisibleLocationObject? MapVisibleLocationObject(BsonValue value)
|
||||||
|
{
|
||||||
|
if (value.IsBsonNull || value is not BsonDocument document)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var stateDoc = document.GetValue("state", new BsonDocument()).AsBsonDocument;
|
||||||
|
return new VisibleLocationObject
|
||||||
|
{
|
||||||
|
Id = document.GetValue("id", "").AsString,
|
||||||
|
ObjectType = document.GetValue("objectType", "").AsString,
|
||||||
|
ObjectKey = document.GetValue("objectKey", "").AsString,
|
||||||
|
Name = document.GetValue("name", "").AsString,
|
||||||
|
State = new VisibleLocationObjectState
|
||||||
|
{
|
||||||
|
ItemKey = stateDoc.GetValue("itemKey", "").AsString,
|
||||||
|
RemainingQuantity = stateDoc.GetValue("remainingQuantity", 0).ToInt32(),
|
||||||
|
GatherQuantity = stateDoc.GetValue("gatherQuantity", 1).ToInt32()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string DetermineBiomeKey(int x, int y)
|
||||||
|
{
|
||||||
|
if (x == 0 && y == 0)
|
||||||
|
return "plains";
|
||||||
|
|
||||||
|
var regionX = FloorDiv(x, 4);
|
||||||
|
var regionY = FloorDiv(y, 4);
|
||||||
|
var roll = Math.Abs(HashCode.Combine(regionX, regionY, 7919)) % 100;
|
||||||
|
if (roll < 35)
|
||||||
|
return "plains";
|
||||||
|
if (roll < 60)
|
||||||
|
return "forest";
|
||||||
|
if (roll < 80)
|
||||||
|
return "rocky";
|
||||||
|
if (roll < 92)
|
||||||
|
return "wetlands";
|
||||||
|
return "desert";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BsonValue CreateLocationObjectValueForBiome(string biomeKey, int x, int y)
|
||||||
|
{
|
||||||
|
var roll = Math.Abs(HashCode.Combine(x, y, 1543)) % 100;
|
||||||
|
return biomeKey switch
|
||||||
|
{
|
||||||
|
"forest" => roll switch
|
||||||
|
{
|
||||||
|
< 35 => BsonNull.Value,
|
||||||
|
< 80 => CreateGatherableObjectDocument("wood", 60, 3),
|
||||||
|
< 95 => CreateGatherableObjectDocument("grass", 120, 10),
|
||||||
|
_ => CreateGatherableObjectDocument("stone", 40, 2)
|
||||||
|
},
|
||||||
|
"rocky" => roll switch
|
||||||
|
{
|
||||||
|
< 60 => BsonNull.Value,
|
||||||
|
< 90 => CreateGatherableObjectDocument("stone", 40, 2),
|
||||||
|
_ => CreateGatherableObjectDocument("wood", 60, 3)
|
||||||
|
},
|
||||||
|
"wetlands" => roll switch
|
||||||
|
{
|
||||||
|
< 40 => BsonNull.Value,
|
||||||
|
< 90 => CreateGatherableObjectDocument("grass", 120, 10),
|
||||||
|
_ => CreateGatherableObjectDocument("wood", 60, 3)
|
||||||
|
},
|
||||||
|
"desert" => roll switch
|
||||||
|
{
|
||||||
|
< 70 => BsonNull.Value,
|
||||||
|
< 95 => CreateGatherableObjectDocument("stone", 40, 2),
|
||||||
|
_ => CreateGatherableObjectDocument("wood", 60, 3)
|
||||||
|
},
|
||||||
|
_ => roll switch
|
||||||
|
{
|
||||||
|
< 50 => BsonNull.Value,
|
||||||
|
< 85 => CreateGatherableObjectDocument("grass", 120, 10),
|
||||||
|
_ => CreateGatherableObjectDocument("wood", 60, 3)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BsonDocument CreateGatherableObjectDocument(string itemKey, int remainingQuantity, int gatherQuantity)
|
||||||
|
{
|
||||||
|
var normalizedItemKey = NormalizeItemKey(itemKey);
|
||||||
|
return new BsonDocument
|
||||||
|
{
|
||||||
|
{ "id", Guid.NewGuid().ToString("N") },
|
||||||
|
{ "objectType", "gatherable" },
|
||||||
|
{ "objectKey", $"{normalizedItemKey}_node" },
|
||||||
|
{ "name", HumanizeItemKey(normalizedItemKey) },
|
||||||
|
{
|
||||||
|
"state", new BsonDocument
|
||||||
|
{
|
||||||
|
{ "itemKey", normalizedItemKey },
|
||||||
|
{ "remainingQuantity", remainingQuantity },
|
||||||
|
{ "gatherQuantity", gatherQuantity }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeItemKey(string itemKey) => itemKey.Trim().ToLowerInvariant();
|
||||||
|
|
||||||
|
private static string HumanizeItemKey(string itemKey)
|
||||||
|
{
|
||||||
|
return string.Join(" ", itemKey.Split('_', StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.Where(part => part.Length > 0)
|
||||||
|
.Select(part => char.ToUpperInvariant(part[0]) + part[1..]));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int FloorDiv(int value, int divisor)
|
||||||
|
{
|
||||||
|
var quotient = value / divisor;
|
||||||
|
var remainder = value % divisor;
|
||||||
|
if (remainder != 0 && ((remainder < 0) != (divisor < 0)))
|
||||||
|
quotient -= 1;
|
||||||
|
return quotient;
|
||||||
|
}
|
||||||
|
|
||||||
private void EnsureLocationCoordIndexes()
|
private void EnsureLocationCoordIndexes()
|
||||||
{
|
{
|
||||||
var indexes = _locations.Indexes.List().ToList();
|
var indexes = _locations.Indexes.List().ToList();
|
||||||
|
|||||||
@ -94,40 +94,42 @@ public class LocationsController : ControllerBase
|
|||||||
return Ok("Updated");
|
return Ok("Updated");
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id}/gather")]
|
[HttpPost("{id}/interact")]
|
||||||
[Authorize(Roles = "USER,SUPER")]
|
[Authorize(Roles = "USER,SUPER")]
|
||||||
public async Task<IActionResult> Gather(string id, [FromBody] GatherResourceRequest req)
|
public async Task<IActionResult> Interact(string id, [FromBody] InteractLocationObjectRequest req)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(req.CharacterId))
|
if (string.IsNullOrWhiteSpace(req.CharacterId))
|
||||||
return BadRequest("characterId required");
|
return BadRequest("characterId required");
|
||||||
if (string.IsNullOrWhiteSpace(req.ResourceKey))
|
if (string.IsNullOrWhiteSpace(req.ObjectId))
|
||||||
return BadRequest("resourceKey required");
|
return BadRequest("objectId required");
|
||||||
|
|
||||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
if (string.IsNullOrWhiteSpace(userId))
|
if (string.IsNullOrWhiteSpace(userId))
|
||||||
return Unauthorized();
|
return Unauthorized();
|
||||||
|
|
||||||
var allowAnyOwner = User.IsInRole("SUPER");
|
var allowAnyOwner = User.IsInRole("SUPER");
|
||||||
var gather = await _locations.GatherResourceAsync(id, req.CharacterId, req.ResourceKey, userId, allowAnyOwner);
|
var interact = await _locations.InteractWithObjectAsync(id, req.CharacterId, req.ObjectId, userId, allowAnyOwner);
|
||||||
if (gather.Status == GatherStatus.LocationNotFound)
|
if (interact.Status == InteractStatus.LocationNotFound)
|
||||||
return NotFound("Location not found");
|
return NotFound("Location not found");
|
||||||
if (gather.Status == GatherStatus.CharacterNotFound)
|
if (interact.Status == InteractStatus.CharacterNotFound)
|
||||||
return NotFound("Character not found");
|
return NotFound("Character not found");
|
||||||
if (gather.Status == GatherStatus.Forbidden)
|
if (interact.Status == InteractStatus.Forbidden)
|
||||||
return Forbid();
|
return Forbid();
|
||||||
if (gather.Status == GatherStatus.Invalid)
|
if (interact.Status == InteractStatus.Invalid)
|
||||||
return BadRequest("Character is not at the target location");
|
return BadRequest("Character is not at the target location");
|
||||||
if (gather.Status == GatherStatus.ResourceNotFound)
|
if (interact.Status == InteractStatus.ObjectNotFound)
|
||||||
return NotFound("Resource not found at location");
|
return NotFound("Location object not found");
|
||||||
if (gather.Status == GatherStatus.ResourceDepleted)
|
if (interact.Status == InteractStatus.UnsupportedObjectType)
|
||||||
return Conflict("Resource is depleted");
|
return BadRequest("Location object type is not supported");
|
||||||
|
if (interact.Status == InteractStatus.ObjectConsumed)
|
||||||
|
return Conflict("Location object is consumed");
|
||||||
|
|
||||||
var inventoryBaseUrl = (_configuration["Services:InventoryApiBaseUrl"] ?? "http://localhost:5003").TrimEnd('/');
|
var inventoryBaseUrl = (_configuration["Services:InventoryApiBaseUrl"] ?? "http://localhost:5003").TrimEnd('/');
|
||||||
var token = Request.Headers.Authorization.ToString();
|
var token = Request.Headers.Authorization.ToString();
|
||||||
var grantBody = JsonSerializer.Serialize(new
|
var grantBody = JsonSerializer.Serialize(new
|
||||||
{
|
{
|
||||||
itemKey = gather.ResourceKey,
|
itemKey = interact.ItemKey,
|
||||||
quantity = gather.QuantityGranted
|
quantity = interact.QuantityGranted
|
||||||
});
|
});
|
||||||
|
|
||||||
var client = _httpClientFactory.CreateClient();
|
var client = _httpClientFactory.CreateClient();
|
||||||
@ -142,17 +144,21 @@ public class LocationsController : ControllerBase
|
|||||||
var responseBody = await response.Content.ReadAsStringAsync();
|
var responseBody = await response.Content.ReadAsStringAsync();
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
await _locations.RestoreGatheredResourceAsync(id, gather.ResourceKey, gather.QuantityGranted);
|
if (interact.PreviousObject is not null)
|
||||||
|
await _locations.RestoreObjectInteractionAsync(id, interact.PreviousObject);
|
||||||
return StatusCode((int)response.StatusCode, responseBody);
|
return StatusCode((int)response.StatusCode, responseBody);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok(new GatherResourceResponse
|
return Ok(new InteractLocationObjectResponse
|
||||||
{
|
{
|
||||||
LocationId = id,
|
LocationId = id,
|
||||||
CharacterId = req.CharacterId,
|
CharacterId = req.CharacterId,
|
||||||
ResourceKey = gather.ResourceKey,
|
ObjectId = interact.ObjectId,
|
||||||
QuantityGranted = gather.QuantityGranted,
|
ObjectType = interact.ObjectType,
|
||||||
RemainingQuantity = gather.RemainingQuantity,
|
ItemKey = interact.ItemKey,
|
||||||
|
QuantityGranted = interact.QuantityGranted,
|
||||||
|
RemainingQuantity = interact.RemainingQuantity,
|
||||||
|
Consumed = interact.Consumed,
|
||||||
InventoryResponseJson = responseBody
|
InventoryResponseJson = responseBody
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,8 @@
|
|||||||
|
namespace LocationsApi.Models;
|
||||||
|
|
||||||
|
public class InteractLocationObjectRequest
|
||||||
|
{
|
||||||
|
public string CharacterId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string ObjectId { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
namespace LocationsApi.Models;
|
||||||
|
|
||||||
|
public class InteractLocationObjectResponse
|
||||||
|
{
|
||||||
|
public string LocationId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string CharacterId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string ObjectId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string ObjectType { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string ItemKey { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public int QuantityGranted { get; set; }
|
||||||
|
|
||||||
|
public int RemainingQuantity { get; set; }
|
||||||
|
|
||||||
|
public bool Consumed { get; set; }
|
||||||
|
|
||||||
|
public string InventoryResponseJson { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@ -18,6 +18,15 @@ public class Location
|
|||||||
[BsonElement("resources")]
|
[BsonElement("resources")]
|
||||||
public List<LocationResource> Resources { get; set; } = [];
|
public List<LocationResource> Resources { get; set; } = [];
|
||||||
|
|
||||||
|
[BsonElement("biomeKey")]
|
||||||
|
public string BiomeKey { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("locationObject")]
|
||||||
|
public LocationObject? LocationObject { get; set; }
|
||||||
|
|
||||||
|
[BsonElement("locationObjectResolved")]
|
||||||
|
public bool LocationObjectResolved { get; set; }
|
||||||
|
|
||||||
[BsonElement("createdUtc")]
|
[BsonElement("createdUtc")]
|
||||||
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|||||||
21
microservices/LocationsApi/Models/LocationObject.cs
Normal file
21
microservices/LocationsApi/Models/LocationObject.cs
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
using MongoDB.Bson.Serialization.Attributes;
|
||||||
|
|
||||||
|
namespace LocationsApi.Models;
|
||||||
|
|
||||||
|
public class LocationObject
|
||||||
|
{
|
||||||
|
[BsonElement("id")]
|
||||||
|
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||||
|
|
||||||
|
[BsonElement("objectType")]
|
||||||
|
public string ObjectType { get; set; } = "gatherable";
|
||||||
|
|
||||||
|
[BsonElement("objectKey")]
|
||||||
|
public string ObjectKey { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("name")]
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("state")]
|
||||||
|
public LocationObjectState State { get; set; } = new();
|
||||||
|
}
|
||||||
15
microservices/LocationsApi/Models/LocationObjectState.cs
Normal file
15
microservices/LocationsApi/Models/LocationObjectState.cs
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
using MongoDB.Bson.Serialization.Attributes;
|
||||||
|
|
||||||
|
namespace LocationsApi.Models;
|
||||||
|
|
||||||
|
public class LocationObjectState
|
||||||
|
{
|
||||||
|
[BsonElement("itemKey")]
|
||||||
|
public string ItemKey { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("remainingQuantity")]
|
||||||
|
public int RemainingQuantity { get; set; }
|
||||||
|
|
||||||
|
[BsonElement("gatherQuantity")]
|
||||||
|
public int GatherQuantity { get; set; } = 1;
|
||||||
|
}
|
||||||
@ -76,6 +76,37 @@ public class LocationStore
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{ "biomeKey", new BsonDocument { { "bsonType", new BsonArray { "string", "null" } } } },
|
||||||
|
{
|
||||||
|
"locationObject", new BsonDocument
|
||||||
|
{
|
||||||
|
{ "bsonType", new BsonArray { "object", "null" } },
|
||||||
|
{
|
||||||
|
"properties", new BsonDocument
|
||||||
|
{
|
||||||
|
{ "id", new BsonDocument { { "bsonType", "string" } } },
|
||||||
|
{ "objectType", new BsonDocument { { "bsonType", "string" } } },
|
||||||
|
{ "objectKey", new BsonDocument { { "bsonType", "string" } } },
|
||||||
|
{ "name", new BsonDocument { { "bsonType", "string" } } },
|
||||||
|
{
|
||||||
|
"state", new BsonDocument
|
||||||
|
{
|
||||||
|
{ "bsonType", new BsonArray { "object", "null" } },
|
||||||
|
{
|
||||||
|
"properties", new BsonDocument
|
||||||
|
{
|
||||||
|
{ "itemKey", new BsonDocument { { "bsonType", "string" } } },
|
||||||
|
{ "remainingQuantity", new BsonDocument { { "bsonType", "int" }, { "minimum", 0 } } },
|
||||||
|
{ "gatherQuantity", new BsonDocument { { "bsonType", "int" }, { "minimum", 1 } } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ "locationObjectResolved", new BsonDocument { { "bsonType", new BsonArray { "bool", "null" } } } },
|
||||||
{ "createdUtc", new BsonDocument { { "bsonType", "date" } } }
|
{ "createdUtc", new BsonDocument { { "bsonType", "date" } } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -125,54 +156,79 @@ public class LocationStore
|
|||||||
return result.ModifiedCount > 0;
|
return result.ModifiedCount > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record GatherResult(GatherStatus Status, string ResourceKey = "", int QuantityGranted = 0, int RemainingQuantity = 0);
|
public sealed record InteractResult(
|
||||||
|
InteractStatus Status,
|
||||||
|
string ObjectId = "",
|
||||||
|
string ObjectType = "",
|
||||||
|
string ItemKey = "",
|
||||||
|
int QuantityGranted = 0,
|
||||||
|
int RemainingQuantity = 0,
|
||||||
|
bool Consumed = false,
|
||||||
|
LocationObject? PreviousObject = null);
|
||||||
|
|
||||||
public async Task<GatherResult> GatherResourceAsync(string locationId, string characterId, string resourceKey, string userId, bool allowAnyOwner)
|
public async Task<InteractResult> InteractWithObjectAsync(string locationId, string characterId, string objectId, string userId, bool allowAnyOwner)
|
||||||
{
|
{
|
||||||
var normalizedKey = resourceKey.Trim().ToLowerInvariant();
|
|
||||||
var location = await _col.Find(l => l.Id == locationId).FirstOrDefaultAsync();
|
var location = await _col.Find(l => l.Id == locationId).FirstOrDefaultAsync();
|
||||||
if (location is null)
|
if (location is null)
|
||||||
return new GatherResult(GatherStatus.LocationNotFound);
|
return new InteractResult(InteractStatus.LocationNotFound);
|
||||||
|
|
||||||
|
location = await EnsureLocationMetadataAsync(location);
|
||||||
|
|
||||||
var character = await _characters.Find(c => c.Id == characterId).FirstOrDefaultAsync();
|
var character = await _characters.Find(c => c.Id == characterId).FirstOrDefaultAsync();
|
||||||
if (character is null)
|
if (character is null)
|
||||||
return new GatherResult(GatherStatus.CharacterNotFound);
|
return new InteractResult(InteractStatus.CharacterNotFound);
|
||||||
if (!allowAnyOwner && character.OwnerUserId != userId)
|
if (!allowAnyOwner && character.OwnerUserId != userId)
|
||||||
return new GatherResult(GatherStatus.Forbidden);
|
return new InteractResult(InteractStatus.Forbidden);
|
||||||
if (character.Coord.X != location.Coord.X || character.Coord.Y != location.Coord.Y)
|
if (character.Coord.X != location.Coord.X || character.Coord.Y != location.Coord.Y)
|
||||||
return new GatherResult(GatherStatus.Invalid);
|
return new InteractResult(InteractStatus.Invalid);
|
||||||
|
|
||||||
var resource = location.Resources.FirstOrDefault(r => NormalizeItemKey(r.ItemKey) == normalizedKey);
|
var locationObject = location.LocationObject;
|
||||||
if (resource is null)
|
if (locationObject is null)
|
||||||
return new GatherResult(GatherStatus.ResourceNotFound);
|
return new InteractResult(InteractStatus.ObjectNotFound);
|
||||||
if (resource.RemainingQuantity <= 0)
|
if (!string.Equals(locationObject.Id, objectId, StringComparison.Ordinal))
|
||||||
return new GatherResult(GatherStatus.ResourceDepleted);
|
return new InteractResult(InteractStatus.ObjectNotFound);
|
||||||
|
if (!string.Equals(locationObject.ObjectType, "gatherable", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return new InteractResult(InteractStatus.UnsupportedObjectType);
|
||||||
|
if (locationObject.State.RemainingQuantity <= 0)
|
||||||
|
return new InteractResult(InteractStatus.ObjectConsumed);
|
||||||
|
|
||||||
var quantityGranted = Math.Min(resource.GatherQuantity, resource.RemainingQuantity);
|
var quantityGranted = Math.Min(locationObject.State.GatherQuantity, locationObject.State.RemainingQuantity);
|
||||||
var filter = Builders<Location>.Filter.And(
|
var remainingQuantity = locationObject.State.RemainingQuantity - quantityGranted;
|
||||||
|
var objectFilter = Builders<Location>.Filter.And(
|
||||||
Builders<Location>.Filter.Eq(l => l.Id, locationId),
|
Builders<Location>.Filter.Eq(l => l.Id, locationId),
|
||||||
Builders<Location>.Filter.ElemMatch(l => l.Resources, r => r.ItemKey == resource.ItemKey && r.RemainingQuantity >= quantityGranted)
|
Builders<Location>.Filter.Eq("locationObject.id", locationObject.Id),
|
||||||
|
Builders<Location>.Filter.Eq("locationObject.state.remainingQuantity", locationObject.State.RemainingQuantity)
|
||||||
);
|
);
|
||||||
var update = Builders<Location>.Update.Inc("resources.$.remainingQuantity", -quantityGranted);
|
|
||||||
var result = await _col.UpdateOneAsync(filter, update);
|
|
||||||
if (result.ModifiedCount == 0)
|
|
||||||
return new GatherResult(GatherStatus.ResourceDepleted);
|
|
||||||
|
|
||||||
return new GatherResult(
|
UpdateDefinition<Location> update;
|
||||||
GatherStatus.Ok,
|
if (remainingQuantity <= 0)
|
||||||
resource.ItemKey,
|
{
|
||||||
quantityGranted,
|
update = Builders<Location>.Update.Unset("locationObject");
|
||||||
resource.RemainingQuantity - quantityGranted);
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
update = Builders<Location>.Update.Set("locationObject.state.remainingQuantity", remainingQuantity);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task RestoreGatheredResourceAsync(string locationId, string resourceKey, int quantity)
|
var result = await _col.UpdateOneAsync(objectFilter, update);
|
||||||
|
if (result.ModifiedCount == 0)
|
||||||
|
return new InteractResult(InteractStatus.ObjectConsumed);
|
||||||
|
|
||||||
|
return new InteractResult(
|
||||||
|
InteractStatus.Ok,
|
||||||
|
locationObject.Id,
|
||||||
|
locationObject.ObjectType,
|
||||||
|
locationObject.State.ItemKey,
|
||||||
|
quantityGranted,
|
||||||
|
Math.Max(0, remainingQuantity),
|
||||||
|
remainingQuantity <= 0,
|
||||||
|
CloneLocationObject(locationObject));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RestoreObjectInteractionAsync(string locationId, LocationObject previousObject)
|
||||||
{
|
{
|
||||||
var normalizedKey = NormalizeItemKey(resourceKey);
|
var filter = Builders<Location>.Filter.Eq(l => l.Id, locationId);
|
||||||
var filter = Builders<Location>.Filter.And(
|
var update = Builders<Location>.Update.Set(l => l.LocationObject, previousObject);
|
||||||
Builders<Location>.Filter.Eq(l => l.Id, locationId),
|
|
||||||
Builders<Location>.Filter.ElemMatch(l => l.Resources, r => r.ItemKey == normalizedKey)
|
|
||||||
);
|
|
||||||
var update = Builders<Location>.Update.Inc("resources.$.remainingQuantity", quantity);
|
|
||||||
await _col.UpdateOneAsync(filter, update);
|
await _col.UpdateOneAsync(filter, update);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -227,11 +283,9 @@ public class LocationStore
|
|||||||
{
|
{
|
||||||
Name = "Origin",
|
Name = "Origin",
|
||||||
Coord = new Coord { X = 0, Y = 0 },
|
Coord = new Coord { X = 0, Y = 0 },
|
||||||
Resources =
|
BiomeKey = DetermineBiomeKey(0, 0),
|
||||||
[
|
LocationObject = CreateLocationObjectForBiome(DetermineBiomeKey(0, 0), 0, 0),
|
||||||
new LocationResource { ItemKey = "wood", RemainingQuantity = 100, GatherQuantity = 3 },
|
LocationObjectResolved = true,
|
||||||
new LocationResource { ItemKey = "grass", RemainingQuantity = 500, GatherQuantity = 10 }
|
|
||||||
],
|
|
||||||
CreatedUtc = DateTime.UtcNow
|
CreatedUtc = DateTime.UtcNow
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -247,6 +301,150 @@ public class LocationStore
|
|||||||
|
|
||||||
private static string NormalizeItemKey(string itemKey) => itemKey.Trim().ToLowerInvariant();
|
private static string NormalizeItemKey(string itemKey) => itemKey.Trim().ToLowerInvariant();
|
||||||
|
|
||||||
|
private async Task<Location> EnsureLocationMetadataAsync(Location location)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(location.BiomeKey) && location.LocationObjectResolved)
|
||||||
|
return location;
|
||||||
|
|
||||||
|
var biomeKey = location.BiomeKey;
|
||||||
|
if (string.IsNullOrWhiteSpace(biomeKey))
|
||||||
|
biomeKey = DetermineBiomeKey(location.Coord.X, location.Coord.Y);
|
||||||
|
|
||||||
|
var migratedObject = TryMigrateLegacyResources(location) ?? CreateLocationObjectForBiome(biomeKey, location.Coord.X, location.Coord.Y);
|
||||||
|
var filter = Builders<Location>.Filter.And(
|
||||||
|
Builders<Location>.Filter.Eq(l => l.Id, location.Id)
|
||||||
|
);
|
||||||
|
var update = Builders<Location>.Update
|
||||||
|
.Set(l => l.BiomeKey, biomeKey)
|
||||||
|
.Set(l => l.LocationObjectResolved, true)
|
||||||
|
.Set(l => l.LocationObject, migratedObject);
|
||||||
|
await _col.UpdateOneAsync(filter, update);
|
||||||
|
location.BiomeKey = biomeKey;
|
||||||
|
location.LocationObject = migratedObject;
|
||||||
|
location.LocationObjectResolved = true;
|
||||||
|
return location;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LocationObject? TryMigrateLegacyResources(Location location)
|
||||||
|
{
|
||||||
|
var legacyResource = location.Resources.FirstOrDefault(r => r.RemainingQuantity > 0);
|
||||||
|
if (legacyResource is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return CreateGatherableObject(
|
||||||
|
legacyResource.ItemKey,
|
||||||
|
legacyResource.RemainingQuantity,
|
||||||
|
legacyResource.GatherQuantity);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DetermineBiomeKey(int x, int y)
|
||||||
|
{
|
||||||
|
if (x == 0 && y == 0)
|
||||||
|
return "plains";
|
||||||
|
|
||||||
|
var regionX = FloorDiv(x, 4);
|
||||||
|
var regionY = FloorDiv(y, 4);
|
||||||
|
var roll = Math.Abs(HashCode.Combine(regionX, regionY, 7919)) % 100;
|
||||||
|
if (roll < 35)
|
||||||
|
return "plains";
|
||||||
|
if (roll < 60)
|
||||||
|
return "forest";
|
||||||
|
if (roll < 80)
|
||||||
|
return "rocky";
|
||||||
|
if (roll < 92)
|
||||||
|
return "wetlands";
|
||||||
|
return "desert";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LocationObject? CreateLocationObjectForBiome(string biomeKey, int x, int y)
|
||||||
|
{
|
||||||
|
var roll = Math.Abs(HashCode.Combine(x, y, 1543)) % 100;
|
||||||
|
return biomeKey switch
|
||||||
|
{
|
||||||
|
"forest" => roll switch
|
||||||
|
{
|
||||||
|
< 35 => null,
|
||||||
|
< 80 => CreateGatherableObject("wood", 60, 3),
|
||||||
|
< 95 => CreateGatherableObject("grass", 120, 10),
|
||||||
|
_ => CreateGatherableObject("stone", 40, 2)
|
||||||
|
},
|
||||||
|
"rocky" => roll switch
|
||||||
|
{
|
||||||
|
< 60 => null,
|
||||||
|
< 90 => CreateGatherableObject("stone", 40, 2),
|
||||||
|
_ => CreateGatherableObject("wood", 60, 3)
|
||||||
|
},
|
||||||
|
"wetlands" => roll switch
|
||||||
|
{
|
||||||
|
< 40 => null,
|
||||||
|
< 90 => CreateGatherableObject("grass", 120, 10),
|
||||||
|
_ => CreateGatherableObject("wood", 60, 3)
|
||||||
|
},
|
||||||
|
"desert" => roll switch
|
||||||
|
{
|
||||||
|
< 70 => null,
|
||||||
|
< 95 => CreateGatherableObject("stone", 40, 2),
|
||||||
|
_ => CreateGatherableObject("wood", 60, 3)
|
||||||
|
},
|
||||||
|
_ => roll switch
|
||||||
|
{
|
||||||
|
< 50 => null,
|
||||||
|
< 85 => CreateGatherableObject("grass", 120, 10),
|
||||||
|
_ => CreateGatherableObject("wood", 60, 3)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LocationObject CreateGatherableObject(string itemKey, int remainingQuantity, int gatherQuantity)
|
||||||
|
{
|
||||||
|
var normalizedItemKey = NormalizeItemKey(itemKey);
|
||||||
|
return new LocationObject
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString("N"),
|
||||||
|
ObjectType = "gatherable",
|
||||||
|
ObjectKey = $"{normalizedItemKey}_node",
|
||||||
|
Name = HumanizeItemKey(normalizedItemKey),
|
||||||
|
State = new LocationObjectState
|
||||||
|
{
|
||||||
|
ItemKey = normalizedItemKey,
|
||||||
|
RemainingQuantity = remainingQuantity,
|
||||||
|
GatherQuantity = gatherQuantity
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string HumanizeItemKey(string itemKey)
|
||||||
|
{
|
||||||
|
return string.Join(" ", itemKey.Split('_', StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.Select(part => char.ToUpperInvariant(part[0]) + part[1..]));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int FloorDiv(int value, int divisor)
|
||||||
|
{
|
||||||
|
var quotient = value / divisor;
|
||||||
|
var remainder = value % divisor;
|
||||||
|
if (remainder != 0 && ((remainder < 0) != (divisor < 0)))
|
||||||
|
quotient -= 1;
|
||||||
|
return quotient;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LocationObject CloneLocationObject(LocationObject source)
|
||||||
|
{
|
||||||
|
return new LocationObject
|
||||||
|
{
|
||||||
|
Id = source.Id,
|
||||||
|
ObjectType = source.ObjectType,
|
||||||
|
ObjectKey = source.ObjectKey,
|
||||||
|
Name = source.Name,
|
||||||
|
State = new LocationObjectState
|
||||||
|
{
|
||||||
|
ItemKey = source.State.ItemKey,
|
||||||
|
RemainingQuantity = source.State.RemainingQuantity,
|
||||||
|
GatherQuantity = source.State.GatherQuantity
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
[MongoDB.Bson.Serialization.Attributes.BsonIgnoreExtraElements]
|
[MongoDB.Bson.Serialization.Attributes.BsonIgnoreExtraElements]
|
||||||
private class CharacterDocument
|
private class CharacterDocument
|
||||||
{
|
{
|
||||||
@ -260,13 +458,14 @@ public class LocationStore
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum GatherStatus
|
public enum InteractStatus
|
||||||
{
|
{
|
||||||
Ok,
|
Ok,
|
||||||
LocationNotFound,
|
LocationNotFound,
|
||||||
CharacterNotFound,
|
CharacterNotFound,
|
||||||
Forbidden,
|
Forbidden,
|
||||||
Invalid,
|
Invalid,
|
||||||
ResourceNotFound,
|
ObjectNotFound,
|
||||||
ResourceDepleted
|
UnsupportedObjectType,
|
||||||
|
ObjectConsumed
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user