Updating location schema to tracker user territory and lock visibility

This commit is contained in:
hz
2026-05-18 13:59:33 -05:00
parent dd151c5af1
commit afeb9cf625
23 changed files with 590 additions and 38 deletions
@@ -17,6 +17,7 @@ public class LocationsController : ControllerBase
_locations = locations;
}
[Authorize(Roles = "USER,SUPER")]
[HttpGet]
public async Task<ActionResult<List<LocationResponse>>> GetAll(
[FromQuery] int? minQ,
@@ -24,6 +25,25 @@ public class LocationsController : ControllerBase
[FromQuery] int? minR,
[FromQuery] int? maxR)
{
if (!User.IsInRole("SUPER"))
{
var username = User.FindFirstValue(ClaimTypes.Name);
if (string.IsNullOrWhiteSpace(username))
{
return Unauthorized();
}
var visible = await _locations.GetVisibleAsync(username);
if (minQ.HasValue && maxQ.HasValue && minR.HasValue && maxR.HasValue)
{
visible = visible
.Where(x => x.Q >= minQ.Value && x.Q <= maxQ.Value && x.R >= minR.Value && x.R <= maxR.Value)
.ToList();
}
return Ok(visible);
}
var locations = minQ.HasValue && maxQ.HasValue && minR.HasValue && maxR.HasValue
? await _locations.GetRangeAsync(minQ.Value, maxQ.Value, minR.Value, maxR.Value)
: await _locations.GetAllAsync();
@@ -31,9 +51,22 @@ public class LocationsController : ControllerBase
return Ok(locations.Select(LocationResponse.FromLocation).ToList());
}
[Authorize(Roles = "USER,SUPER")]
[HttpGet("{q:int}/{r:int}")]
public async Task<ActionResult<LocationResponse>> Get(int q, int r)
{
if (!User.IsInRole("SUPER"))
{
var username = User.FindFirstValue(ClaimTypes.Name);
if (string.IsNullOrWhiteSpace(username))
{
return Unauthorized();
}
var visibleLocation = await _locations.GetVisibleLocationAsync(username, q, r);
return visibleLocation is null ? NotFound() : Ok(visibleLocation);
}
var location = await _locations.GetAsync(q, r);
return location is null ? NotFound() : Ok(LocationResponse.FromLocation(location));
}
@@ -48,10 +81,61 @@ public class LocationsController : ControllerBase
return Unauthorized();
}
if (!User.IsInRole("SUPER") && !await _locations.CanSeeAsync(username, request.Q, request.R))
{
return NotFound();
}
var location = await _locations.ClaimAsync(request.Q, request.R, username);
if (location is null)
{
return Conflict("Location is already controlled by another player.");
}
return Ok(LocationResponse.FromLocation(location));
}
[Authorize(Roles = "USER,SUPER")]
[HttpGet("home")]
public async Task<ActionResult<LocationResponse>> GetHome()
{
var username = User.FindFirstValue(ClaimTypes.Name);
if (string.IsNullOrWhiteSpace(username))
{
return Unauthorized();
}
var home = await _locations.GetHomeAsync(username);
return home is null ? NotFound() : Ok(LocationResponse.FromLocation(home));
}
[Authorize(Roles = "USER,SUPER")]
[HttpPost("home")]
public async Task<ActionResult<LocationResponse>> AssignHome()
{
var username = User.FindFirstValue(ClaimTypes.Name);
if (string.IsNullOrWhiteSpace(username))
{
return Unauthorized();
}
var home = await _locations.AssignHomeAsync(username);
return Ok(LocationResponse.FromLocation(home));
}
[Authorize(Roles = "USER,SUPER")]
[HttpGet("visible")]
public async Task<ActionResult<List<LocationResponse>>> GetVisible([FromQuery] int? depth)
{
var username = User.FindFirstValue(ClaimTypes.Name);
if (string.IsNullOrWhiteSpace(username))
{
return Unauthorized();
}
return Ok(await _locations.GetVisibleAsync(username, depth));
}
[Authorize(Roles = "SUPER")]
[HttpPut]
public async Task<ActionResult<LocationResponse>> Upsert([FromBody] UpsertLocationRequest request)
+43 -1
View File
@@ -21,10 +21,13 @@ public class LocationResponse
public int Q { get; set; }
public int R { get; set; }
public string Terrain { get; set; } = "Field";
public string Kind { get; set; } = "Open";
public string? OwnerUsername { get; set; }
public string? Label { get; set; }
public int Level { get; set; }
public DateTime? HomeAssignedAtUtc { get; set; }
public DateTime UpdatedAtUtc { get; set; }
public bool IsVisible { get; set; } = true;
public static LocationResponse FromLocation(Location location) => new()
{
@@ -32,9 +35,48 @@ public class LocationResponse
Q = location.Q,
R = location.R,
Terrain = location.Terrain,
Kind = location.Kind,
OwnerUsername = location.OwnerUsername,
Label = location.Label,
Level = location.Level,
UpdatedAtUtc = location.UpdatedAtUtc
HomeAssignedAtUtc = location.HomeAssignedAtUtc,
UpdatedAtUtc = location.UpdatedAtUtc,
IsVisible = true
};
public static LocationResponse FromCoordinate(int q, int r) => new()
{
Q = q,
R = r,
Terrain = LocationTerrain.FromCoordinate(q, r),
Kind = "Open",
Level = 1,
UpdatedAtUtc = DateTime.UtcNow,
IsVisible = true
};
}
public static class LocationTerrain
{
public static string FromCoordinate(int q, int r)
{
var height = Math.Max(Math.Max(Math.Abs(q), Math.Abs(r)), Math.Abs(-q - r));
if (height <= 1)
{
return "Command";
}
if ((q * 19 + r * 7) % 5 == 0)
{
return "Highland";
}
if ((q + r) % 4 == 0)
{
return "Forest";
}
if ((q * 3 - r) % 6 == 0)
{
return "Ore";
}
return "Field";
}
}
@@ -12,9 +12,11 @@ public class Location
public int Q { get; set; }
public int R { get; set; }
public string Terrain { get; set; } = "Field";
public string Kind { get; set; } = "Open";
public string? OwnerUsername { get; set; }
public string? Label { get; set; }
public int Level { get; set; } = 1;
public DateTime? HomeAssignedAtUtc { get; set; }
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow;
}
+7 -3
View File
@@ -4,10 +4,14 @@ Tracks hex locations for the territory map.
## Endpoints
- `GET /api/locations` List all known locations.
- `GET /api/locations?minQ=-7&maxQ=7&minR=-7&maxR=7` List a coordinate range.
- `GET /api/locations/{q}/{r}` Fetch one location.
- `GET /api/locations` List visible locations for USER/SUPER, all known locations for SUPER.
- `GET /api/locations?minQ=-7&maxQ=7&minR=-7&maxR=7` List a coordinate range, constrained by visibility for USER.
- `GET /api/locations/{q}/{r}` Fetch one visible location for USER, any known location for SUPER.
- `POST /api/locations/claim` Claim a location for the authenticated user.
- `GET /api/locations/home` Get the authenticated user's home location.
- `POST /api/locations/home` Assign or return the authenticated user's home location.
- `GET /api/locations/visible` Get locations visible to the authenticated user.
- `GET /api/locations/visible?depth=3` Override visibility depth for a request.
- `PUT /api/locations` Upsert location data (SUPER only).
`claim` and `put` require the same JWT issued by AuthApi.
@@ -6,6 +6,9 @@ namespace LocationsApi.Services;
public class LocationService
{
private readonly IMongoCollection<Location> _locations;
private readonly int _homeMinDistance;
private readonly int _homeSearchRadius;
private readonly int _visibilityDepth;
public LocationService(IConfiguration cfg)
{
@@ -14,6 +17,9 @@ public class LocationService
var client = new MongoClient(connectionString);
var database = client.GetDatabase(databaseName);
_locations = database.GetCollection<Location>("Locations");
_homeMinDistance = int.TryParse(cfg["World:HomeMinDistance"], out var minDistance) ? Math.Max(1, minDistance) : 8;
_homeSearchRadius = int.TryParse(cfg["World:HomeSearchRadius"], out var searchRadius) ? Math.Max(_homeMinDistance, searchRadius) : 80;
_visibilityDepth = int.TryParse(cfg["World:VisibilityDepth"], out var visibilityDepth) ? Math.Max(0, visibilityDepth) : 3;
var coordinateIndex = Builders<Location>.IndexKeys
.Ascending(x => x.Q)
@@ -21,6 +27,17 @@ public class LocationService
_locations.Indexes.CreateOne(new CreateIndexModel<Location>(
coordinateIndex,
new CreateIndexOptions { Unique = true }));
var ownerHomeIndex = Builders<Location>.IndexKeys
.Ascending(x => x.OwnerUsername)
.Ascending(x => x.Kind);
_locations.Indexes.CreateOne(new CreateIndexModel<Location>(
ownerHomeIndex,
new CreateIndexOptions<Location>
{
Unique = true,
PartialFilterExpression = Builders<Location>.Filter.Eq(x => x.Kind, "Home")
}));
}
public Task<List<Location>> GetAllAsync() =>
@@ -44,13 +61,16 @@ public class LocationService
var update = Builders<Location>.Update
.SetOnInsert(x => x.CreatedAtUtc, now)
.Set(x => x.Terrain, NormalizeTerrain(request.Terrain))
.Set(x => x.Kind, "Open")
.Set(x => x.Label, request.Label)
.Set(x => x.Level, Math.Max(1, request.Level))
.Set(x => x.UpdatedAtUtc, now);
if (!string.IsNullOrWhiteSpace(ownerUsername))
{
update = update.Set(x => x.OwnerUsername, ownerUsername);
update = update
.Set(x => x.OwnerUsername, ownerUsername)
.Set(x => x.Kind, "Claimed");
}
var options = new FindOneAndUpdateOptions<Location>
@@ -65,12 +85,22 @@ public class LocationService
options);
}
public async Task<Location> ClaimAsync(int q, int r, string username)
public async Task<Location?> ClaimAsync(int q, int r, string username)
{
var existing = await GetAsync(q, r);
if (existing is not null)
{
if (!string.IsNullOrWhiteSpace(existing.OwnerUsername) &&
!string.Equals(existing.OwnerUsername, username, StringComparison.OrdinalIgnoreCase))
{
return null;
}
existing.OwnerUsername = username;
if (string.IsNullOrWhiteSpace(existing.Kind) || existing.Kind == "Open")
{
existing.Kind = "Claimed";
}
existing.UpdatedAtUtc = DateTime.UtcNow;
await _locations.ReplaceOneAsync(x => x.Id == existing.Id, existing);
return existing;
@@ -79,6 +109,203 @@ public class LocationService
return await UpsertAsync(new UpsertLocationRequest { Q = q, R = r, Terrain = "Field" }, username);
}
public async Task<Location?> GetHomeAsync(string username) =>
await _locations.Find(x => x.OwnerUsername == username && x.Kind == "Home").FirstOrDefaultAsync();
public async Task<Location> AssignHomeAsync(string username)
{
var existingHome = await GetHomeAsync(username);
if (existingHome is not null)
{
return existingHome;
}
var homeLocations = await _locations.Find(x => x.Kind == "Home").ToListAsync();
foreach (var candidate in HomeCandidates(username))
{
if (!IsFarEnoughFromHomes(candidate.q, candidate.r, homeLocations))
{
continue;
}
var existingLocation = await GetAsync(candidate.q, candidate.r);
if (existingLocation is not null && !string.IsNullOrWhiteSpace(existingLocation.OwnerUsername))
{
continue;
}
var home = await TryCreateHomeAsync(username, candidate.q, candidate.r);
if (home is not null)
{
return home;
}
homeLocations = await _locations.Find(x => x.Kind == "Home").ToListAsync();
}
throw new InvalidOperationException("No valid home location is available in the configured search radius.");
}
public async Task<List<LocationResponse>> GetVisibleAsync(string username, int? visibilityDepth = null)
{
var ownedLocations = await _locations.Find(x => x.OwnerUsername == username).ToListAsync();
if (ownedLocations.Count == 0)
{
ownedLocations.Add(await AssignHomeAsync(username));
}
var depth = Math.Max(0, visibilityDepth ?? _visibilityDepth);
var visibleCoordinates = new HashSet<(int q, int r)>();
foreach (var owned in ownedLocations)
{
foreach (var coordinate in HexRange(owned.Q, owned.R, depth))
{
visibleCoordinates.Add(coordinate);
}
}
var minQ = visibleCoordinates.Min(x => x.q);
var maxQ = visibleCoordinates.Max(x => x.q);
var minR = visibleCoordinates.Min(x => x.r);
var maxR = visibleCoordinates.Max(x => x.r);
var existingLocations = await GetRangeAsync(minQ, maxQ, minR, maxR);
var existingByCoordinate = existingLocations.ToDictionary(x => (x.Q, x.R), x => x);
return visibleCoordinates
.OrderBy(x => x.q)
.ThenBy(x => x.r)
.Select(coordinate =>
existingByCoordinate.TryGetValue(coordinate, out var existing)
? LocationResponse.FromLocation(existing)
: LocationResponse.FromCoordinate(coordinate.q, coordinate.r))
.ToList();
}
public async Task<LocationResponse?> GetVisibleLocationAsync(string username, int q, int r)
{
if (!await CanSeeAsync(username, q, r))
{
return null;
}
var existing = await GetAsync(q, r);
return existing is null
? LocationResponse.FromCoordinate(q, r)
: LocationResponse.FromLocation(existing);
}
public async Task<bool> CanSeeAsync(string username, int q, int r)
{
var ownedLocations = await _locations.Find(x => x.OwnerUsername == username).ToListAsync();
if (ownedLocations.Count == 0)
{
return false;
}
return ownedLocations.Any(owned => HexDistance(owned.Q, owned.R, q, r) <= _visibilityDepth);
}
private async Task<Location?> TryCreateHomeAsync(string username, int q, int r)
{
var now = DateTime.UtcNow;
var update = Builders<Location>.Update
.SetOnInsert(x => x.CreatedAtUtc, now)
.Set(x => x.Terrain, "Command")
.Set(x => x.Kind, "Home")
.Set(x => x.OwnerUsername, username)
.Set(x => x.Label, $"{username}'s Home")
.Set(x => x.Level, 1)
.Set(x => x.HomeAssignedAtUtc, now)
.Set(x => x.UpdatedAtUtc, now);
try
{
return await _locations.FindOneAndUpdateAsync(
x => x.Q == q && x.R == r && (x.OwnerUsername == null || x.OwnerUsername == username),
update,
new FindOneAndUpdateOptions<Location>
{
IsUpsert = true,
ReturnDocument = ReturnDocument.After
});
}
catch (MongoWriteException)
{
return await GetHomeAsync(username);
}
catch (MongoCommandException)
{
return await GetHomeAsync(username);
}
}
private IEnumerable<(int q, int r)> HomeCandidates(string username)
{
var preferredDirection = Math.Abs(username.GetHashCode()) % 6;
yield return (0, 0);
for (var radius = _homeMinDistance; radius <= _homeSearchRadius; radius++)
{
foreach (var candidate in HexRing(radius, preferredDirection))
{
yield return candidate;
}
}
}
private static IEnumerable<(int q, int r)> HexRing(int radius, int directionOffset)
{
var directions = new (int q, int r)[]
{
(1, 0),
(1, -1),
(0, -1),
(-1, 0),
(-1, 1),
(0, 1)
};
var q = directions[(4 + directionOffset) % 6].q * radius;
var r = directions[(4 + directionOffset) % 6].r * radius;
for (var side = 0; side < 6; side++)
{
var direction = directions[(side + directionOffset) % 6];
for (var step = 0; step < radius; step++)
{
yield return (q, r);
q += direction.q;
r += direction.r;
}
}
}
private static IEnumerable<(int q, int r)> HexRange(int centerQ, int centerR, int radius)
{
for (var dq = -radius; dq <= radius; dq++)
{
var minDr = Math.Max(-radius, -dq - radius);
var maxDr = Math.Min(radius, -dq + radius);
for (var dr = minDr; dr <= maxDr; dr++)
{
yield return (centerQ + dq, centerR + dr);
}
}
}
private bool IsFarEnoughFromHomes(int q, int r, List<Location> homeLocations) =>
homeLocations.All(home => HexDistance(q, r, home.Q, home.R) >= _homeMinDistance);
private static int HexDistance(int q1, int r1, int q2, int r2)
{
var s1 = -q1 - r1;
var s2 = -q2 - r2;
return Math.Max(Math.Max(Math.Abs(q1 - q2), Math.Abs(r1 - r2)), Math.Abs(s1 - s2));
}
private static string NormalizeTerrain(string terrain)
{
var trimmed = terrain.Trim();
@@ -14,5 +14,10 @@
"Issuer": "territory-autobattler",
"Audience": "territory-autobattler-auth-api"
},
"World": {
"HomeMinDistance": 8,
"HomeSearchRadius": 80,
"VisibilityDepth": 3
},
"AllowedHosts": "*"
}
@@ -14,5 +14,10 @@
"Issuer": "territory-autobattler",
"Audience": "territory-autobattler-auth-api"
},
"World": {
"HomeMinDistance": 8,
"HomeSearchRadius": 80,
"VisibilityDepth": 3
},
"AllowedHosts": "*"
}
@@ -8,3 +8,6 @@ data:
MongoDB__DatabaseName: territory-autobattler
Jwt__Issuer: territory-autobattler
Jwt__Audience: territory-autobattler-auth-api
World__HomeMinDistance: "8"
World__HomeSearchRadius: "80"
World__VisibilityDepth: "3"
@@ -13,7 +13,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("LocationsApi")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+9207fe36a3c0b115c7ad6fd3d148dd6ca2094375")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+dd151c5af16980e9e4d4fdc393f96a5b9d53747c")]
[assembly: System.Reflection.AssemblyProductAttribute("LocationsApi")]
[assembly: System.Reflection.AssemblyTitleAttribute("LocationsApi")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
@@ -1 +1 @@
b403eb203fdf58aebecc5d945b0abb24d1c483301d0672168baaeecc45119276
07cb9b4cb320a27b1959ac08682032c59f0c6e7d3b6a25bd9b63a8ad902bb4b8
@@ -1 +1 @@
{"GlobalPropertiesHash":"w7mmSUeJy3EjTBKOw9i7DTiAbSD0tO4IicJPYORYxAc=","FingerprintPatternsHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["u6f8oDnbzrimiNBDeJHAHevNSr3MAeMqrDg9p7nyxOE=","5iNsYgTJ7UV3yFd6ljv5hsMAG441CEYl/SrWmWfNtXc=","9WHrt72HnJ5zi2iiKthnOFXj64buUk7n8VJpIRqlWUs=","H8SD/zn5odFOCH8IKkG0GxtQdpeP5bXD\u002BPPQyk8g9Tk=","TQpYeFHS2qgMU/ptlMMsuvxql0RCmqmAhUrgJv\u002BHh9A=","AubgranklSc/XcuzOmTCVcnLDHalwHX672pKnqMGGH0=","bRq\u002BCEv/2oksFWboXE/6CRNvoz7a//ybXVNKledXUhc=","aIYHzbHzogvauDRzzlcHDs54OxiLFduH5H6wBtjQMKk="],"CachedAssets":{},"CachedCopyCandidates":{}}
{"GlobalPropertiesHash":"w7mmSUeJy3EjTBKOw9i7DTiAbSD0tO4IicJPYORYxAc=","FingerprintPatternsHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["tA5IfBScz2\u002Bs6UlS6C510zVinusuY1Dx2ibHDM/cVfc=","5iNsYgTJ7UV3yFd6ljv5hsMAG441CEYl/SrWmWfNtXc=","TEE2WzAfi463TJjsb9i\u002BmdcaoLigQZZABaffYxHGL7s=","H8SD/zn5odFOCH8IKkG0GxtQdpeP5bXD\u002BPPQyk8g9Tk=","TQpYeFHS2qgMU/ptlMMsuvxql0RCmqmAhUrgJv\u002BHh9A=","AubgranklSc/XcuzOmTCVcnLDHalwHX672pKnqMGGH0=","vddsqaGugGe8mVuoS05nGn\u002BxiJBwaOAoWwVS4\u002BKYa8A=","yZPWhf5bVqIwacMkpdMp2P17I1egbzHFZlg/l3vFlKk="],"CachedAssets":{},"CachedCopyCandidates":{}}
@@ -1 +1 @@
{"GlobalPropertiesHash":"GFtoKVzjVKzTL2sykHz39KP6vofGVDcii0x1tMRS6e8=","FingerprintPatternsHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["u6f8oDnbzrimiNBDeJHAHevNSr3MAeMqrDg9p7nyxOE=","5iNsYgTJ7UV3yFd6ljv5hsMAG441CEYl/SrWmWfNtXc=","9WHrt72HnJ5zi2iiKthnOFXj64buUk7n8VJpIRqlWUs=","H8SD/zn5odFOCH8IKkG0GxtQdpeP5bXD\u002BPPQyk8g9Tk=","TQpYeFHS2qgMU/ptlMMsuvxql0RCmqmAhUrgJv\u002BHh9A=","AubgranklSc/XcuzOmTCVcnLDHalwHX672pKnqMGGH0=","bRq\u002BCEv/2oksFWboXE/6CRNvoz7a//ybXVNKledXUhc=","aIYHzbHzogvauDRzzlcHDs54OxiLFduH5H6wBtjQMKk="],"CachedAssets":{},"CachedCopyCandidates":{}}
{"GlobalPropertiesHash":"GFtoKVzjVKzTL2sykHz39KP6vofGVDcii0x1tMRS6e8=","FingerprintPatternsHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["tA5IfBScz2\u002Bs6UlS6C510zVinusuY1Dx2ibHDM/cVfc=","5iNsYgTJ7UV3yFd6ljv5hsMAG441CEYl/SrWmWfNtXc=","TEE2WzAfi463TJjsb9i\u002BmdcaoLigQZZABaffYxHGL7s=","H8SD/zn5odFOCH8IKkG0GxtQdpeP5bXD\u002BPPQyk8g9Tk=","TQpYeFHS2qgMU/ptlMMsuvxql0RCmqmAhUrgJv\u002BHh9A=","AubgranklSc/XcuzOmTCVcnLDHalwHX672pKnqMGGH0=","vddsqaGugGe8mVuoS05nGn\u002BxiJBwaOAoWwVS4\u002BKYa8A=","yZPWhf5bVqIwacMkpdMp2P17I1egbzHFZlg/l3vFlKk="],"CachedAssets":{},"CachedCopyCandidates":{}}
@@ -1 +1 @@
{"GlobalPropertiesHash":"igFr7v0EeKBfAjhR7vM0gDd+Q/K60wUiwt+1xe9MQyQ=","FingerprintPatternsHash":"gq3WsqcKBUGTSNle7RKKyXRIwh7M8ccEqOqYvIzoM04=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["u6f8oDnbzrimiNBDeJHAHevNSr3MAeMqrDg9p7nyxOE="],"CachedAssets":{},"CachedCopyCandidates":{}}
{"GlobalPropertiesHash":"igFr7v0EeKBfAjhR7vM0gDd+Q/K60wUiwt+1xe9MQyQ=","FingerprintPatternsHash":"gq3WsqcKBUGTSNle7RKKyXRIwh7M8ccEqOqYvIzoM04=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["tA5IfBScz2\u002Bs6UlS6C510zVinusuY1Dx2ibHDM/cVfc="],"CachedAssets":{},"CachedCopyCandidates":{}}