commit 56de31d5c59f2b1ded1c1a81dbdb56e76ef8c97f Author: Rokas Puzonas Date: Wed Jul 31 01:12:40 2024 +0300 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c82b07 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +zig-cache +zig-out diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..f23c750 --- /dev/null +++ b/build.zig @@ -0,0 +1,35 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const exe = b.addExecutable(.{ + .name = "artificer", + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }); + + b.installArtifact(exe); + + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + + if (b.args) |args| { + run_cmd.addArgs(args); + } + + const run_step = b.step("run", "Run the app"); + run_step.dependOn(&run_cmd.step); + + const exe_unit_tests = b.addTest(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }); + + const run_exe_unit_tests = b.addRunArtifact(exe_unit_tests); + const test_step = b.step("test", "Run unit tests"); + test_step.dependOn(&run_exe_unit_tests.step); +} diff --git a/build.zig.zon b/build.zig.zon new file mode 100644 index 0000000..dadd4eb --- /dev/null +++ b/build.zig.zon @@ -0,0 +1,7 @@ +.{ + .name = "artificer", + .version = "0.1.0", + .minimum_zig_version = "0.12.0", + .dependencies = .{ }, + .paths = .{ "" }, +} diff --git a/src/artifacts.zig b/src/artifacts.zig new file mode 100644 index 0000000..12fb3b2 --- /dev/null +++ b/src/artifacts.zig @@ -0,0 +1,544 @@ +const std = @import("std"); +const json = std.json; +const Allocator = std.mem.Allocator; + +// Specification: https://api.artifactsmmo.com/docs + +// TODO: Convert 'expiration' date time strings into date time objects + +const ArtifactsAPI = @This(); + +pub const APIErrors = error { + RequestFailed, + ParseFailed +}; + +const ServerStatus = struct { + // TODO: Parse the rest of the fields + allocator: Allocator, + status: []const u8, + version: []const u8, + + 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; + + return ServerStatus{ + .allocator = allocator, + .status = try allocator.dupe(u8, status), + .version = try allocator.dupe(u8, version) + }; + } + + pub fn deinit(self: ServerStatus) void { + self.allocator.free(self.status); + self.allocator.free(self.version); + } +}; + +pub const Character = struct { + 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 = 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, + }; + } + }; + + 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 = 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"), + }; + } + }; + + 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, + + 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(); + + 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) + }; + } + + pub fn deinit(self: *Character) void { + var child_allocator = self.arena.child_allocator; + self.arena.deinit(); + child_allocator.destroy(self.arena); + } +}; + +pub const CharacterList = struct { + allocator: Allocator, + items: []Character, + + pub fn deinit(self: CharacterList) void { + for (self.items) |*char| { + char.deinit(); + } + self.allocator.free(self.items); + } +}; + +pub const Cooldown = struct { + pub const Reason = enum { + movement, + fight, + crafting, + gathering, + buy_ge, + sell_ge, + delete_item, + deposit_bank, + withdraw_bank, + equip, + unequip, + task, + recycling, + + fn parse(str: []const u8) ?Reason { + const eql = std.mem.eql; + if (eql(u8, str, "movement")) { + return .movement; + } else if (eql(u8, str, "fight")) { + return .fight; + } else if (eql(u8, str, "crafting")) { + return .crafting; + } else if (eql(u8, str, "gathering")) { + return .gathering; + } else if (eql(u8, str, "buy_ge")) { + return .buy_ge; + } else if (eql(u8, str, "sell_ge")) { + return .sell_ge; + } else if (eql(u8, str, "delete_item")) { + return .delete_item; + } else if (eql(u8, str, "deposit_bank")) { + return .deposit_bank; + } else if (eql(u8, str, "withdraw_bank")) { + return .withdraw_bank; + } else if (eql(u8, str, "equip")) { + return .equip; + } else if (eql(u8, str, "unequip")) { + return .unequip; + } else if (eql(u8, str, "task")) { + return .task; + } else if (eql(u8, str, "recycling")) { + return .recycling; + } else { + return null; + } + } + }; + + allocator: Allocator, + total_seconds: i64, + remaining_seconds: i64, + expiration: []u8, + reason: Reason, + + fn parse(allocator: Allocator, obj: json.ObjectMap) !Cooldown { + const reason = getJsonString(obj, "reason") orelse return error.MissingProperty; + return Cooldown{ + .allocator = allocator, + .total_seconds = getJsonInteger(obj, "totalSeconds") orelse return error.MissingProperty, + .remaining_seconds = getJsonInteger(obj, "remainingSeconds") orelse return error.MissingProperty, + .expiration = (try dupeJsonString(allocator, obj, "expiration")) orelse return error.MissingProperty, + .reason = Reason.parse(reason) orelse return error.UnknownReason + }; + } + + pub fn deinit(self: Cooldown) void { + self.allocator.free(self.expiration); + } +}; + +pub const FightResult = struct { + cooldown: Cooldown, + + fn parse(allocator: Allocator, obj: json.ObjectMap) !FightResult { + const cooldown = getJsonObject(obj, "cooldown") orelse return error.MissingProperty; + + return FightResult{ + .cooldown = try Cooldown.parse(allocator, cooldown) + }; + } + + pub fn deinit(self: FightResult) void { + self.cooldown.deinit(); + } +}; + +const ArtifactsFetchResult = struct { + arena: std.heap.ArenaAllocator, + status: std.http.Status, + body: ?json.Value = null, + + fn deinit(self: ArtifactsFetchResult) void { + self.arena.deinit(); + } +}; + +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, + .client = .{ .allocator = allocator }, + .server = server, + .server_uri = server_uri + }; +} + +fn fetch(self: *ArtifactsAPI, method: std.http.Method, path: []const u8) !ArtifactsFetchResult { + var uri = self.server_uri; + uri.path = .{ .raw = path }; + + var arena = std.heap.ArenaAllocator.init(self.allocator); + errdefer arena.deinit(); + + var response_storage = std.ArrayList(u8).init(arena.allocator()); + + var opts = std.http.Client.FetchOptions{ + .method = method, + .location = .{ .uri = uri }, + .response_storage = .{ .dynamic = &response_storage } + }; + + var authorization_header: ?[]u8 = null; + defer if (authorization_header) |str| self.allocator.free(str); + + if (self.token) |token| { + authorization_header = try std.fmt.allocPrint(self.allocator, "Bearer {s}", .{token}); + opts.headers.authorization = .{ .override = authorization_header.? }; + } + + const result = try self.client.fetch(opts); + + if (result.status != .ok) { + return ArtifactsFetchResult{ + .arena = arena, + .status = result.status + }; + } + + const response_body = response_storage.items; + + const parsed = try json.parseFromSliceLeaky(json.Value, arena.allocator(), response_body, .{ .allocate = .alloc_if_needed }); + if (parsed != json.Value.object) { + return APIErrors.ParseFailed; + } + + return ArtifactsFetchResult{ + .status = result.status, + .arena = arena, + .body = parsed.object.get("data") + }; +} + +pub fn setServer(self: *ArtifactsAPI, url: []const u8) !void { + const url_dupe = self.allocator.dupe(u8, url); + errdefer self.allocator.free(url_dupe); + + const uri = try std.Uri.parse(url_dupe); + + self.allocator.free(self.server); + self.server = url_dupe; + self.server_uri = uri; +} + +pub fn setToken(self: *ArtifactsAPI, token: ?[]const u8) !void { + var new_token: ?[]u8 = null; + if (token != null) { + new_token = try self.allocator.dupe(u8, token.?); + } + + if (self.token) |str| self.allocator.free(str); + self.token = new_token; +} + +fn getJsonString(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; +} + +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) { + return null; + } + + if (value.? != json.Value.integer) { + return null; + } + + return value.?.integer; +} + +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.?); +} + +pub fn getServerStatus(self: *ArtifactsAPI) !ServerStatus { + const result = try self.fetch(.GET, "/"); + defer result.deinit(); + + if (result.status != .ok) { + return APIErrors.RequestFailed; + } + if (result.body == null) { + return APIErrors.ParseFailed; + } + + const body = asJsonObject(result.body.?) orelse return APIErrors.ParseFailed; + return ServerStatus.parse(self.allocator, body) catch return APIErrors.ParseFailed; +} + +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); + + const result = try self.fetch(.GET, path); + defer result.deinit(); + + if (result.status != .ok) { + return APIErrors.RequestFailed; + } + if (result.body == null) { + return APIErrors.ParseFailed; + } + + const body = asJsonObject(result.body.?) orelse return APIErrors.ParseFailed; + return Character.parse(self.allocator, body) catch return APIErrors.ParseFailed; +} + +pub fn listMyCharacters(self: *ArtifactsAPI) !CharacterList { + const path = try std.fmt.allocPrint(self.allocator, "/my/characters", .{}); + defer self.allocator.free(path); + + const result = try self.fetch(.GET, path); + defer result.deinit(); + + if (result.status != .ok) { + return APIErrors.RequestFailed; + } + if (result.body == null) { + return APIErrors.ParseFailed; + } + + const body = asJsonArray(result.body.?) orelse return APIErrors.ParseFailed; + + var characters = try std.ArrayList(Character).initCapacity(self.allocator, body.items.len); + errdefer { + for (characters.items) |*char| { + char.deinit(); + } + characters.deinit(); + } + + 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; + + characters.appendAssumeCapacity(char); + } + + + return CharacterList{ + .allocator = self.allocator, + .items = characters.items + }; +} + +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); + + const result = try self.fetch(.POST, path); + defer result.deinit(); + + if (result.status != .ok) { + return APIErrors.RequestFailed; + } + if (result.body == null) { + return APIErrors.ParseFailed; + } + + const body = asJsonObject(result.body.?) orelse return APIErrors.ParseFailed; + return FightResult.parse(self.allocator, body) catch return APIErrors.ParseFailed; +} + +pub fn deinit(self: *ArtifactsAPI) void { + self.client.deinit(); + self.allocator.free(self.server); + if (self.token) |str| self.allocator.free(str); +} diff --git a/src/main.zig b/src/main.zig new file mode 100644 index 0000000..d376917 --- /dev/null +++ b/src/main.zig @@ -0,0 +1,26 @@ +const std = @import("std"); +const ArtifactsAPI = @import("artifacts.zig"); + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + var api = try ArtifactsAPI.init(allocator); + defer api.deinit(); + + { // Set auth token from environment variable + var env = try std.process.getEnvMap(allocator); + defer env.deinit(); + + const token = env.get("ARTIFACTS_TOKEN"); + try api.setToken(token); + } + + while (true) { + const result = try api.actionFight("Daisy"); + defer result.deinit(); + + std.time.sleep(std.time.ns_per_s * (@as(u64, @intCast(result.cooldown.remaining_seconds)) + 1)); + } +}