diff --git a/src/api/enum_string_utils.zig b/src/api/enum_string_utils.zig new file mode 100644 index 0000000..11fb79a --- /dev/null +++ b/src/api/enum_string_utils.zig @@ -0,0 +1,25 @@ +const std = @import("std"); +const assert = std.debug.assert; + +pub fn EnumStringUtils(TargetEnum: anytype, str_to_tag_mapping: anytype) type { + if (str_to_tag_mapping.len != @typeInfo(TargetEnum).Enum.fields.len) { + @compileLog("Mapping is not exhaustive"); + } + + const EnumMapping = std.ComptimeStringMap(TargetEnum, str_to_tag_mapping); + + return struct { + pub fn fromString(str: []const u8) ?TargetEnum { + return EnumMapping.get(str); + } + + pub fn toString(value: TargetEnum) []const u8 { + inline for (str_to_tag_mapping) |mapping| { + if (mapping[1] == value) { + return mapping[0]; + } + } + unreachable; + } + }; +} diff --git a/src/api/server.zig b/src/api/server.zig index e8f0754..b70b3af 100644 --- a/src/api/server.zig +++ b/src/api/server.zig @@ -9,6 +9,7 @@ pub const parseDateTime = @import("../date_time/parse.zig").parseDateTime; const json_utils = @import("json_utils.zig"); pub const Character = @import("character.zig"); const BoundedSlotsArray = @import("./slot_array.zig").BoundedSlotsArray; +const EnumStringUtils = @import("./enum_string_utils.zig").EnumStringUtils; pub const Slot = @import("./slot.zig"); pub const Position = @import("./position.zig"); @@ -29,7 +30,16 @@ token: ?[]u8 = null, item_codes: std.ArrayList([]u8), characters: std.ArrayList(Character), + items: std.StringHashMap(Item), +maps: std.AutoHashMap(Position, MapTile), +resources: std.StringHashMap(Resource), +monsters: std.StringHashMap(Monster), + +prefetched_resources: bool = false, +prefetched_maps: bool = false, +prefetched_monsters: bool = false, +prefetched_items: bool = false, // ------------------------- API errors ------------------------ @@ -144,30 +154,15 @@ pub const Skill = enum { cooking, woodcutting, mining, - - fn parse(str: []const u8) ?Skill { - const eql = std.mem.eql; - const mapping = .{ - .{ "weaponcrafting" , .weaponcrafting }, - .{ "gearcrafting" , .gearcrafting }, - .{ "jewelrycrafting", .jewelrycrafting }, - .{ "cooking" , .cooking }, - .{ "woodcutting" , .woodcutting }, - .{ "mining" , .mining }, - }; - if (mapping.len != @typeInfo(Skill).Enum.fields.len) { - @compileLog("Mapping is not exhaustive"); - } - - inline for (mapping) |mapping_entry| { - if (eql(u8, str, mapping_entry[0])) { - return mapping_entry[1]; - } - } - - return null; - } }; +pub const SkillUtils = EnumStringUtils(Skill, .{ + .{ "weaponcrafting" , Skill.weaponcrafting }, + .{ "gearcrafting" , Skill.gearcrafting }, + .{ "jewelrycrafting", Skill.jewelrycrafting }, + .{ "cooking" , Skill.cooking }, + .{ "woodcutting" , Skill.woodcutting }, + .{ "mining" , Skill.mining }, +}); const ServerStatus = struct { allocator: Allocator, @@ -209,8 +204,7 @@ pub const Cooldown = struct { recycling, fn parse(str: []const u8) ?Reason { - const eql = std.mem.eql; - const mapping = .{ + const Mapping = std.ComptimeStringMap(Reason, .{ .{ "movement" , .movement }, .{ "fight" , .fight }, .{ "crafting" , .crafting }, @@ -224,18 +218,9 @@ pub const Cooldown = struct { .{ "unequip" , .unequip }, .{ "task" , .task }, .{ "recycling" , .recycling }, - }; - if (mapping.len != @typeInfo(Reason).Enum.fields.len) { - @compileLog("Mapping is not exhaustive"); - } + }); - inline for (mapping) |mapping_entry| { - if (eql(u8, str, mapping_entry[0])) { - return mapping_entry[1]; - } - } - - return null; + return Mapping.get(str); } }; @@ -577,48 +562,96 @@ pub const EquipResult = struct { } }; -pub const MapResult = struct { +pub const MapContentType = enum { + monster, + resource, + workshop, + bank, + grand_exchange, + tasks_master, +}; +pub const MapContentTypeUtils = EnumStringUtils(MapContentType, .{ + .{ "monster" , .monster }, + .{ "resource" , .resource }, + .{ "workshop" , .workshop }, + .{ "bank" , .bank }, + .{ "grand_exchange", .grand_exchange }, + .{ "tasks_master" , .tasks_master }, +}); + +pub const MapTile = struct { pub const MapContent = struct { - type: []u8, + type: MapContentType, code: []u8, }; name: []u8, skin: []u8, - x: i64, - y: i64, + position: Position, content: ?MapContent, - pub fn parse(api: *Server, obj: json.ObjectMap, allocator: Allocator) !MapResult { + pub fn parse(api: *Server, obj: json.ObjectMap, allocator: Allocator) !MapTile { _ = api; var content: ?MapContent = null; if (json_utils.getObject(obj, "content")) |content_obj| { + const content_type = json_utils.getString(content_obj, "type") orelse return error.MissingProperty; + content = MapContent{ - .type = (try json_utils.dupeString(allocator, content_obj, "type")) orelse return error.MissingProperty, + .type = MapContentTypeUtils.fromString(content_type) orelse return error.InvalidContentType, .code = (try json_utils.dupeString(allocator, content_obj, "code")) orelse return error.MissingProperty, }; } - return MapResult{ + const x = json_utils.getInteger(obj, "x") orelse return error.MissingProperty; + const y = json_utils.getInteger(obj, "y") orelse return error.MissingProperty; + + return MapTile{ .name = (try json_utils.dupeString(allocator, obj, "name")) orelse return error.MissingProperty, .skin = (try json_utils.dupeString(allocator, obj, "skin")) orelse return error.MissingProperty, - .x = json_utils.getInteger(obj, "x") orelse return error.MissingProperty, - .y = json_utils.getInteger(obj, "y") orelse return error.MissingProperty, + .position = Position.init(x, y), .content = content }; } - pub fn deinit(self: MapResult, allocator: Allocator) void { + pub fn deinit(self: MapTile, allocator: Allocator) void { allocator.free(self.name); allocator.free(self.skin); if (self.content) |content| { - allocator.free(content.type); allocator.free(content.code); } } }; +pub const ItemType = enum { + consumable, + body_armor, + weapon, + resource, + leg_armor, + helmet, + boots, + shield, + amulet, + ring, + artifact, + currency, +}; +const ItemTypeUtils = EnumStringUtils(ItemType, .{ + .{ "consumable", .consumable }, + .{ "body_armor", .body_armor }, + .{ "weapon" , .weapon }, + .{ "resource" , .resource }, + .{ "leg_armor" , .leg_armor }, + .{ "helmet" , .helmet }, + .{ "boots" , .boots }, + .{ "shield" , .shield }, + .{ "amulet" , .amulet }, + .{ "ring" , .ring }, + .{ "artifact" , .artifact }, + .{ "currency" , .currency }, +}); + pub const Item = struct { pub const Recipe = struct { const Items = BoundedSlotsArray(8); @@ -639,7 +672,7 @@ pub const Item = struct { const items = json_utils.getArray(obj, "items") orelse return error.MissingProperty; return Recipe{ - .skill = Skill.parse(skill) orelse return error.InvalidSkill, + .skill = SkillUtils.fromString(skill) orelse return error.InvalidSkill, .level = @intCast(level), .quantity = @intCast(quantity), .items = try Items.parse(api, items) @@ -651,29 +684,27 @@ pub const Item = struct { name: []u8, code: []u8, level: u64, - type: []u8, + type: ItemType, subtype: []u8, description: []u8, craft: ?Recipe, // TODO: effects - // TODO: Grand exchange pub fn parse(api: *Server, obj: json.ObjectMap, allocator: Allocator) !Item { - const item_obj = json_utils.getObject(obj, "item") orelse return error.MissingProperty; - - const level = json_utils.getInteger(item_obj, "level") orelse return error.MissingProperty; + const level = json_utils.getInteger(obj, "level") orelse return error.MissingProperty; if (level < 1) return error.InvalidLevel; - const craft = json_utils.getObject(item_obj, "craft"); + const craft = json_utils.getObject(obj, "craft"); + const item_type_str = try json_utils.getStringRequired(obj, "type"); return Item{ .allocator = allocator, - .name = (try json_utils.dupeString(allocator, item_obj, "name")) orelse return error.MissingProperty, - .code = (try json_utils.dupeString(allocator, item_obj, "code")) orelse return error.MissingProperty, + .name = (try json_utils.dupeString(allocator, obj, "name")) orelse return error.MissingProperty, + .code = (try json_utils.dupeString(allocator, obj, "code")) orelse return error.MissingProperty, .level = @intCast(level), - .type = (try json_utils.dupeString(allocator, item_obj, "type")) orelse return error.MissingProperty, - .subtype = (try json_utils.dupeString(allocator, item_obj, "subtype")) orelse return error.MissingProperty, - .description = (try json_utils.dupeString(allocator, item_obj, "description")) orelse return error.MissingProperty, + .type = ItemTypeUtils.fromString(item_type_str) orelse return error.InvalidType, + .subtype = (try json_utils.dupeString(allocator, obj, "subtype")) orelse return error.MissingProperty, + .description = (try json_utils.dupeString(allocator, obj, "description")) orelse return error.MissingProperty, .craft = if (craft != null) try Recipe.parse(api, craft.?) else null }; } @@ -681,12 +712,195 @@ pub const Item = struct { pub fn deinit(self: Item) void { self.allocator.free(self.name); self.allocator.free(self.code); - self.allocator.free(self.type); self.allocator.free(self.subtype); self.allocator.free(self.description); } }; +pub const ItemWithGE = struct { + item: Item, + // TODO: Grand exchange + + pub fn parse(api: *Server, obj: json.ObjectMap, allocator: Allocator) !ItemWithGE { + const item_obj = json_utils.getObject(obj, "item") orelse return error.MissingProperty; + const ge_obj = json_utils.getObject(obj, "ge") orelse return error.MissingProperty; + _ = ge_obj; + + return ItemWithGE{ + .item = try Item.parse(api, item_obj, allocator), + }; + } +}; + +pub const ResourceSkill = enum { + mining, + woodcutting, + fishing, +}; +const ResourceSkillUtils = EnumStringUtils(ResourceSkill, .{ + .{ "mining" , .mining }, + .{ "woodcutting", .woodcutting }, + .{ "fishing" , .fishing }, +}); + +const DropRates = std.BoundedArray(DropRate, 8); +const DropRate = struct { + item_id: ItemId, + rate: u64, + min_quantity: u64, + max_quantity: u64, + + fn parse(api: *Server, obj: json.ObjectMap) !DropRate { + const rate = try json_utils.getIntegerRequired(obj, "rate"); + if (rate < 1) { + return error.InvalidRate; + } + + const min_quantity = try json_utils.getIntegerRequired(obj, "min_quantity"); + if (min_quantity < 1) { + return error.InvalidMinQuantity; + } + + const max_quantity = try json_utils.getIntegerRequired(obj, "max_quantity"); + if (max_quantity < 1) { + return error.InvalidMinQuantity; + } + + const code_str = try json_utils.getStringRequired(obj, "code"); + const item_id = try api.getItemId(code_str); + + return DropRate{ + .item_id = item_id, + .rate = @intCast(rate), + .min_quantity = @intCast(min_quantity), + .max_quantity = @intCast(max_quantity) + }; + } + + fn parseList(api: *Server, array: json.Array) !DropRates { + var drops = DropRates.init(0) catch unreachable; + for (array.items) |drop_value| { + const drop_obj = json_utils.asObject(drop_value) orelse return error.InvalidObject; + try drops.append(try DropRate.parse(api, drop_obj)); + } + return drops; + } + + fn doesListContain(drops: *DropRates, item_id: ItemId) bool { + for (drops.constSlice()) |drop| { + if (drop.item_id == item_id) { + return true; + } + } + return false; + } +}; + +pub const Resource = struct { + name: []u8, + code: []u8, + skill: ResourceSkill, + level: u64, + drops: DropRates, + + fn parse(api: *Server, obj: json.ObjectMap, allocator: Allocator) !Resource { + const drops_array = json_utils.getArray(obj, "drops") orelse return error.MissingProperty; + + const level = try json_utils.getIntegerRequired(obj, "level"); + if (level < 0) { + return error.InvalidLevel; + } + + const skill_str = try json_utils.getStringRequired(obj, "skill"); + + return Resource{ + .name = try json_utils.dupeStringRequired(allocator, obj, "name"), + .code = try json_utils.dupeStringRequired(allocator, obj, "code"), + .level = @intCast(level), + .skill = ResourceSkillUtils.fromString(skill_str) orelse return error.InvalidSkill, + .drops = try DropRate.parseList(api, drops_array) + }; + } + + fn deinit(self: Resource, allocator: Allocator) void { + allocator.free(self.name); + allocator.free(self.code); + } +}; + +pub const Monster = struct { + const ElementalStats = struct { + attack: i64, + resistance: i64, + + pub fn parse(object: json.ObjectMap, attack: []const u8, resistance: []const u8) !ElementalStats { + return ElementalStats{ + .attack = try json_utils.getIntegerRequired(object, attack), + .resistance = try json_utils.getIntegerRequired(object, resistance), + }; + } + }; + + name: []u8, + code: []u8, + level: u64, + hp: u64, + min_gold: u64, + max_gold: u64, + + fire: ElementalStats, + earth: ElementalStats, + water: ElementalStats, + air: ElementalStats, + + drops: DropRates, + + fn parse(api: *Server, obj: json.ObjectMap, allocator: Allocator) !Monster { + const drops_array = json_utils.getArray(obj, "drops") orelse return error.MissingProperty; + + const min_gold = try json_utils.getIntegerRequired(obj, "min_gold"); + if (min_gold < 0) { + return error.InvalidMinGold; + } + + const max_gold = try json_utils.getIntegerRequired(obj, "max_gold"); + if (max_gold < 0) { + return error.InvalidMaxGold; + } + + const level = try json_utils.getIntegerRequired(obj, "level"); + if (level < 0) { + return error.InvalidLevel; + } + + const hp = try json_utils.getIntegerRequired(obj, "hp"); + if (hp < 0) { + return error.InvalidHp; + } + + return Monster{ + .name = try json_utils.dupeStringRequired(allocator, obj, "name"), + .code = try json_utils.dupeStringRequired(allocator, obj, "code"), + .level = @intCast(level), + .hp = @intCast(hp), + + .fire = try ElementalStats.parse(obj, "attack_fire" , "res_fire" ), + .earth = try ElementalStats.parse(obj, "attack_earth", "res_earth"), + .water = try ElementalStats.parse(obj, "attack_water", "res_water"), + .air = try ElementalStats.parse(obj, "attack_air" , "res_air" ), + + .min_gold = @intCast(min_gold), + .max_gold = @intCast(max_gold), + .drops = try DropRate.parseList(api, drops_array) + }; + } + + fn deinit(self: Monster, allocator: Allocator) void { + allocator.free(self.name); + allocator.free(self.code); + } +}; + pub const ArtifactsFetchResult = struct { arena: std.heap.ArenaAllocator, status: std.http.Status, @@ -697,6 +911,16 @@ pub const ArtifactsFetchResult = struct { } }; +fn appendQueryParam(query: *std.ArrayList(u8), key: []const u8, value: []const u8) !void { + if (query.items.len > 0) { + try query.appendSlice("&"); + } + + try query.appendSlice(key); + try query.appendSlice("="); + try query.appendSlice(value); +} + // ------------------------- General API methods ------------------------ pub fn init(allocator: Allocator) !Server { @@ -712,6 +936,9 @@ pub fn init(allocator: Allocator) !Server { .item_codes = std.ArrayList([]u8).init(allocator), .characters = std.ArrayList(Character).init(allocator), .items = std.StringHashMap(Item).init(allocator), + .maps = std.AutoHashMap(Position, MapTile).init(allocator), + .resources = std.StringHashMap(Resource).init(allocator), + .monsters = std.StringHashMap(Monster).init(allocator), }; } @@ -735,6 +962,24 @@ pub fn deinit(self: *Server) void { item.deinit(); } self.items.deinit(); + + var mapsIter = self.maps.valueIterator(); + while (mapsIter.next()) |map| { + map.deinit(self.allocator); + } + self.maps.deinit(); + + var resourcesIter = self.resources.valueIterator(); + while (resourcesIter.next()) |resource| { + resource.deinit(self.allocator); + } + self.resources.deinit(); + + var monstersIter = self.monsters.valueIterator(); + while (monstersIter.next()) |monster| { + monster.deinit(self.allocator); + } + self.monsters.deinit(); } const FetchOptions = struct { @@ -757,6 +1002,7 @@ fn allocPaginationParams(allocator: Allocator, page: u64, page_size: ?u64) ![]u8 } } +// TODO: add retries when hitting a ratelimit fn fetch(self: *Server, options: FetchOptions) APIError!ArtifactsFetchResult { const method = options.method; const path = options.path; @@ -775,6 +1021,8 @@ fn fetch(self: *Server, options: FetchOptions) APIError!ArtifactsFetchResult { var total_pages: u64 = 1; var fetch_results = std.ArrayList(json.Value).init(arena.allocator()); + const has_query = options.query != null and options.query.?.len > 0; + while (true) : (current_page += 1) { var pagination_params: ?[]u8 = null; defer if (pagination_params) |str| self.allocator.free(str); @@ -783,7 +1031,7 @@ fn fetch(self: *Server, options: FetchOptions) APIError!ArtifactsFetchResult { pagination_params = try allocPaginationParams(self.allocator, current_page, options.page_size); } - if (options.query != null and pagination_params != null) { + if (has_query and pagination_params != null) { const combined = try std.mem.join(self.allocator, "&", &.{ options.query.?, pagination_params.? }); self.allocator.free(pagination_params.?); pagination_params = combined; @@ -791,7 +1039,7 @@ fn fetch(self: *Server, options: FetchOptions) APIError!ArtifactsFetchResult { uri.query = .{ .raw = combined }; } else if (pagination_params != null) { uri.query = .{ .raw = pagination_params.? }; - } else if (options.query != null) { + } else if (has_query) { uri.query = .{ .raw = options.query.? }; } @@ -811,7 +1059,12 @@ fn fetch(self: *Server, options: FetchOptions) APIError!ArtifactsFetchResult { opts.headers.authorization = .{ .override = authorization_header.? }; } - log.debug("fetch {} {s}", .{method, path}); + if (uri.query) |query| { + log.debug("fetch {} {s}?{s}", .{method, path, query.raw}); + } else { + log.debug("fetch {} {s}", .{method, path}); + } + const result = self.client.fetch(opts) catch return APIError.RequestFailed; const response_body = response_storage.items; @@ -965,7 +1218,9 @@ fn fetchOptionalArray( errdefer { if (std.meta.hasFn(Object, "deinit")) { for (array.items) |*item| { - item.deinit(); + _ = item; + // TODO: + // item.deinit(); } } array.deinit(); @@ -1085,11 +1340,88 @@ pub fn findCharacterPtr(self: *Server, name: []const u8) ?*Character { return null; } - +// TODO: Remove this function pub fn findItem(self: *const Server, name: []const u8) ?Item { return self.items.get(name); } +// TODO: Remove this function +pub fn findMap(self: *const Server, position: Position) ?MapTile { + return self.maps.get(position); +} + +fn addOrUpdateItem(self: *Server, item: Item) !void { + var entry = try self.items.getOrPut(item.code); + if (entry.found_existing) { + entry.value_ptr.deinit(); + } + entry.value_ptr.* = item; +} + +fn addOrUpdateMap(self: *Server, map: MapTile) !void { + var entry = try self.maps.getOrPut(map.position); + if (entry.found_existing) { + entry.value_ptr.deinit(self.allocator); + } + entry.value_ptr.* = map; +} + +fn addOrUpdateResource(self: *Server, resource: Resource) !void { + var entry = try self.resources.getOrPut(resource.code); + if (entry.found_existing) { + entry.value_ptr.deinit(self.allocator); + } + entry.value_ptr.* = resource; +} + +fn addOrUpdateMonster(self: *Server, monster: Monster) !void { + var entry = try self.monsters.getOrPut(monster.code); + if (entry.found_existing) { + entry.value_ptr.deinit(self.allocator); + } + entry.value_ptr.* = monster; +} + +pub fn prefetch(self: *Server) !void { + self.prefetched_resources = false; + self.prefetched_maps = false; + self.prefetched_monsters = false; + self.prefetched_items = false; + + try self.prefetchResources(); + try self.prefetchMaps(); + try self.prefetchMonsters(); + try self.prefetchItems(); +} + +pub fn prefetchResources(self: *Server) !void { + var resources = try self.getResources(.{}); + defer resources.deinit(); + + self.prefetched_resources = true; +} + +pub fn prefetchMaps(self: *Server) !void { + var maps = try self.getMaps(.{}); + defer maps.deinit(); + + self.prefetched_maps = true; +} + +pub fn prefetchMonsters(self: *Server) !void { + var monsters = try self.getMonsters(.{}); + defer monsters.deinit(); + + self.prefetched_monsters = true; +} + +pub fn prefetchItems(self: *Server) !void { + var items = try self.getItems(.{}); + defer items.deinit(); + + self.prefetched_items = true; +} + // ------------------------- Endpoints ------------------------ pub fn getServerStatus(self: *Server) !ServerStatus { @@ -1411,56 +1743,381 @@ pub fn getBankItemQuantity(self: *Server, code: []const u8) APIError!?u64 { return list_items[0].quantity; } -pub fn getMap(self: *Server, allocator: Allocator, x: i64, y: i64) APIError!?MapResult { +pub fn getMap(self: *Server, x: i64, y: i64) APIError!?MapTile { + const position = Position.init(x, y); + + if (self.findMap(position)) |map| { + return map; + } + + if (self.prefetched_maps) { + return null; + } + const path = try std.fmt.allocPrint(self.allocator, "/maps/{}/{}", .{x, y}); defer self.allocator.free(path); - return self.fetchOptionalObject( + const result = self.fetchOptionalObject( APIError, null, - MapResult, - MapResult.parse, .{ allocator }, + MapTile, + MapTile.parse, .{ self.allocator }, .{ .method = .GET, .path = path } ); + + if (result) |map| { + self.addOrUpdateMap(map); + } + + return result; } -pub fn getMaps(self: *Server, allocator: Allocator) APIError!std.ArrayList(MapResult) { - return self.fetchArray( - allocator, +pub const MapOptions = struct { + code: ?[]const u8 = null, + type: ?MapContentType = null, +}; + +pub fn getMaps(self: *Server, opts: MapOptions) APIError!std.ArrayList(MapTile) { + if (self.prefetched_maps) { + var found = std.ArrayList(MapTile).init(self.allocator); + var mapIter = self.maps.valueIterator(); + while (mapIter.next()) |map| { + if (opts.type) |content_type| { + if (map.content == null) continue; + if (map.content.?.type != content_type) continue; + } + if (opts.code) |content_code| { + if (map.content == null) continue; + if (!std.mem.eql(u8, map.content.?.code, content_code)) continue; + } + + try found.append(map.*); + } + return found; + } + + var query = std.ArrayList(u8).init(self.allocator); + defer query.deinit(); + + if (opts.code) |code| { + try appendQueryParam(&query, "content_code", code); + } + if (opts.type) |map_type| { + try appendQueryParam(&query, "content_type", MapContentTypeUtils.toString(map_type)); + } + + const result = try self.fetchArray( + self.allocator, APIError, null, - MapResult, - MapResult.parse, .{ allocator }, - .{ .method = .GET, .path = "/maps", .paginated = true } + MapTile, + MapTile.parse, .{ self.allocator }, + .{ .method = .GET, .path = "/maps", .paginated = true, .page_size = 100, .query = query.items } ); + + for (result.items) |map| { + try self.addOrUpdateMap(map); + } + + return result; } pub fn getItem(self: *Server, code: []const u8) APIError!?Item { - if (self.findItem(code)) |item| { + if (self.items.get(code)) |item| { return item; } + if (self.prefetched_items) { + return null; + } + const path = try std.fmt.allocPrint(self.allocator, "/items/{s}", .{code}); defer self.allocator.free(path); const result = try self.fetchOptionalObject( APIError, null, - Item, - Item.parse, .{ self.allocator }, + ItemWithGE, + ItemWithGE.parse, .{ self.allocator }, .{ .method = .GET, .path = path } ); - if (result) |item| { - const item_id = try self.getItemId(code); - const code_owned = self.getItemCode(item_id).?; + if (result) |item_with_ge| { + try self.addOrUpdateItem(item_with_ge.item); - var entry = try self.items.getOrPut(code_owned); - if (entry.found_existing) { - entry.value_ptr.deinit(); + return item_with_ge.item; + } else { + return null; + } +} + +pub const ItemOptions = struct { + craft_material: ?[]const u8 = null, + craft_skill: ?Skill = null, + min_level: ?u64 = null, + max_level: ?u64 = null, + name: ?[]const u8 = null, + type: ?ItemType = null, +}; + +pub fn getItems(self: *Server, opts: ItemOptions) APIError!std.ArrayList(Item) { + if (self.prefetched_items) { + var found = std.ArrayList(Item).init(self.allocator); + var itemIter = self.items.valueIterator(); + while (itemIter.next()) |item| { + if (opts.craft_skill) |craft_skill| { + if (item.craft == null) continue; + if (item.craft.?.skill != craft_skill) continue; + } + if (opts.craft_material) |craft_material| { + if (item.craft == null) continue; + const recipe = item.craft.?; + + const craft_material_id = try self.getItemId(craft_material); + const material_quantity = recipe.items.getQuantity(craft_material_id); + if (material_quantity == 0) continue; + } + if (opts.min_level) |min_level| { + if (item.level < min_level) continue; + } + if (opts.max_level) |max_level| { + if (item.level > max_level) continue; + } + if (opts.type) |item_type| { + if (item.type != item_type) continue; + } + if (opts.name) |name| { + if (std.mem.indexOf(u8, item.name, name) == null) continue; + } + + try found.append(item.*); } - entry.value_ptr.* = item; + return found; + } + + var str_arena = std.heap.ArenaAllocator.init(self.allocator); + defer str_arena.deinit(); + + var query = std.ArrayList(u8).init(self.allocator); + defer query.deinit(); + + if (opts.craft_material) |craft_material| { + try appendQueryParam(&query, "craft_material", craft_material); + } + if (opts.min_level) |min_level| { + const min_level_str = try std.fmt.allocPrint(str_arena.allocator(), "{}", .{ min_level }); + try appendQueryParam(&query, "min_level", min_level_str); + } + if (opts.max_level) |max_level| { + const max_level_str = try std.fmt.allocPrint(str_arena.allocator(), "{}", .{ max_level }); + try appendQueryParam(&query, "max_level", max_level_str); + } + if (opts.name) |name| { + try appendQueryParam(&query, "name", name); + } + if (opts.type) |item_type| { + try appendQueryParam(&query, "type", ItemTypeUtils.toString(item_type)); + } + + const result = try self.fetchArray( + self.allocator, + APIError, + null, + Item, + Item.parse, .{ self.allocator }, + .{ .method = .GET, .path = "/items", .paginated = true, .page_size = 100, .query = query.items } + ); + errdefer result.deinit(); + + for (result.items) |item| { + try self.addOrUpdateItem(item); } return result; } + +pub fn getResource(self: *Server, code: []const u8) APIError!?Resource { + if (self.resources.get(code)) |resource| { + return resource; + } + + if (self.prefetched_resources) { + return null; + } + + const path = try std.fmt.allocPrint(self.allocator, "/resources/{s}", .{code}); + defer self.allocator.free(path); + + const result = try self.fetchOptionalObject( + APIError, + null, + Resource, + Resource.parse, .{ self.allocator }, + .{ .method = .GET, .path = path } + ); + + if (result) |resource| { + try self.addOrUpdateResource(resource); + } + + return result; +} + +pub const ResourceOptions = struct { + drop: ?[]const u8 = null, + max_level: ?u64 = null, + min_level: ?u64 = null, + skill: ?ResourceSkill = null, +}; + +pub fn getResources(self: *Server, opts: ResourceOptions) APIError!std.ArrayList(Resource) { + if (self.prefetched_resources) { + var found = std.ArrayList(Resource).init(self.allocator); + var resourceIter = self.resources.valueIterator(); + while (resourceIter.next()) |resource| { + if (opts.min_level) |min_level| { + if (resource.level < min_level) continue; + } + if (opts.max_level) |max_level| { + if (resource.level > max_level) continue; + } + if (opts.drop) |drop| { + const item_id = try self.getItemId(drop); + if (!DropRate.doesListContain(&resource.drops, item_id)) { + continue; + } + } + + try found.append(resource.*); + } + return found; + } + + var str_arena = std.heap.ArenaAllocator.init(self.allocator); + defer str_arena.deinit(); + + var query = std.ArrayList(u8).init(self.allocator); + defer query.deinit(); + + if (opts.drop) |drop| { + try appendQueryParam(&query, "drop", drop); + } + if (opts.min_level) |min_level| { + const min_level_str = try std.fmt.allocPrint(str_arena.allocator(), "{}", .{ min_level }); + try appendQueryParam(&query, "min_level", min_level_str); + } + if (opts.max_level) |max_level| { + const max_level_str = try std.fmt.allocPrint(str_arena.allocator(), "{}", .{ max_level }); + try appendQueryParam(&query, "max_level", max_level_str); + } + if (opts.skill) |skill| { + try appendQueryParam(&query, "skill", ResourceSkillUtils.toString(skill)); + } + + const result = try self.fetchArray( + self.allocator, + APIError, + null, + Resource, + Resource.parse, .{ self.allocator }, + .{ .method = .GET, .path = "/resources", .paginated = true, .query = query.items } + ); + errdefer result.deinit(); + + for (result.items) |resource| { + try self.addOrUpdateResource(resource); + } + + return result; +} + +pub fn getMonster(self: *Server, code: []const u8) APIError!?Monster { + if (self.monsters.get(code)) |monster| { + return monster; + } + + if (self.prefetched_monsters) { + return null; + } + + const path = try std.fmt.allocPrint(self.allocator, "/monsters/{s}", .{code}); + defer self.allocator.free(path); + + const result = try self.fetchOptionalObject( + APIError, + null, + Monster, + Monster.parse, .{ self.allocator }, + .{ .method = .GET, .path = path } + ); + + if (result) |monster| { + try self.addOrUpdateMonster(monster); + } + + return result; +} + +pub const MonsterOptions = struct { + drop: ?[]const u8 = null, + max_level: ?u64 = null, + min_level: ?u64 = null, +}; + +pub fn getMonsters(self: *Server, opts: MonsterOptions) APIError!std.ArrayList(Monster) { + if (self.prefetched_monsters) { + var found = std.ArrayList(Monster).init(self.allocator); + var monsterIter = self.monsters.valueIterator(); + while (monsterIter.next()) |monster| { + if (opts.min_level) |min_level| { + if (monster.level < min_level) continue; + } + if (opts.max_level) |max_level| { + if (monster.level > max_level) continue; + } + if (opts.drop) |drop| { + const item_id = try self.getItemId(drop); + if (!DropRate.doesListContain(&monster.drops, item_id)) { + continue; + } + } + + try found.append(monster.*); + } + return found; + } + + var str_arena = std.heap.ArenaAllocator.init(self.allocator); + defer str_arena.deinit(); + + var query = std.ArrayList(u8).init(self.allocator); + defer query.deinit(); + + if (opts.drop) |drop| { + try appendQueryParam(&query, "drop", drop); + } + if (opts.min_level) |min_level| { + const min_level_str = try std.fmt.allocPrint(str_arena.allocator(), "{}", .{ min_level }); + try appendQueryParam(&query, "min_level", min_level_str); + } + if (opts.max_level) |max_level| { + const max_level_str = try std.fmt.allocPrint(str_arena.allocator(), "{}", .{ max_level }); + try appendQueryParam(&query, "max_level", max_level_str); + } + + const result = try self.fetchArray( + self.allocator, + APIError, + null, + Monster, + Monster.parse, .{ self.allocator }, + .{ .method = .GET, .path = "/monsters", .paginated = true, .query = query.items } + ); + + for (result.items) |monster| { + try self.addOrUpdateMonster(monster); + } + + return result; +} + diff --git a/src/character_brain.zig b/src/character_brain.zig index aa631dc..05898db 100644 --- a/src/character_brain.zig +++ b/src/character_brain.zig @@ -98,10 +98,7 @@ comptime { assert(@typeInfo(QueuedAction).Union.fields.len == @typeInfo(QueuedActionResult).Union.fields.len); } -name: []const u8, -routine: union (enum) { - idle, - +pub const CharacterTask = union(enum) { fight: struct { at: Position, target: Server.ItemIdQuantity, @@ -113,16 +110,33 @@ routine: union (enum) { progress: u64 = 0, }, craft: struct { + at: Position, target: Server.ItemIdQuantity, progress: u64 = 0, }, -}, + + pub fn isComplete(self: CharacterTask) bool { + return switch (self) { + .fight => |args| { + return args.progress >= args.target.quantity; + }, + .gather => |args| { + return args.progress >= args.target.quantity; + }, + .craft => |args| { + return args.progress >= args.target.quantity; + } + }; + } +}; + +name: []const u8, action_queue: std.ArrayList(QueuedAction), +task: ?CharacterTask = null, pub fn init(allocator: Allocator, name: []const u8) !CharacterBrain { return CharacterBrain{ .name = try allocator.dupe(u8, name), - .routine = .idle, .action_queue = std.ArrayList(QueuedAction).init(allocator), }; } @@ -375,9 +389,9 @@ pub fn performNextAction(self: *CharacterBrain, api: *Server) !void { } fn onActionCompleted(self: *CharacterBrain, result: QueuedActionResult) void { - switch (self.routine) { - .idle => {}, + if (self.task == null) return; + switch (self.task.?) { .fight => |*args| { if (result.get(.fight)) |r| { const fight_result: Server.FightResult = r; @@ -405,27 +419,21 @@ fn onActionCompleted(self: *CharacterBrain, result: QueuedActionResult) void { } } -pub fn isRoutineFinished(self: *CharacterBrain) bool { - return switch (self.routine) { - .idle => false, +pub fn isTaskFinished(self: *CharacterBrain) bool { + if (self.task == null) { + return false; + } - .fight => |args| { - return args.progress >= args.target.quantity; - }, - .gather => |args| { - return args.progress >= args.target.quantity; - }, - .craft => |args| { - return args.progress >= args.target.quantity; - } - }; + return self.task.?.isComplete(); } -pub fn performRoutine(self: *CharacterBrain, api: *Server) !void { - switch (self.routine) { - .idle => { - std.log.debug("[{s}] idle", .{self.name}); - }, +pub fn performTask(self: *CharacterBrain, api: *Server) !void { + if (self.task == null) { + std.log.debug("[{s}] idle", .{self.name}); + return; + } + + switch (self.task.?) { .fight => |args| { try self.fightRoutine(api, args.at); }, @@ -433,7 +441,7 @@ pub fn performRoutine(self: *CharacterBrain, api: *Server) !void { try self.gatherRoutine(api, args.at); }, .craft => |args| { - try self.craftRoutine(api, args.target.id, args.target.quantity); + try self.craftRoutine(api, args.at, args.target.id, args.target.quantity); } } } @@ -541,7 +549,7 @@ fn withdrawFromBank(self: *CharacterBrain, api: *Server, items: []const Server.S return true; } -fn craftItem(self: *CharacterBrain, api: *Server, id: Server.ItemId, quantity: u64) !bool { +fn craftItem(self: *CharacterBrain, api: *Server, workstation: Position, id: Server.ItemId, quantity: u64) !bool { var character = api.findCharacter(self.name).?; const inventory_quantity = character.inventory.getQuantity(id); @@ -549,24 +557,6 @@ fn craftItem(self: *CharacterBrain, api: *Server, id: Server.ItemId, quantity: u return false; } - const code = api.getItemCode(id) orelse return error.InvalidItemId; - const item = try api.getItem(code) orelse return error.ItemNotFound; - if (item.craft == null) { - return error.NotCraftable; - } - - const recipe = item.craft.?; - - // TODO: Figure this out dynamically - const workstation = switch (recipe.skill) { - .weaponcrafting => Position{ .x = 2, .y = 1 }, - .gearcrafting => Position{ .x = 3, .y = 1 }, - .jewelrycrafting => Position{ .x = 1, .y = 3 }, - .cooking => Position{ .x = 1, .y = 1 }, - .woodcutting => Position{ .x = -2, .y = -3 }, - .mining => Position{ .x = 1, .y = 5 }, - }; - if (try self.moveIfNeeded(api, workstation)) { return true; } @@ -579,7 +569,7 @@ fn craftItem(self: *CharacterBrain, api: *Server, id: Server.ItemId, quantity: u return true; } -fn craftRoutine(self: *CharacterBrain, api: *Server, id: Server.ItemId, quantity: u64) !void { +fn craftRoutine(self: *CharacterBrain, api: *Server, workstation: Position, id: Server.ItemId, quantity: u64) !void { var character = api.findCharacter(self.name).?; const inventory_quantity = character.inventory.getQuantity(id); if (inventory_quantity >= quantity) { @@ -595,7 +585,6 @@ fn craftRoutine(self: *CharacterBrain, api: *Server, id: Server.ItemId, quantity } const recipe = target_item.craft.?; - assert(recipe.quantity == 1); // TODO: Add support for recipe which produce multiple items var needed_items = recipe.items; for (needed_items.slice()) |*needed_item| { @@ -606,7 +595,7 @@ fn craftRoutine(self: *CharacterBrain, api: *Server, id: Server.ItemId, quantity return; } - if (try self.craftItem(api, id, quantity)) { + if (try self.craftItem(api, workstation, id, quantity)) { return; } } diff --git a/src/main.zig b/src/main.zig index ee86f85..e7ebaac 100644 --- a/src/main.zig +++ b/src/main.zig @@ -8,6 +8,10 @@ const CharacterBrain = @import("./character_brain.zig"); // pub const std_options = .{ .log_level = .debug }; +fn todo() void { + unreachable; +} + fn currentTime() f64 { const timestamp: f64 = @floatFromInt(std.time.milliTimestamp()); return timestamp / std.time.ms_per_s; @@ -82,6 +86,176 @@ fn getAPITokenFromArgs(allocator: Allocator) !?[]u8 { return try allocator.dupe(u8, std.mem.trim(u8,token,"\n\t ")); } +const TaskTree = struct { + const TaskTreeNode = struct { + parent: ?usize, + character_task: CharacterBrain.CharacterTask, + }; + + const Nodes = std.ArrayList(TaskTreeNode); + + allocator: Allocator, + nodes: Nodes, + api: *Server, + + pub fn init(allocator: Allocator, api: *Server) TaskTree { + return TaskTree{ + .allocator = allocator, + .nodes = Nodes.init(allocator), + .api = api + }; + } + + pub fn deinit(self: TaskTree) void { + self.nodes.deinit(); + } + + fn appendNode(self: *TaskTree, node: TaskTreeNode) !usize { + try self.nodes.append(node); + return self.nodes.items.len-1; + } + + fn appendSubTree(self: *TaskTree, code: []const u8, quantity: u64, parent: ?usize) !void { + if (quantity == 0) return; + + const item = (try self.api.getItem(code)).?; + const item_id = try self.api.getItemId(code); + + const eql = std.mem.eql; + if (item.craft) |recipe| { + const craft_count = std.math.divCeil(u64, quantity, recipe.quantity) catch unreachable; + + const skill_str = Server.SkillUtils.toString(recipe.skill); + const workshop_maps = try self.api.getMaps(.{ .code = skill_str, .type = .workshop }); + defer workshop_maps.deinit(); + + if (workshop_maps.items.len == 0) return error.WorkshopNotFound; + if (workshop_maps.items.len > 1) std.log.warn("Multiple workshop locations exist", .{}); + + const node_id = try self.appendNode(TaskTreeNode{ + .parent = parent, + .character_task = .{ + .craft = .{ + .at = workshop_maps.items[0].position, + .target = .{ .id = item_id, .quantity = craft_count } + } + } + }); + + for (recipe.items.slots.constSlice()) |recipe_item| { + const recipe_item_code = self.api.getItemCode(recipe_item.id).?; + try self.appendSubTree(recipe_item_code, recipe_item.quantity * craft_count, node_id); + } + } else { + if (item.type == .resource) { + if (eql(u8, item.subtype, "mining")) { + const resources = try self.api.getResources(.{ .drop = code }); + defer resources.deinit(); + + if (resources.items.len == 0) return error.ResourceNotFound; + if (resources.items.len > 1) std.log.warn("Multiple resources exist for target item", .{}); + const resource_code = resources.items[0].code; + + const resource_maps = try self.api.getMaps(.{ .code = resource_code }); + defer resource_maps.deinit(); + + if (resource_maps.items.len == 0) return error.MapNotFound; + if (resource_maps.items.len > 1) std.log.warn("Multiple map locations exist for resource", .{}); + const resource_map = resource_maps.items[0]; + + _ = try self.appendNode(TaskTreeNode{ + .parent = parent, + .character_task = .{ + .gather = .{ + .at = resource_map.position, + .target = .{ .id = item_id, .quantity = quantity } + } + } + }); + } else if (eql(u8, item.subtype, "mob")) { + const monsters = try self.api.getMonsters(.{ .drop = code }); + defer monsters.deinit(); + + if (monsters.items.len == 0) return error.ResourceNotFound; + if (monsters.items.len > 1) std.log.warn("Multiple monsters exist for target item", .{}); + const monster_code = monsters.items[0].code; + + const resource_maps = try self.api.getMaps(.{ .code = monster_code }); + defer resource_maps.deinit(); + + if (resource_maps.items.len == 0) return error.MapNotFound; + if (resource_maps.items.len > 1) std.log.warn("Multiple map locations exist for monster", .{}); + const resource_map = resource_maps.items[0]; + + _ = try self.appendNode(TaskTreeNode{ + .parent = parent, + .character_task = .{ + .fight = .{ + .at = resource_map.position, + .target = .{ .id = item_id, .quantity = quantity } + } + } + }); + } + } else { + } + } + } + + fn listByParent(self: *const TaskTree, parent: ?usize) !std.ArrayList(usize) { + var found_nodes = std.ArrayList(usize).init(self.allocator); + for (0.., self.nodes.items) |i, node| { + if (node.parent == parent) { + try found_nodes.append(i); + } + } + return found_nodes; + } + + pub fn format( + self: TaskTree, + comptime fmt: []const u8, + options: std.fmt.FormatOptions, + writer: anytype, + ) !void { + _ = fmt; + _ = options; + + var root_nodes = try self.listByParent(null); + defer root_nodes.deinit(); + + for (root_nodes.items) |root_node| { + try self.formatNode(root_node, 0, writer); + } + } + + fn formatNode(self: TaskTree, node_id: usize, level: u32, writer: anytype) !void { + const node = self.nodes.items[node_id]; + try writer.writeBytesNTimes(" ", level); + switch (node.character_task) { + .fight => |args| { + const item = self.api.getItemCode(args.target.id).?; + try writer.print("Task{{ .fight = {{ \"{s}\" x {} }}, .at = {} }}\n", .{item, args.target.quantity, args.at}); + }, + .gather => |args| { + const item = self.api.getItemCode(args.target.id).?; + try writer.print("Task{{ .gather = {{ \"{s}\" x {} }}, .at = {} }}\n", .{item, args.target.quantity, args.at}); + }, + .craft => |args| { + const item = self.api.getItemCode(args.target.id).?; + try writer.print("Task{{ .craft = {{ \"{s}\" x {} }}, .at = {} }}\n", .{item, args.target.quantity, args.at}); + }, + } + + var child_nodes = try self.listByParent(node_id); + defer child_nodes.deinit(); + + for (child_nodes.items) |child_node| { + try self.formatNode(child_node, level + 1, writer); + } + } +}; + pub fn main() !void { var gpa = std.heap.GeneralPurposeAllocator(.{}){}; defer _ = gpa.deinit(); @@ -90,6 +264,8 @@ pub fn main() !void { var api = try Server.init(allocator); defer api.deinit(); + try api.prefetch(); + const token = (try getAPITokenFromArgs(allocator)) orelse return error.MissingToken; defer allocator.free(token); @@ -105,7 +281,7 @@ pub fn main() !void { try goal_manager.addCharacter(char.name); } - goal_manager.characters.items[0].routine = .{ + goal_manager.characters.items[0].task = .{ .fight = .{ .at = Position.init(0, 1), .target = .{ @@ -115,7 +291,7 @@ pub fn main() !void { }, }; - goal_manager.characters.items[1].routine = .{ + goal_manager.characters.items[1].task = .{ .gather = .{ .at = Position.init(-1, 0), .target = .{ @@ -125,7 +301,7 @@ pub fn main() !void { } }; - goal_manager.characters.items[2].routine = .{ + goal_manager.characters.items[2].task = .{ .gather = .{ .at = Position.init(2, 0), .target = .{ @@ -135,7 +311,7 @@ pub fn main() !void { } }; - goal_manager.characters.items[3].routine = .{ + goal_manager.characters.items[3].task = .{ .gather = .{ .at = Position.init(4, 2), .target = .{ @@ -145,7 +321,7 @@ pub fn main() !void { } }; - goal_manager.characters.items[4].routine = .{ + goal_manager.characters.items[4].task = .{ .fight = .{ .at = Position.init(0, 1), .target = .{ @@ -155,11 +331,23 @@ pub fn main() !void { }, }; - const APIError = Server.APIError; + var task_tree = TaskTree.init(allocator, &api); + defer task_tree.deinit(); + + try task_tree.appendSubTree("sticky_dagger", 5, null); + // try task_tree.appendSubTree("copper_boots" , 5, null); + // try task_tree.appendSubTree("copper_helmet", 5, null); + // try task_tree.appendSubTree("copper_legs_armor", 5, null); + // try task_tree.appendSubTree("copper_armor", 5, null); + // try task_tree.appendSubTree("copper_ring", 5, null); + + std.debug.print("{}", .{task_tree}); + + if (false) { std.log.info("Starting main loop", .{}); while (true) { goal_manager.runNextAction() catch |err| switch (err) { - APIError.ServerUnavailable => { + Server.APIError.ServerUnavailable => { // If the server is down, wait for a moment and try again. std.time.sleep(std.time.ns_per_min * 5); continue; @@ -174,7 +362,7 @@ pub fn main() !void { if (brain.isRoutineFinished()) { if (!try brain.depositItemsToBank(&api)) { - brain.routine = .idle; + brain.routine = null; } continue; } @@ -182,4 +370,5 @@ pub fn main() !void { try brain.performRoutine(&api); } } + } }