From 99aad39c57adac5ac064d6b4dfc3d98fcbe9ee4e Mon Sep 17 00:00:00 2001 From: Rokas Puzonas Date: Wed, 7 Aug 2024 00:53:59 +0300 Subject: [PATCH] track state of inventory locally --- build.zig | 3 + src/artifacts.zig | 481 +++++++++++++++------------------------------ src/character.zig | 259 ++++++++++++++++++++++++ src/json_utils.zig | 80 ++++++++ src/main.zig | 238 +++++++++++++++------- src/timegm.c | 35 ++++ src/timegm.h | 3 + 7 files changed, 715 insertions(+), 384 deletions(-) create mode 100644 src/character.zig create mode 100644 src/json_utils.zig create mode 100644 src/timegm.c create mode 100644 src/timegm.h diff --git a/build.zig b/build.zig index f23c750..bd55c76 100644 --- a/build.zig +++ b/build.zig @@ -10,6 +10,9 @@ pub fn build(b: *std.Build) void { .target = target, .optimize = optimize, }); + exe.linkLibC(); + exe.addIncludePath(b.path("src")); + exe.addCSourceFile(.{ .file = b.path("src/timegm.c") }); b.installArtifact(exe); diff --git a/src/artifacts.zig b/src/artifacts.zig index 8bd1bc2..527f0a8 100644 --- a/src/artifacts.zig +++ b/src/artifacts.zig @@ -3,31 +3,44 @@ const assert = std.debug.assert; const json = std.json; const Allocator = std.mem.Allocator; +const json_utils = @import("json_utils.zig"); +pub const Character = @import("character.zig"); + // Specification: https://api.artifactsmmo.com/docs -// TODO: Convert 'expiration' date time strings into date time objects - const ArtifactsAPI = @This(); +pub const ItemId = u32; + +allocator: Allocator, +client: std.http.Client, + +server: []u8, +server_uri: std.Uri, + +token: ?[]u8 = null, + +item_codes: std.ArrayList([]u8), + pub const APIErrors = error { RequestFailed, ParseFailed }; const ServerStatus = struct { - // TODO: Parse the rest of the fields allocator: Allocator, status: []const u8, version: []const u8, + characters_online: i64, - fn parse(allocator: Allocator, object: json.ObjectMap) !ServerStatus { - const status = getJsonString(object, "status") orelse return error.MissingStatus; - const version = getJsonString(object, "version") orelse return error.MissingVersion; + fn parse(api: *ArtifactsAPI, object: json.ObjectMap, allocator: Allocator) !ServerStatus { + _ = api; return ServerStatus{ .allocator = allocator, - .status = try allocator.dupe(u8, status), - .version = try allocator.dupe(u8, version) + .characters_online = json_utils.getInteger(object, "characters_online") orelse return error.MissingProperty, + .status = (try json_utils.dupeString(allocator, object, "status")) orelse return error.MissingStatus, + .version = (try json_utils.dupeString(allocator, object, "version")) orelse return error.MissingVersion }; } @@ -37,211 +50,42 @@ const ServerStatus = struct { } }; -pub const Character = struct { - pub const SkillStats = struct { - level: i64, - xp: i64, - max_xp: i64, +pub fn parseDateTime(datetime: []const u8) ?f64 { + const time_h = @cImport({ + @cDefine("_XOPEN_SOURCE", "700"); + @cInclude("stddef.h"); + @cInclude("stdio.h"); + @cInclude("time.h"); + @cInclude("timegm.h"); + }); - fn parse(object: json.ObjectMap, level: []const u8, xp: []const u8, max_xp: []const u8) !SkillStats { - return SkillStats{ - .level = getJsonInteger(object, level) orelse return error.MissingProperty, - .xp = getJsonInteger(object, xp) orelse return error.MissingProperty, - .max_xp = getJsonInteger(object, max_xp) orelse return error.MissingProperty, - }; - } - }; + var buffer: [256]u8 = undefined; + var fba = std.heap.FixedBufferAllocator.init(&buffer); + var allocator = fba.allocator(); - pub const CombatStats = struct { - attack: i64, - damage: i64, - resistance: i64, + const datetime_z = allocator.dupeZ(u8, datetime) catch return null; - fn parse(object: json.ObjectMap, attack: []const u8, damage: []const u8, resistance: []const u8) !CombatStats { - return CombatStats{ - .attack = getJsonInteger(object, attack) orelse return error.MissingProperty, - .damage = getJsonInteger(object, damage) orelse return error.MissingProperty, - .resistance = getJsonInteger(object, resistance) orelse return error.MissingProperty, - }; - } - }; - - pub const Equipment = struct { - pub const Consumable = struct { - name: []u8, - quantity: i64, - - fn parse(allocator: Allocator, obj: json.ObjectMap, name: []const u8, quantity: []const u8) !Consumable { - return Consumable{ - .name = (try dupeJsonString(allocator, obj, name)) orelse return error.MissingProperty, - .quantity = getJsonInteger(obj, quantity) orelse return error.MissingProperty, - }; - } - }; - - weapon: []u8, - shield: []u8, - helmet: []u8, - body_armor: []u8, - leg_armor: []u8, - boots: []u8, - - ring1: []u8, - ring2: []u8, - amulet: []u8, - - artifact1: []u8, - artifact2: []u8, - artifact3: []u8, - - consumable1: Consumable, - consumable2: Consumable, - - fn parse(allocator: Allocator, obj: json.ObjectMap) !Equipment { - return Equipment{ - .weapon = (try dupeJsonString(allocator, obj, "weapon_slot")) orelse return error.MissingProperty, - .shield = (try dupeJsonString(allocator, obj, "shield_slot")) orelse return error.MissingProperty, - .helmet = (try dupeJsonString(allocator, obj, "helmet_slot")) orelse return error.MissingProperty, - .body_armor = (try dupeJsonString(allocator, obj, "body_armor_slot")) orelse return error.MissingProperty, - .leg_armor = (try dupeJsonString(allocator, obj, "leg_armor_slot")) orelse return error.MissingProperty, - .boots = (try dupeJsonString(allocator, obj, "boots_slot")) orelse return error.MissingProperty, - .ring1 = (try dupeJsonString(allocator, obj, "ring1_slot")) orelse return error.MissingProperty, - .ring2 = (try dupeJsonString(allocator, obj, "ring2_slot")) orelse return error.MissingProperty, - .amulet = (try dupeJsonString(allocator, obj, "amulet_slot")) orelse return error.MissingProperty, - .artifact1 = (try dupeJsonString(allocator, obj, "artifact1_slot")) orelse return error.MissingProperty, - .artifact2 = (try dupeJsonString(allocator, obj, "artifact2_slot")) orelse return error.MissingProperty, - .artifact3 = (try dupeJsonString(allocator, obj, "artifact3_slot")) orelse return error.MissingProperty, - .consumable1 = try Consumable.parse(allocator, obj, "consumable1_slot", "consumable1_slot_quantity"), - .consumable2 = try Consumable.parse(allocator, obj, "consumable2_slot", "consumable2_slot_quantity"), - }; - } - }; - - pub const Inventory = struct { - const slots_count = 20; - - pub const InventorySlot = struct { - code: []const u8, - quantity: i64, - - fn parse(allocator: Allocator, slot_obj: json.ObjectMap) !InventorySlot { - return InventorySlot{ - .code = (try dupeJsonString(allocator, slot_obj, "code")) orelse return error.MissingProperty, - .quantity = getJsonInteger(slot_obj, "quantity") orelse return error.MissingProperty, - }; - } - }; - - slots: [slots_count]InventorySlot, - - fn parse(allocator: Allocator, slots_array: json.Array) !Inventory { - assert(slots_array.items.len == Inventory.slots_count); - - var inventory: Inventory = undefined; - - for (0.., slots_array.items) |i, slot_value| { - const slot_obj = asJsonObject(slot_value) orelse return error.InvalidType; - inventory.slots[i] = try InventorySlot.parse(allocator, slot_obj); - } - - return inventory; - } - }; - - arena: *std.heap.ArenaAllocator, - - name: []u8, - skin: []u8, - account: ?[]u8, - total_xp: i64, - gold: i64, - hp: i64, - haste: i64, - x: i64, - y: i64, - cooldown: i64, - cooldown_expiration: []u8, - - combat: SkillStats, - mining: SkillStats, - woodcutting: SkillStats, - fishing: SkillStats, - weaponcrafting: SkillStats, - gearcrafting: SkillStats, - jewelrycrafting: SkillStats, - cooking: SkillStats, - - water: CombatStats, - fire: CombatStats, - earth: CombatStats, - air: CombatStats, - - equipment: Equipment, - - inventory_max_items: i64, - inventory: Inventory, - - fn parse(child_allocator: Allocator, obj: json.ObjectMap) !Character { - var arena = try child_allocator.create(std.heap.ArenaAllocator); - errdefer child_allocator.destroy(arena); - - arena.* = std.heap.ArenaAllocator.init(child_allocator); - errdefer arena.deinit(); - - const allocator = arena.allocator(); - - const inventory = getJsonArray(obj, "inventory") orelse return error.MissingProperty; - - return Character{ - .arena = arena, - .account = try dupeJsonString(allocator, obj, "account"), - .name = (try dupeJsonString(allocator, obj, "name")) orelse return error.MissingProperty, - .skin = (try dupeJsonString(allocator, obj, "skin")) orelse return error.MissingProperty, - - .total_xp = getJsonInteger(obj, "total_xp") orelse return error.MissingProperty, - .gold = getJsonInteger(obj, "gold") orelse return error.MissingProperty, - .hp = getJsonInteger(obj, "hp") orelse return error.MissingProperty, - .haste = getJsonInteger(obj, "haste") orelse return error.MissingProperty, - .x = getJsonInteger(obj, "x") orelse return error.MissingProperty, - .y = getJsonInteger(obj, "y") orelse return error.MissingProperty, - .cooldown = getJsonInteger(obj, "cooldown") orelse return error.MissingProperty, - .cooldown_expiration = (try dupeJsonString(allocator, obj, "cooldown_expiration")) orelse return error.MissingProperty, - - .combat = try SkillStats.parse(obj, "level", "xp", "max_xp"), - .mining = try SkillStats.parse(obj, "mining_level", "mining_xp", "mining_max_xp"), - .woodcutting = try SkillStats.parse(obj, "woodcutting_level", "woodcutting_xp", "woodcutting_max_xp"), - .fishing = try SkillStats.parse(obj, "fishing_level", "fishing_xp", "fishing_max_xp"), - .weaponcrafting = try SkillStats.parse(obj, "weaponcrafting_level", "weaponcrafting_xp", "weaponcrafting_max_xp"), - .gearcrafting = try SkillStats.parse(obj, "gearcrafting_level", "gearcrafting_xp", "gearcrafting_max_xp"), - .jewelrycrafting = try SkillStats.parse(obj, "jewelrycrafting_level", "jewelrycrafting_xp", "jewelrycrafting_max_xp"), - .cooking = try SkillStats.parse(obj, "cooking_level", "cooking_xp", "cooking_max_xp"), - - .water = try CombatStats.parse(obj, "attack_water", "dmg_water", "res_water"), - .fire = try CombatStats.parse(obj, "attack_fire", "dmg_fire", "res_fire"), - .earth = try CombatStats.parse(obj, "attack_earth", "dmg_earth", "res_earth"), - .air = try CombatStats.parse(obj, "attack_air", "dmg_air", "res_air"), - - .equipment = try Equipment.parse(allocator, obj), - - .inventory_max_items = getJsonInteger(obj, "inventory_max_items") orelse return error.MissingProperty, - .inventory = try Inventory.parse(allocator, inventory) - }; + var tm: time_h.tm = .{}; + const s = time_h.strptime(datetime_z, "%FT%H:%M:%S.", &tm); + if (s == null) { + return null; } - pub fn deinit(self: *Character) void { - var child_allocator = self.arena.child_allocator; - self.arena.deinit(); - child_allocator.destroy(self.arena); + const s_len = std.mem.len(s); + if (s[s_len-1] != 'Z') { + return null; } - pub fn getItemCount(self: *const Character) u32 { - var count: u32 = 0; - for (self.inventory.slots) |slot| { - count += @intCast(slot.quantity); - } - return count; + const milliseconds_str = s[0..(s_len-1)]; + var milliseconds: f64 = @floatFromInt(std.fmt.parseUnsigned(u32, milliseconds_str, 10) catch return null); + while (milliseconds >= 1) { + milliseconds /= 10; } -}; + + const seconds: f64 = @floatFromInt(time_h.my_timegm(&tm)); + + return seconds + milliseconds; +} pub const CharacterList = struct { allocator: Allocator, @@ -305,44 +149,81 @@ pub const Cooldown = struct { } }; - allocator: Allocator, - total_seconds: i64, - remaining_seconds: i64, - expiration: []u8, + expiration: f64, reason: Reason, - fn parse(allocator: Allocator, obj: json.ObjectMap) !Cooldown { - const reason = getJsonString(obj, "reason") orelse return error.MissingProperty; + fn parse(obj: json.ObjectMap) !Cooldown { + const reason = try json_utils.getStringRequired(obj, "reason"); + const expiration = try json_utils.getStringRequired(obj, "expiration"); + return Cooldown{ - .allocator = allocator, - .total_seconds = getJsonInteger(obj, "total_seconds") orelse return error.MissingProperty, - .remaining_seconds = getJsonInteger(obj, "remaining_seconds") orelse return error.MissingProperty, - .expiration = (try dupeJsonString(allocator, obj, "expiration")) orelse return error.MissingProperty, + .expiration = parseDateTime(expiration) orelse return error.InvalidDateTime, .reason = Reason.parse(reason) orelse return error.UnknownReason }; } - - pub fn deinit(self: Cooldown) void { - self.allocator.free(self.expiration); - } }; pub const FightResult = struct { + const Details = struct { + const DroppedItem = struct { + id: ItemId, + quantity: i64 + }; + + const Result = enum { win, lose }; + + xp: i64, + gold: i64, + drops: std.BoundedArray(DroppedItem, 8), + result: Result, + + fn parse(api: *ArtifactsAPI, obj: json.ObjectMap) !Details { + const result = try json_utils.getStringRequired(obj, "result"); + var result_enum: Result = undefined; + if (std.mem.eql(u8, result, "win")) { + result_enum = .win; + } else if (std.mem.eql(u8, result, "win")) { + result_enum = .lose; + } + + var drops = std.BoundedArray(DroppedItem, 8).init(0) catch unreachable; + const drops_obj = json_utils.getArray(obj, "drops") orelse return error.MissingProperty; + for (drops_obj.items) |drop_value| { + const drop_obj = json_utils.asObject(drop_value) orelse return error.MissingProperty; + const code = try json_utils.getStringRequired(drop_obj, "code"); + + try drops.append(DroppedItem{ + .id = try api.getItemId(code), + .quantity = try json_utils.getIntegerRequired(drop_obj, "quantity") + }); + } + + return Details{ + .xp = try json_utils.getIntegerRequired(obj, "xp"), + .gold = try json_utils.getIntegerRequired(obj, "gold"), + .drops = drops, + .result = result_enum, + }; + } + }; + cooldown: Cooldown, character: Character, + fight: Details, - fn parse(allocator: Allocator, obj: json.ObjectMap) !FightResult { - const cooldown = getJsonObject(obj, "cooldown") orelse return error.MissingProperty; - const character = getJsonObject(obj, "character") orelse return error.MissingProperty; + fn parse(api: *ArtifactsAPI, obj: json.ObjectMap, allocator: Allocator) !FightResult { + const cooldown = json_utils.getObject(obj, "cooldown") orelse return error.MissingProperty; + const character = json_utils.getObject(obj, "character") orelse return error.MissingProperty; + const fight = json_utils.getObject(obj, "fight") orelse return error.MissingProperty; return FightResult{ - .cooldown = try Cooldown.parse(allocator, cooldown), - .character = try Character.parse(allocator, character) + .cooldown = try Cooldown.parse(cooldown), + .character = try Character.parse(character, allocator, api), + .fight = try Details.parse(api, fight) }; } pub fn deinit(self: *FightResult) void { - self.cooldown.deinit(); self.character.deinit(); } }; @@ -350,48 +231,52 @@ pub const FightResult = struct { pub const MoveResult = struct { cooldown: Cooldown, - fn parse(allocator: Allocator, obj: json.ObjectMap) !MoveResult { - const cooldown = getJsonObject(obj, "cooldown") orelse return error.MissingProperty; + fn parse(api: *ArtifactsAPI, obj: json.ObjectMap) !MoveResult { + _ = api; + + const cooldown = json_utils.getObject(obj, "cooldown") orelse return error.MissingProperty; return MoveResult{ - .cooldown = try Cooldown.parse(allocator, cooldown) + .cooldown = try Cooldown.parse(cooldown) }; } pub fn deinit(self: MoveResult) void { - self.cooldown.deinit(); + _ = self; } }; pub const GoldTransactionResult = struct { cooldown: Cooldown, - fn parse(allocator: Allocator, obj: json.ObjectMap) !GoldTransactionResult { - const cooldown = getJsonObject(obj, "cooldown") orelse return error.MissingProperty; + fn parse(api: *ArtifactsAPI, obj: json.ObjectMap) !GoldTransactionResult { + _ = api; + const cooldown = json_utils.getObject(obj, "cooldown") orelse return error.MissingProperty; return GoldTransactionResult{ - .cooldown = try Cooldown.parse(allocator, cooldown) + .cooldown = try Cooldown.parse(cooldown) }; } pub fn deinit(self: GoldTransactionResult) void { - self.cooldown.deinit(); + _ = self; } }; pub const ItemTransactionResult = struct { cooldown: Cooldown, - fn parse(allocator: Allocator, obj: json.ObjectMap) !ItemTransactionResult { - const cooldown = getJsonObject(obj, "cooldown") orelse return error.MissingProperty; + fn parse(api: *ArtifactsAPI, obj: json.ObjectMap) !ItemTransactionResult { + _ = api; + const cooldown = json_utils.getObject(obj, "cooldown") orelse return error.MissingProperty; return ItemTransactionResult{ - .cooldown = try Cooldown.parse(allocator, cooldown) + .cooldown = try Cooldown.parse(cooldown) }; } pub fn deinit(self: ItemTransactionResult) void { - self.cooldown.deinit(); + _ = self; } }; @@ -405,26 +290,30 @@ pub const ArtifactsFetchResult = struct { } }; -allocator: Allocator, -client: std.http.Client, - -server: []u8, -server_uri: std.Uri, - -token: ?[]u8 = null, - pub fn init(allocator: Allocator) !ArtifactsAPI { const server = try allocator.dupe(u8, "https://api.artifactsmmo.com"); const server_uri = std.Uri.parse(server) catch unreachable; return ArtifactsAPI{ .allocator = allocator, + .item_codes = std.ArrayList([]u8).init(allocator), .client = .{ .allocator = allocator }, .server = server, .server_uri = server_uri }; } +pub fn deinit(self: *ArtifactsAPI) void { + self.client.deinit(); + self.allocator.free(self.server); + if (self.token) |str| self.allocator.free(str); + + for (self.item_codes.items) |code| { + self.allocator.free(code); + } + self.item_codes.deinit(); +} + fn fetch(self: *ArtifactsAPI, method: std.http.Method, path: []const u8, payload: ?[]const u8) !ArtifactsFetchResult { var uri = self.server_uri; uri.path = .{ .raw = path }; @@ -472,7 +361,7 @@ fn fetch(self: *ArtifactsAPI, method: std.http.Method, path: []const u8, payload }; } -fn fetchAndParseObject(self: *ArtifactsAPI, Result: type, method: std.http.Method, path: []const u8, payload: ?[]const u8) !Result { +fn fetchAndParseObject(self: *ArtifactsAPI, Result: type, method: std.http.Method, path: []const u8, payload: ?[]const u8, args: anytype) !Result { const result = try self.fetch(method, path, payload); defer result.deinit(); @@ -483,8 +372,9 @@ fn fetchAndParseObject(self: *ArtifactsAPI, Result: type, method: std.http.Metho return APIErrors.ParseFailed; } - const body = asJsonObject(result.body.?) orelse return APIErrors.ParseFailed; - return Result.parse(self.allocator, body) catch return APIErrors.ParseFailed; + const body = json_utils.asObject(result.body.?) orelse return APIErrors.ParseFailed; + return @call(.auto, Result.parse, .{ self, body } ++ args) catch return APIErrors.ParseFailed; + // return Result.parse(self.allocator, body) catch return APIErrors.ParseFailed; } pub fn setServer(self: *ArtifactsAPI, url: []const u8) !void { @@ -508,80 +398,42 @@ pub fn setToken(self: *ArtifactsAPI, token: ?[]const u8) !void { self.token = new_token; } -fn getJsonString(object: json.ObjectMap, name: []const u8) ?[]const u8 { - const value = object.get(name); - if (value == null) { - return null; +pub fn getItemId(self: *ArtifactsAPI, code: []const u8) !ItemId { + assert(code.len != 0); + + for (0.., self.item_codes.items) |i, item_code| { + if (std.mem.eql(u8, code, item_code)) { + return @intCast(i); + } } - if (value.? != json.Value.string) { - return null; - } + const code_dupe = try self.allocator.dupe(u8, code); + errdefer self.allocator.free(code_dupe); + try self.item_codes.append(code_dupe); - return value.?.string; + return @intCast(self.item_codes.items.len - 1); } -fn dupeJsonString(allocator: Allocator, object: json.ObjectMap, name: []const u8) !?[]u8 { - const str = getJsonString(object, name) orelse return null; - return try allocator.dupe(u8, str); -} - -fn getJsonInteger(object: json.ObjectMap, name: []const u8) ?i64 { - const value = object.get(name); - if (value == null) { +pub fn getItemCode(self: *const ArtifactsAPI, id: ItemId) ?[]const u8 { + if (id >= self.item_codes.items.len) { return null; } - if (value.? != json.Value.integer) { - return null; - } - - return value.?.integer; + return self.item_codes.items[id]; } -fn asJsonObject(value: json.Value) ?json.ObjectMap { - if (value != json.Value.object) { - return null; - } - return value.object; -} - -fn asJsonArray(value: json.Value) ?json.Array { - if (value != json.Value.array) { - return null; - } - - return value.array; -} - -fn getJsonObject(object: json.ObjectMap, name: []const u8) ?json.ObjectMap { - const value = object.get(name); - if (value == null) { - return null; - } - - return asJsonObject(value.?); -} - -fn getJsonArray(object: json.ObjectMap, name: []const u8) ?json.Array { - const value = object.get(name); - if (value == null) { - return null; - } - - return asJsonArray(value.?); -} +// ------------------------- Endpoints ------------------------ pub fn getServerStatus(self: *ArtifactsAPI) !ServerStatus { - return try self.fetchAndParseObject(ServerStatus, .GET, "/", null); + return try self.fetchAndParseObject(ServerStatus, .GET, "/", null, .{ self.allocator }); } pub fn getCharacter(self: *ArtifactsAPI, name: []const u8) !Character { const path = try std.fmt.allocPrint(self.allocator, "/characters/{s}", .{name}); defer self.allocator.free(path); - return try self.fetchAndParseObject(Character, .GET, path, null); + return try self.fetchAndParseObject(Character, .GET, path, null, .{ self.allocator }); } pub fn listMyCharacters(self: *ArtifactsAPI) !CharacterList { @@ -598,7 +450,7 @@ pub fn listMyCharacters(self: *ArtifactsAPI) !CharacterList { return APIErrors.ParseFailed; } - const body = asJsonArray(result.body.?) orelse return APIErrors.ParseFailed; + const body = json_utils.asArray(result.body.?) orelse return APIErrors.ParseFailed; var characters = try std.ArrayList(Character).initCapacity(self.allocator, body.items.len); errdefer { @@ -609,13 +461,12 @@ pub fn listMyCharacters(self: *ArtifactsAPI) !CharacterList { } for (body.items) |character_json| { - const character_obj = asJsonObject(character_json) orelse return APIErrors.ParseFailed; - const char = Character.parse(self.allocator, character_obj) catch return APIErrors.ParseFailed; + const character_obj = json_utils.asObject(character_json) orelse return APIErrors.ParseFailed; + const char = Character.parse(character_obj, self.allocator, self) catch return APIErrors.ParseFailed; characters.appendAssumeCapacity(char); } - return CharacterList{ .allocator = self.allocator, .items = characters.items @@ -626,7 +477,7 @@ pub fn actionFight(self: *ArtifactsAPI, name: []const u8) !FightResult { const path = try std.fmt.allocPrint(self.allocator, "/my/{s}/action/fight", .{name}); defer self.allocator.free(path); - return try self.fetchAndParseObject(FightResult, .POST, path, null); + return try self.fetchAndParseObject(FightResult, .POST, path, null, .{ self.allocator }); } pub fn actionMove(self: *ArtifactsAPI, name: []const u8, x: i64, y: i64) !MoveResult { @@ -636,7 +487,7 @@ pub fn actionMove(self: *ArtifactsAPI, name: []const u8, x: i64, y: i64) !MoveRe const payload = try std.fmt.allocPrint(self.allocator, "{{ \"x\":{},\"y\":{} }}", .{x, y}); defer self.allocator.free(payload); - return try self.fetchAndParseObject(MoveResult, .POST, path, payload); + return try self.fetchAndParseObject(MoveResult, .POST, path, payload, .{}); } pub fn actionBankDepositGold(self: *ArtifactsAPI, name: []const u8, quantity: u64) !GoldTransactionResult { @@ -646,21 +497,15 @@ pub fn actionBankDepositGold(self: *ArtifactsAPI, name: []const u8, quantity: u6 const payload = try std.fmt.allocPrint(self.allocator, "{{ \"quantity\":{} }}", .{quantity}); defer self.allocator.free(payload); - return try self.fetchAndParseObject(GoldTransactionResult, .POST, path, payload); + return try self.fetchAndParseObject(GoldTransactionResult, .POST, path, payload, .{}); } -pub fn actionBankDeposit(self: *ArtifactsAPI, name: []const u8, code: []const u8, quantity: u64) !ItemTransactionResult { +pub fn actionBankDepositItem(self: *ArtifactsAPI, name: []const u8, code: []const u8, quantity: u64) !ItemTransactionResult { const path = try std.fmt.allocPrint(self.allocator, "/my/{s}/action/bank/deposit", .{name}); defer self.allocator.free(path); const payload = try std.fmt.allocPrint(self.allocator, "{{ \"code\":\"{s}\",\"quantity\":{} }}", .{code, quantity}); defer self.allocator.free(payload); - return try self.fetchAndParseObject(ItemTransactionResult, .POST, path, payload); -} - -pub fn deinit(self: *ArtifactsAPI) void { - self.client.deinit(); - self.allocator.free(self.server); - if (self.token) |str| self.allocator.free(str); + return try self.fetchAndParseObject(ItemTransactionResult, .POST, path, payload, .{}); } diff --git a/src/character.zig b/src/character.zig new file mode 100644 index 0000000..0b46b5b --- /dev/null +++ b/src/character.zig @@ -0,0 +1,259 @@ +const std = @import("std"); +const json_utils = @import("json_utils.zig"); +const ArtifactsAPI = @import("artifacts.zig"); +const parseDateTime = ArtifactsAPI.parseDateTime; +const ItemId = ArtifactsAPI.ItemId; +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const json = std.json; + +const Character = @This(); + +fn getItemId(api: *ArtifactsAPI, object: json.ObjectMap, name: []const u8) !?ItemId { + const code = try json_utils.getStringRequired(object, name); + if (code.len == 0) { + return null; + } + + return try api.getItemId(code); +} + + +pub const SkillStats = struct { + level: i64, + xp: i64, + max_xp: i64, + + fn parse(object: json.ObjectMap, level: []const u8, xp: []const u8, max_xp: []const u8) !SkillStats { + return SkillStats{ + .level = try json_utils.getIntegerRequired(object, level), + .xp = try json_utils.getIntegerRequired(object, xp), + .max_xp = try json_utils.getIntegerRequired(object, max_xp), + }; + } +}; + +pub const CombatStats = struct { + attack: i64, + damage: i64, + resistance: i64, + + fn parse(object: json.ObjectMap, attack: []const u8, damage: []const u8, resistance: []const u8) !CombatStats { + return CombatStats{ + .attack = try json_utils.getIntegerRequired(object, attack), + .damage = try json_utils.getIntegerRequired(object, damage), + .resistance = try json_utils.getIntegerRequired(object, resistance), + }; + } +}; + +pub const Equipment = struct { + pub const Consumable = struct { + id: ?ItemId, + quantity: i64, + + fn parse(api: *ArtifactsAPI, obj: json.ObjectMap, name: []const u8, quantity: []const u8) !Consumable { + return Consumable{ + .id = try getItemId(api, obj, name), + .quantity = try json_utils.getIntegerRequired(obj, quantity), + }; + } + }; + + weapon: ?ItemId, + shield: ?ItemId, + helmet: ?ItemId, + body_armor: ?ItemId, + leg_armor: ?ItemId, + boots: ?ItemId, + + ring1: ?ItemId, + ring2: ?ItemId, + amulet: ?ItemId, + + artifact1: ?ItemId, + artifact2: ?ItemId, + artifact3: ?ItemId, + + consumable1: Consumable, + consumable2: Consumable, + + fn parse(api: *ArtifactsAPI, obj: json.ObjectMap) !Equipment { + return Equipment{ + .weapon = try getItemId(api, obj, "weapon_slot"), + .shield = try getItemId(api, obj, "shield_slot"), + .helmet = try getItemId(api, obj, "helmet_slot"), + .body_armor = try getItemId(api, obj, "body_armor_slot"), + .leg_armor = try getItemId(api, obj, "leg_armor_slot"), + .boots = try getItemId(api, obj, "boots_slot"), + .ring1 = try getItemId(api, obj, "ring1_slot"), + .ring2 = try getItemId(api, obj, "ring2_slot"), + .amulet = try getItemId(api, obj, "amulet_slot"), + .artifact1 = try getItemId(api, obj, "artifact1_slot"), + .artifact2 = try getItemId(api, obj, "artifact2_slot"), + .artifact3 = try getItemId(api, obj, "artifact3_slot"), + .consumable1 = try Consumable.parse(api, obj, "consumable1_slot", "consumable1_slot_quantity"), + .consumable2 = try Consumable.parse(api, obj, "consumable2_slot", "consumable2_slot_quantity"), + }; + } +}; + +pub const Inventory = struct { + const slot_count = 20; + + pub const Slot = struct { + id: ?ItemId, + quantity: i64, + + fn parse(api: *ArtifactsAPI, slot_obj: json.ObjectMap) !Slot { + return Slot{ + .id = try getItemId(api, slot_obj, "code"), + .quantity = try json_utils.getIntegerRequired(slot_obj, "quantity"), + }; + } + }; + + slots: [slot_count]Slot, + + fn parse(api: *ArtifactsAPI, slots_array: json.Array) !Inventory { + assert(slots_array.items.len == Inventory.slot_count); + + var inventory: Inventory = undefined; + + for (0.., slots_array.items) |i, slot_value| { + const slot_obj = json_utils.asObject(slot_value) orelse return error.InvalidType; + inventory.slots[i] = try Slot.parse(api, slot_obj); + } + + return inventory; + } + + fn findSlot(self: *Inventory, id: ItemId) ?*Slot { + for (&self.slots) |*slot| { + if (slot.id == id) { + return slot; + } + } + return null; + } + + pub fn removeItem(self: *Inventory, id: ItemId, quantity: u64) void { + const slot = self.findSlot(id) orelse unreachable; + assert(slot.quantity >= quantity); + + slot.quantity -= @intCast(quantity); + if (slot.quantity == 0) { + slot.id = null; + } + } + + pub fn addItem(self: *Inventory, id: ItemId, quantity: u64) void { + if (self.findSlot(id)) |slot| { + slot.quantity += @intCast(quantity); + } else { + var empty_slot: ?*Slot = null; + for (&self.slots) |*slot| { + if (slot.id == null) { + empty_slot = slot; + } + } + + assert(empty_slot != null); + empty_slot.?.id = id; + empty_slot.?.quantity = @intCast(quantity); + } + } +}; + +arena: *std.heap.ArenaAllocator, + +name: []u8, +skin: []u8, +account: ?[]u8, +total_xp: i64, +gold: i64, +hp: i64, +haste: i64, +x: i64, +y: i64, +cooldown_expiration: f64, + +combat: SkillStats, +mining: SkillStats, +woodcutting: SkillStats, +fishing: SkillStats, +weaponcrafting: SkillStats, +gearcrafting: SkillStats, +jewelrycrafting: SkillStats, +cooking: SkillStats, + +water: CombatStats, +fire: CombatStats, +earth: CombatStats, +air: CombatStats, + +equipment: Equipment, + +inventory_max_items: i64, +inventory: Inventory, + +pub fn parse(obj: json.ObjectMap, child_allocator: Allocator, api: *ArtifactsAPI) !Character { + var arena = try child_allocator.create(std.heap.ArenaAllocator); + errdefer child_allocator.destroy(arena); + + arena.* = std.heap.ArenaAllocator.init(child_allocator); + errdefer arena.deinit(); + + const allocator = arena.allocator(); + + const inventory = json_utils.getArray(obj, "inventory") orelse return error.MissingProperty; + const cooldown_expiration = json_utils.getString(obj, "cooldown_expiration") orelse return error.MissingProperty; + + return Character{ + .arena = arena, + .account = try json_utils.dupeString(allocator, obj, "account"), + .name = (try json_utils.dupeString(allocator, obj, "name")) orelse return error.MissingProperty, + .skin = (try json_utils.dupeString(allocator, obj, "skin")) orelse return error.MissingProperty, + + .total_xp = try json_utils.getIntegerRequired(obj, "total_xp"), + .gold = try json_utils.getIntegerRequired(obj, "gold"), + .hp = try json_utils.getIntegerRequired(obj, "hp"), + .haste = try json_utils.getIntegerRequired(obj, "haste"), + .x = try json_utils.getIntegerRequired(obj, "x"), + .y = try json_utils.getIntegerRequired(obj, "y"), + .cooldown_expiration = parseDateTime(cooldown_expiration) orelse return error.InvalidDateTime, + + .combat = try SkillStats.parse(obj, "level", "xp", "max_xp"), + .mining = try SkillStats.parse(obj, "mining_level", "mining_xp", "mining_max_xp"), + .woodcutting = try SkillStats.parse(obj, "woodcutting_level", "woodcutting_xp", "woodcutting_max_xp"), + .fishing = try SkillStats.parse(obj, "fishing_level", "fishing_xp", "fishing_max_xp"), + .weaponcrafting = try SkillStats.parse(obj, "weaponcrafting_level", "weaponcrafting_xp", "weaponcrafting_max_xp"), + .gearcrafting = try SkillStats.parse(obj, "gearcrafting_level", "gearcrafting_xp", "gearcrafting_max_xp"), + .jewelrycrafting = try SkillStats.parse(obj, "jewelrycrafting_level", "jewelrycrafting_xp", "jewelrycrafting_max_xp"), + .cooking = try SkillStats.parse(obj, "cooking_level", "cooking_xp", "cooking_max_xp"), + + .water = try CombatStats.parse(obj, "attack_water", "dmg_water", "res_water"), + .fire = try CombatStats.parse(obj, "attack_fire", "dmg_fire", "res_fire"), + .earth = try CombatStats.parse(obj, "attack_earth", "dmg_earth", "res_earth"), + .air = try CombatStats.parse(obj, "attack_air", "dmg_air", "res_air"), + + .equipment = try Equipment.parse(api, obj), + + .inventory_max_items = json_utils.getInteger(obj, "inventory_max_items") orelse return error.MissingProperty, + .inventory = try Inventory.parse(api, inventory) + }; +} + +pub fn deinit(self: *Character) void { + var child_allocator = self.arena.child_allocator; + self.arena.deinit(); + child_allocator.destroy(self.arena); +} + +pub fn getItemCount(self: *const Character) u32 { + var count: u32 = 0; + for (self.inventory.slots) |slot| { + count += @intCast(slot.quantity); + } + return count; +} diff --git a/src/json_utils.zig b/src/json_utils.zig new file mode 100644 index 0000000..90c324d --- /dev/null +++ b/src/json_utils.zig @@ -0,0 +1,80 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const json = std.json; + +pub fn getInteger(object: json.ObjectMap, name: []const u8) ?i64 { + const value = object.get(name); + if (value == null) { + return null; + } + + if (value.? != json.Value.integer) { + return null; + } + + return value.?.integer; +} + +pub fn getIntegerRequired(object: json.ObjectMap, name: []const u8) !i64 { + return getInteger(object, name) orelse return error.MissingProperty; +} + +pub fn asObject(value: json.Value) ?json.ObjectMap { + if (value != json.Value.object) { + return null; + } + + return value.object; +} + +pub fn asArray(value: json.Value) ?json.Array { + if (value != json.Value.array) { + return null; + } + + return value.array; +} + +pub fn getObject(object: json.ObjectMap, name: []const u8) ?json.ObjectMap { + const value = object.get(name); + if (value == null) { + return null; + } + + return asObject(value.?); +} + +pub fn getArray(object: json.ObjectMap, name: []const u8) ?json.Array { + const value = object.get(name); + if (value == null) { + return null; + } + + return asArray(value.?); +} + +pub fn getString(object: json.ObjectMap, name: []const u8) ?[]const u8 { + const value = object.get(name); + if (value == null) { + return null; + } + + if (value.? != json.Value.string) { + return null; + } + + return value.?.string; +} + +pub fn getStringRequired(object: json.ObjectMap, name: []const u8) ![]const u8 { + return getString(object, name) orelse return error.MissingProperty; +} + +pub fn dupeString(allocator: Allocator, object: json.ObjectMap, name: []const u8) !?[]u8 { + const str = getString(object, name) orelse return null; + return try allocator.dupe(u8, str); +} + +pub fn dupeStringRequired(allocator: Allocator, object: json.ObjectMap, name: []const u8) ![]u8 { + return (try dupeString(allocator, object, name)) orelse return error.MissingProperty; +} diff --git a/src/main.zig b/src/main.zig index cfe5ef2..3212b9c 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,42 +1,97 @@ const std = @import("std"); const assert = std.debug.assert; const ArtifactsAPI = @import("artifacts.zig"); +const Allocator = std.mem.Allocator; -fn waitForCooldown(remaining_seconds: i64) void { - if (remaining_seconds > 0) { - std.log.debug("Waiting for cooldown {}", .{remaining_seconds}); - std.time.sleep(std.time.ns_per_s * (@as(u64, @intCast(remaining_seconds)) + 1)); +const Position = struct { + x: i64, + y: i64, + + fn init(x: i64, y: i64) Position { + return Position{ + .x = x, + .y = y + }; } + + fn eql(self: Position, other: Position) bool { + return self.x == other.x and self.y == other.y; + } +}; + +const QueuedAction = union(enum) { + move: Position, + attack, + depositGold: u64, + depositItem: struct { id: ArtifactsAPI.ItemId, quantity: u64 }, +}; + +const ManagedCharacter = struct { + character: ArtifactsAPI.Character, + action_queue: std.ArrayList(QueuedAction), + cooldown_expires_at: f64, +}; + +fn currentTime() f64 { + const timestamp: f64 = @floatFromInt(std.time.milliTimestamp()); + return timestamp / std.time.ms_per_s; } -pub fn depositIfNeeded(api: *ArtifactsAPI, char: *ArtifactsAPI.Character) !void { - if (char.getItemCount() < char.inventory_max_items) return; +const Manager = struct { + allocator: Allocator, + characters: std.ArrayList(ManagedCharacter), + api: *ArtifactsAPI, - { - const move_result = try api.actionMove(char.name, 4, 1); - defer move_result.deinit(); - waitForCooldown(move_result.cooldown.remaining_seconds); + fn init(allocator: Allocator, api: *ArtifactsAPI) Manager { + return Manager{ + .allocator = allocator, + .api = api, + .characters = std.ArrayList(ManagedCharacter).init(allocator), + }; } - const deposit_gold = try api.actionBankDepositGold(char.name, @intCast(char.gold)); - defer deposit_gold.deinit(); - waitForCooldown(deposit_gold.cooldown.remaining_seconds); - - for (char.inventory.slots) |slot| { - if (slot.quantity == 0) continue; - - const deposit_item = try api.actionBankDeposit(char.name, slot.code, @intCast(slot.quantity)); - defer deposit_item.deinit(); - waitForCooldown(deposit_item.cooldown.remaining_seconds); + fn addCharacter(self: *Manager, character: ArtifactsAPI.Character) !void { + try self.characters.append(ManagedCharacter{ + .character = character, + .action_queue = std.ArrayList(QueuedAction).init(self.allocator), + .cooldown_expires_at = character.cooldown_expiration + }); } - { - const move_result = try api.actionMove(char.name, 0, 1); - defer move_result.deinit(); - waitForCooldown(move_result.cooldown.remaining_seconds); + fn poll(self: *Manager) ?*ManagedCharacter { + if (self.characters.items.len == 0) return null; + + var earliest_expiration = self.characters.items[0].cooldown_expires_at; + + var now = currentTime(); + for (self.characters.items) |managed_character| { + earliest_expiration = @min(earliest_expiration, managed_character.cooldown_expires_at); + } + + if (earliest_expiration > now) { + const duration_s = earliest_expiration - now; + const duration_ms: u64 = @intFromFloat(@trunc(duration_s * std.time.ms_per_s)); + std.log.debug("waiting for {d:.3}s", .{duration_s}); + std.time.sleep(std.time.ns_per_ms * (duration_ms + 100)); + } + + now = currentTime(); + for (self.characters.items) |*managed_character| { + if (now >= managed_character.cooldown_expires_at) { + return managed_character; + } + } + + return null; } -} + fn deinit(self: Manager) void { + for (self.characters.items) |managed_character| { + managed_character.action_queue.deinit(); + } + self.characters.deinit(); + } +}; pub fn main() !void { var gpa = std.heap.GeneralPurposeAllocator(.{}){}; @@ -62,59 +117,110 @@ pub fn main() !void { return error.MissingToken; } + var manager = Manager.init(allocator, &api); + defer manager.deinit(); + const characters = try api.listMyCharacters(); defer characters.deinit(); - { - var longest_cooldown: i64 = 0; - - for (characters.items) |char| { - if (char.x == 0 and char.y == 1) continue; - - const result = try api.actionMove(char.name, 0, 1); - defer result.deinit(); - - longest_cooldown = @max(longest_cooldown, result.cooldown.remaining_seconds); - } - - if (longest_cooldown > 0) { - std.log.debug("Waiting for cooldown {}", .{longest_cooldown}); - - std.time.sleep(std.time.ns_per_s * (@as(u64, @intCast(longest_cooldown)) + 1)); - } + for (characters.items) |character| { + try manager.addCharacter(character); } - std.log.info("Started main loop", .{}); - while (true) { - var results = std.ArrayList(ArtifactsAPI.FightResult).init(allocator); - defer { - for (results.items) |*result| { - result.deinit(); - } - results.deinit(); - } + const status = try api.getServerStatus(); + defer status.deinit(); - for (characters.items) |char| { - var result = try api.actionFight(char.name); - errdefer result.deinit(); + std.log.info("Server status: {s} v{s}", .{ status.status, status.version }); + std.log.info("Characters online: {}", .{ status.characters_online }); - try results.append(result); - } + const chicken_pos = Position{ .x = 0, .y = 1 }; + const bank_pos = Position{ .x = 4, .y = 1 }; - { - var longest_cooldown: i64 = 0; - for (results.items) |result| { - longest_cooldown = @max(longest_cooldown, result.cooldown.remaining_seconds); + std.log.info("Starting main loop", .{}); + while (manager.poll()) |char| { + if (char.action_queue.items.len > 0) { + const action = char.action_queue.items[0]; + + var cooldown: ArtifactsAPI.Cooldown = undefined; + switch (action) { + .attack => { + std.log.debug("{s} attacks", .{char.character.name}); + var result = try api.actionFight(char.character.name); + defer result.deinit(); + + cooldown = result.cooldown; + char.character.gold += result.fight.gold; + for (result.fight.drops.slice()) |item| { + char.character.inventory.addItem(item.id, @intCast(item.quantity)); + } + }, + .move => |pos| { + std.log.debug("move {s} to ({}, {})", .{char.character.name, pos.x, pos.y}); + var result = try api.actionMove(char.character.name, pos.x, pos.y); + defer result.deinit(); + + cooldown = result.cooldown; + char.character.x = pos.x; + char.character.y = pos.y; + }, + .depositGold => |quantity| { + std.log.debug("deposit {} gold from {s}", .{quantity, char.character.name}); + var result = try api.actionBankDepositGold(char.character.name, quantity); + defer result.deinit(); + + cooldown = result.cooldown; + char.character.gold -= @intCast(quantity); + assert(char.character.gold >= 0); + }, + .depositItem => |item| { + std.log.debug("deposit {s}(x{}) from {s}", .{api.getItemCode(item.id).?, item.quantity, char.character.name}); + const code = api.getItemCode(item.id) orelse return error.ItemNotFound; + var result = try api.actionBankDepositItem(char.character.name, code, item.quantity); + defer result.deinit(); + + cooldown = result.cooldown; + char.character.inventory.removeItem(item.id, item.quantity); + } } - if (longest_cooldown > 0) { - std.log.debug("Waiting for cooldown {}", .{longest_cooldown}); - std.time.sleep(std.time.ns_per_s * (@as(u64, @intCast(longest_cooldown)) + 1)); - } + char.cooldown_expires_at = cooldown.expiration; + + _ = char.action_queue.orderedRemove(0); + continue; } - for (results.items) |*result| { - try depositIfNeeded(&api, &result.character); + var character_pos = Position.init(char.character.x, char.character.y); + + // Deposit items and gold to bank if full + if (char.character.getItemCount() == char.character.inventory_max_items) { + if (!character_pos.eql(bank_pos)) { + try char.action_queue.append(.{ .move = bank_pos }); + } + + for (char.character.inventory.slots) |slot| { + if (slot.quantity == 0) continue; + + if (slot.id) |item_id| { + try char.action_queue.append(.{ + .depositItem = .{ .id = item_id, .quantity = @intCast(slot.quantity) } + }); + } + } + + if (char.character.gold > 0) { + try char.action_queue.append(.{ .depositGold = @intCast(char.character.gold) }); + } + + continue; } + + // Go to chickens + if (!character_pos.eql(chicken_pos)) { + try char.action_queue.append(.{ .move = chicken_pos }); + continue; + } + + // Attack chickens + try char.action_queue.append(.{ .attack = {} }); } } diff --git a/src/timegm.c b/src/timegm.c new file mode 100644 index 0000000..4da05d2 --- /dev/null +++ b/src/timegm.c @@ -0,0 +1,35 @@ +#include "time.h" + +// From: https://stackoverflow.com/a/58037981 + +// Algorithm: http://howardhinnant.github.io/date_algorithms.html +int days_from_epoch(int y, int m, int d) +{ + y -= m <= 2; + int era = y / 400; + int yoe = y - era * 400; // [0, 399] + int doy = (153 * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1; // [0, 365] + int doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096] + return era * 146097 + doe - 719468; +} + +// It does not modify broken-down time +time_t my_timegm(struct tm const* t) +{ + int year = t->tm_year + 1900; + int month = t->tm_mon; // 0-11 + if (month > 11) + { + year += month / 12; + month %= 12; + } + else if (month < 0) + { + int years_diff = (11 - month) / 12; + year -= years_diff; + month += 12 * years_diff; + } + int days_since_epoch = days_from_epoch(year, month + 1, t->tm_mday); + + return 60 * (60 * (24L * days_since_epoch + t->tm_hour) + t->tm_min) + t->tm_sec; +} diff --git a/src/timegm.h b/src/timegm.h new file mode 100644 index 0000000..58494c8 --- /dev/null +++ b/src/timegm.h @@ -0,0 +1,3 @@ +#include "time.h" + +time_t my_timegm(struct tm const* t);