diff --git a/src/me/despawningbone/discordbot/command/games/Koohii.java b/src/me/despawningbone/discordbot/command/games/Koohii.java index 78acb94..b320d72 100644 --- a/src/me/despawningbone/discordbot/command/games/Koohii.java +++ b/src/me/despawningbone/discordbot/command/games/Koohii.java @@ -1,1874 +1,1934 @@ package me.despawningbone.discordbot.command.games; import java.util.ArrayList; import java.util.Collections; import java.io.BufferedReader; import java.io.IOException; +//edited by me for compatibility / bug fixes +//TODO clean up koohii to fit my own code style? rn it feels really awkward to use ngl + /** * pure java implementation of github.com/Francesco149/oppai-ng . * *
 * this is meant to be a single file library that's as portable and
 * easy to set up as possible for java projects that need
 * pp/difficulty calculation.
 *
 * when running the test suite, speed is roughly equivalent to the C
 * implementation, but peak memory usage is almost 80 times higher.
 * if you are on a system with limited resources or you don't want
 * to spend time installing and setting up java, you can use the C
 * implementation which doesn't depend on any third party software.
 * -----------------------------------------------------------------
 * usage:
 * put Koohii.java in your project's folder
 * -----------------------------------------------------------------
 * import java.io.BufferedReader;
 * import java.io.InputStreamReader;
 *
 * class Example {
 *
 * public static void main(String[] args) throws java.io.IOException
 * {
 *     BufferedReader stdin =
 *         new BufferedReader(new InputStreamReader(System.in)
 *     );
 *
 *     Koohii.Map beatmap = new Koohii.Parser().map(stdin);
 *     Koohii.DiffCalc stars = new Koohii.DiffCalc().calc(beatmap);
 *     System.out.printf("%s stars\n", stars.total);
 *
 *     Koohii.PPv2 pp = Koohii.PPv2(
 *         stars.aim, stars.speed, beatmap
 *     );
 *
 *     System.out.printf("%s pp\n", pp.total);
 * }
 *
 * }
 * -----------------------------------------------------------------
 * javac Example.java
 * cat /path/to/file.osu | java Example
 * -----------------------------------------------------------------
 * this is free and unencumbered software released into the
 * public domain.
 *
 * refer to the attached UNLICENSE or http://unlicense.org/
 * 
* * @author Franc[e]sco (lolisamurai@tfwno.gf) */ public final class Koohii { private Koohii() {} -public final int VERSION_MAJOR = 1; -public final int VERSION_MINOR = 2; +public final int VERSION_MAJOR = 2; +public final int VERSION_MINOR = 1; public final int VERSION_PATCH = 0; /** prints a message to stderr. */ public static void info(String fmt, Object... args) { System.err.printf(fmt, args); } /* ------------------------------------------------------------- */ /* math */ /** 2D vector with double values */ public static class Vector2 { public double x = 0.0, y = 0.0; public Vector2() {} public Vector2(Vector2 other) { this(other.x, other.y); } public Vector2(double x, double y) { this.x = x; this.y = y; } public String toString() { return String.format("(%s, %s)", x, y); } /** * this -= other . * @return this */ public Vector2 sub(Vector2 other) { x -= other.x; y -= other.y; return this; } /** * this *= value . * @return this */ public Vector2 mul(double value) { x *= value; y *= value; return this; } /** length (magnitude) of the vector. */ public double len() { return Math.sqrt(x * x + y * y); } /** dot product between two vectors, correlates with the angle */ public double dot(Vector2 other) { return x * other.x + y * other.y; } } /* ------------------------------------------------------------- */ /* beatmap utils */ public static final int MODE_STD = 0; public static final int MODE_TK = 1; public static class Circle { public Vector2 pos = new Vector2(); public String toString() { return pos.toString(); } } public static class Slider { public Vector2 pos = new Vector2(); /** distance travelled by one repetition. */ public double distance = 0.0; /** 1 = no repeats. */ public int repetitions = 1; public String toString() { return String.format( "{ pos=%s, distance=%s, repetitions=%d }", pos, distance, repetitions ); } } public static final int OBJ_CIRCLE = 1<<0; public static final int OBJ_SLIDER = 1<<1; public static final int OBJ_SPINNER = 1<<3; /** strain index for speed */ public final static int DIFF_SPEED = 0; /** strain index for aim */ public final static int DIFF_AIM = 1; public static class HitObject { /** start time in milliseconds. */ public double time = 0.0; public int type = OBJ_CIRCLE; /** an instance of Circle or Slider or null. */ public Object data = null; public Vector2 normpos = new Vector2(); public double angle = 0.0; public final double[] strains = new double[] { 0.0, 0.0 }; public boolean is_single = false; public double delta_time = 0.0; public double d_distance = 0.0; /** string representation of the type bitmask. */ public String typestr() { StringBuilder res = new StringBuilder(); if ((type & OBJ_CIRCLE) != 0) res.append("circle | "); if ((type & OBJ_SLIDER) != 0) res.append("slider | "); if ((type & OBJ_SPINNER) != 0) res.append("spinner | "); String result = res.toString(); return result.substring(0, result.length() - 3); } public String toString() { return String.format( "{ time=%s, type=%s, data=%s, normpos=%s, " + "strains=[ %s, %s ], is_single=%s }", time, typestr(), data, normpos, strains[0], strains[1], is_single ); } } public static class Timing { /** start time in milliseconds. */ public double time = 0.0; public double ms_per_beat = -100.0; /** if false, ms_per_beat is -100 * bpm_multiplier. */ public boolean change = false; } /** * the bare minimum beatmap data for difficulty calculation. * * this object can be reused for multiple beatmaps without * re-allocation by simply calling reset() */ public static class Map { public int format_version; public int mode; public String title, title_unicode; public String artist, artist_unicode; /** mapper name. */ public String creator; /** difficulty name. */ public String version; public int ncircles, nsliders, nspinners; public float hp, cs, od, ar; public float sv, tick_rate; public final ArrayList objects = new ArrayList(512); public final ArrayList tpoints = new ArrayList(32); public Map() { reset(); } /** clears the instance so that it can be reused. */ public void reset() { title = title_unicode = artist = artist_unicode = creator = version = ""; ncircles = nsliders = nspinners = 0; hp = cs = od = ar = 5.0f; sv = tick_rate = 1.0f; objects.clear(); tpoints.clear(); } public String toString() { StringBuilder sb = new StringBuilder(); for (HitObject obj : objects) { sb.append(obj); sb.append(", "); } String objs_str = sb.toString(); sb.setLength(0); for (Timing t : tpoints) { sb.append(t); sb.append(", "); } String timing_str = sb.toString(); return String.format( "beatmap { mode=%d, title=%s, title_unicode=%s, " + "artist=%s, artist_unicode=%s, creator=%s, " + "version=%s, ncircles=%d, nsliders=%d, nspinners=%d," + " hp=%s, cs=%s, od=%s, ar=%s, sv=%s, tick_rate=%s, " + "tpoints=[ %s ], objects=[ %s ] }", mode, title, title_unicode, artist, artist_unicode, creator, version, ncircles, nsliders, nspinners, hp, cs, od, ar, sv, tick_rate, timing_str, objs_str ); } + //CUSTOM: added taiko handling method which is way less complicated than this public int max_combo() { - int res = 0; - int tindex = -1; - double tnext = Double.NEGATIVE_INFINITY; - double px_per_beat = 0.0; - - for (HitObject obj : objects) - { - if ((obj.type & OBJ_SLIDER) == 0) + int res = 0; + switch(mode) { + case MODE_STD: + int tindex = -1; + double tnext = Double.NEGATIVE_INFINITY; + double px_per_beat = 0.0; + + for (HitObject obj : objects) { - /* non-sliders add 1 combo */ - ++res; - continue; - } + if ((obj.type & OBJ_SLIDER) == 0) + { + /* non-sliders add 1 combo */ + ++res; + continue; + } - /* keep track of the current timing point without - looping through all of them for every object */ - while (obj.time >= tnext) - { - ++tindex; + /* keep track of the current timing point without + looping through all of them for every object */ + while (obj.time >= tnext) + { + ++tindex; - if (tpoints.size() > tindex + 1) { - tnext = tpoints.get(tindex + 1).time; - } else { - tnext = Double.POSITIVE_INFINITY; - } + if (tpoints.size() > tindex + 1) { + tnext = tpoints.get(tindex + 1).time; + } else { + tnext = Double.POSITIVE_INFINITY; + } - Timing t = tpoints.get(tindex); + Timing t = tpoints.get(tindex); - double sv_multiplier = 1.0; + double sv_multiplier = 1.0; - if (!t.change && t.ms_per_beat < 0) { - sv_multiplier = -100.0 / t.ms_per_beat; - } + if (!t.change && t.ms_per_beat < 0) { + sv_multiplier = -100.0 / t.ms_per_beat; + } - px_per_beat = sv * 100.0 * sv_multiplier; - if (format_version < 8) { - px_per_beat /= sv_multiplier; + px_per_beat = sv * 100.0 * sv_multiplier; + if (format_version < 8) { + px_per_beat /= sv_multiplier; + } } - } - /* slider, we need to calculate slider ticks */ - Slider sl = (Slider)obj.data; + /* slider, we need to calculate slider ticks */ + Slider sl = (Slider)obj.data; - double num_beats = - (sl.distance * sl.repetitions) / px_per_beat; + double num_beats = + (sl.distance * sl.repetitions) / px_per_beat; - int ticks = (int) - Math.ceil( - (num_beats - 0.1) / sl.repetitions * tick_rate - ); + int ticks = (int) + Math.ceil( + (num_beats - 0.1) / sl.repetitions * tick_rate + ); - --ticks; - ticks *= sl.repetitions; - ticks += sl.repetitions + 1; + --ticks; + ticks *= sl.repetitions; + ticks += sl.repetitions + 1; - res += Math.max(0, ticks); - } + res += Math.max(0, ticks); + } + break; + case MODE_TK: + res = ncircles; + break; + default: //should never throw since this object cant get past init if its unsupported + throw new UnsupportedOperationException("unsupported gamemode"); + } return res; } } /* ------------------------------------------------------------- */ /* beatmap parser */ /* note: I just let parser throw built-in exceptions instead of error checking stuff because it's as good as making my own exception since you can check lastline/lastpos when you catch */ public static class Parser { /** last line touched. */ public String lastline; /** last line number touched. */ public int nline; /** last token touched. */ public String lastpos; /** true if the parsing completed successfully. */ public boolean done; /** * the parsed beatmap will be stored in this object. * willl persist throughout reset() calls and will be reused by * subsequent parse calls until changed. * @see Parser#reset */ public Map beatmap = null; private String section; /* current section */ private boolean ar_found = false; public Parser() { reset(); } private void reset() { lastline = lastpos = section = ""; nline = 0; done = false; if (beatmap != null) { beatmap.reset(); } } public String toString() { return String.format( "in line %d\n%s\n> %s", nline, lastline, lastpos ); } private void warn(String fmt, Object... args) { info("W: "); info(fmt, args); info("\n%s\n", this); } /** * trims v, sets lastpos to it and returns trimmed v. * should be used to access any string that can make the parser * fail */ private String setlastpos(String v) { v = v.trim(); lastpos = v; return v; } private String[] property() { String[] split = lastline.split(":", 2); split[0] = setlastpos(split[0]); if (split.length > 1) { split[1] = setlastpos(split[1]); } /* why does java have such inconsistent naming? ArrayList length is .size(), normal array length is .length, string length is .length(). why do I have to look up documentation for stuff that should have the same interface? */ return split; } private void metadata() { String[] p = property(); if (p[0].equals("Title")) { beatmap.title = p[1]; } else if (p[0].equals("TitleUnicode")) { beatmap.title_unicode = p[1]; } else if (p[0].equals("Artist")) { beatmap.artist = p[1]; } else if (p[0].equals("ArtistUnicode")) { beatmap.artist_unicode = p[1]; } else if (p[0].equals("Creator")) { beatmap.creator = p[1]; } else if (p[0].equals("Version")) { beatmap.version = p[1]; } } private void general() { String[] p = property(); if (p[0].equals("Mode")) { beatmap.mode = Integer.parseInt(setlastpos(p[1])); if (beatmap.mode != MODE_STD && beatmap.mode != MODE_TK) { - throw new UnsupportedOperationException( - "this gamemode is not yet supported" - ); + throw new UnsupportedOperationException( + "this gamemode is not yet supported" + ); } } } private void difficulty() { String[] p = property(); /* what's up with the redundant Float.parseFloat ?_? */ if (p[0].equals("CircleSize")) { beatmap.cs = Float.parseFloat(setlastpos(p[1])); } else if (p[0].equals("OverallDifficulty")) { beatmap.od = Float.parseFloat(setlastpos(p[1])); } else if (p[0].equals("ApproachRate")) { beatmap.ar = Float.parseFloat(setlastpos(p[1])); ar_found = true; } else if (p[0].equals("HPDrainRate")) { beatmap.hp = Float.parseFloat(setlastpos(p[1])); } else if (p[0].equals("SliderMultiplier")) { beatmap.sv = Float.parseFloat(setlastpos(p[1])); } else if (p[0].equals("SliderTickRate")) { beatmap.tick_rate = Float.parseFloat(setlastpos(p[1])); } } private void timing() { String[] s = lastline.split(","); if (s.length > 8) { warn("timing point with trailing values"); } Timing t = new Timing(); t.time = Double.parseDouble(setlastpos(s[0])); t.ms_per_beat = Double.parseDouble(setlastpos(s[1])); if (s.length >= 7) { t.change = !s[6].trim().equals("0"); } beatmap.tpoints.add(t); } private void objects() { String[] s = lastline.split(","); if (s.length > 11) { warn("object with trailing values"); } HitObject obj = new HitObject(); obj.time = Double.parseDouble(setlastpos(s[2])); obj.type = Integer.parseInt(setlastpos(s[3])); if ((obj.type & OBJ_CIRCLE) != 0) { ++beatmap.ncircles; Circle c = new Circle(); c.pos.x = Double.parseDouble(setlastpos(s[0])); c.pos.y = Double.parseDouble(setlastpos(s[1])); obj.data = c; } else if ((obj.type & OBJ_SPINNER) != 0) { ++beatmap.nspinners; } else if ((obj.type & OBJ_SLIDER) != 0) { ++beatmap.nsliders; Slider sli = new Slider(); sli.pos.x = Double.parseDouble(setlastpos(s[0])); sli.pos.y = Double.parseDouble(setlastpos(s[1])); sli.repetitions = Integer.parseInt(setlastpos(s[6])); sli.distance = Double.parseDouble(setlastpos(s[7])); obj.data = sli; } beatmap.objects.add(obj); } /** * calls reset() on beatmap and parses a osu file into it. * if beatmap is null, it will be initialized to a new Map * @return this.beatmap * @throws IOException */ public Map map(BufferedReader reader) throws IOException { String line = null; if (beatmap == null) { beatmap = new Map(); } reset(); while ((line = reader.readLine()) != null) { lastline = line; ++nline; /* comments (according to lazer) */ if (line.startsWith(" ") || line.startsWith("_")) { continue; } line = lastline = line.trim(); if (line.length() <= 0) { continue; } /* c++ style comments */ if (line.startsWith("//")) { continue; } /* [SectionName] */ if (line.startsWith("[")) { section = line.substring(1, line.length() - 1); continue; } try { if (section.equals("Metadata")) metadata(); else if (section.equals("General")) general(); else if (section.equals("Difficulty")) difficulty(); else if (section.equals("TimingPoints")) timing(); else if (section.equals("HitObjects")) objects(); else { int fmt_index = line.indexOf("file format v"); if (fmt_index < 0) { continue; } beatmap.format_version = Integer.parseInt( line.substring(fmt_index + 13) ); } } catch (NumberFormatException e) { warn("ignoring line with bad number"); } catch (ArrayIndexOutOfBoundsException e) { warn("ignoring malformed line"); } } if (!ar_found) { beatmap.ar = beatmap.od; } done = true; return beatmap; } /** * sets beatmap and returns map(reader) * @return this.beatmap * @throws IOException */ public Map map(BufferedReader reader, Map beatmap) throws IOException { this.beatmap = beatmap; return map(reader); } } /* ------------------------------------------------------------- */ /* mods utils */ public static final int MODS_NOMOD = 0; public static final int MODS_NF = 1<<0; public static final int MODS_EZ = 1<<1; public static final int MODS_TOUCH_DEVICE = 1<<2; public static final int MODS_TD = MODS_TOUCH_DEVICE; public static final int MODS_HD = 1<<3; public static final int MODS_HR = 1<<4; public static final int MODS_DT = 1<<6; public static final int MODS_HT = 1<<8; public static final int MODS_NC = 1<<9; public static final int MODS_FL = 1<<10; public static final int MODS_SO = 1<<12; -public static final int MODS_SD = 1<<5; -public static final int MODS_PF = 1<<14; public static final int MODS_SPEED_CHANGING = MODS_DT | MODS_HT | MODS_NC; public static final int MODS_MAP_CHANGING = MODS_HR | MODS_EZ | MODS_SPEED_CHANGING; /** @return a string representation of the mods, such as HDDT */ public static String mods_str(int mods) { StringBuilder sb = new StringBuilder(); if ((mods & MODS_NF) != 0) { sb.append("NF"); } if ((mods & MODS_EZ) != 0) { sb.append("EZ"); } if ((mods & MODS_TOUCH_DEVICE) != 0) { sb.append("TD"); } if ((mods & MODS_HD) != 0) { sb.append("HD"); } if ((mods & MODS_HR) != 0) { sb.append("HR"); } if ((mods & MODS_NC) != 0) { sb.append("NC"); } else if ((mods & MODS_DT) != 0) { sb.append("DT"); } if ((mods & MODS_HT) != 0) { sb.append("HT"); } if ((mods & MODS_FL) != 0) { sb.append("FL"); } if ((mods & MODS_SO) != 0) { sb.append("SO"); } - - if ((mods & MODS_PF) != 0) { - sb.append("PF"); - } else if ((mods & MODS_SD) != 0) { - sb.append("SD"); - } return sb.toString(); } /** @return mod bitmask from the string representation */ public static int mods_from_str(String str) { int mask = 0; while (str.length() > 0) { if (str.startsWith("NF")) mask |= MODS_NF; else if (str.startsWith("EZ")) mask |= MODS_EZ; else if (str.startsWith("TD")) mask |= MODS_TOUCH_DEVICE; else if (str.startsWith("HD")) mask |= MODS_HD; else if (str.startsWith("HR")) mask |= MODS_HR; else if (str.startsWith("DT")) mask |= MODS_DT; else if (str.startsWith("HT")) mask |= MODS_HT; else if (str.startsWith("NC")) mask |= MODS_NC; else if (str.startsWith("FL")) mask |= MODS_FL; else if (str.startsWith("SO")) mask |= MODS_SO; else { str = str.substring(1); continue; } str = str.substring(2); } return mask; } /** * beatmap stats with mods applied. * should be populated with the base beatmap stats and passed to * mods_apply which will modify the stats for the given mods */ public static class MapStats { float ar, od, cs, hp; public MapStats() { } public MapStats(Map map) { ar = map.ar; od = map.od; cs = map.cs; hp = map.hp; } /** * speed multiplier / music rate. * this doesn't need to be initialized before calling mods_apply */ float speed = 1.0f; } private static final double OD0_MS = 80; private static final double OD10_MS = 20; private static final double AR0_MS = 1800.0; private static final double AR5_MS = 1200.0; private static final double AR10_MS = 450.0; private static final double OD_MS_STEP = (OD0_MS - OD10_MS) / 10.0; private static final double AR_MS_STEP1 = (AR0_MS - AR5_MS) / 5.0; private static final double AR_MS_STEP2 = (AR5_MS - AR10_MS) / 5.0; private static final int APPLY_AR = 1<<0; private static final int APPLY_OD = 1<<1; private static final int APPLY_CS = 1<<2; private static final int APPLY_HP = 1<<3; /** * applies mods to mapstats. * *
 * Koohii.MapStats mapstats = new Koohii.MapStats();
 * mapstats.ar = 9;
 * Koohii.mods_apply(Koohii.MODS_DT, mapstats, Koohii.APPLY_AR);
 * // mapstats.ar is now 10.33, mapstats.speed is 1.5
 * 
* * @param mapstats the base beatmap stats * @param flags bitmask that specifies which stats to modify. only * the stats specified here need to be initialized in * mapstats. * @return mapstats * @see MapStats */ public static MapStats mods_apply(int mods, MapStats mapstats, int flags) { + mapstats.speed = 1.0f; + if ((mods & MODS_MAP_CHANGING) == 0) { return mapstats; } if ((mods & (MODS_DT | MODS_NC)) != 0) { mapstats.speed = 1.5f; } if ((mods & MODS_HT) != 0) { mapstats.speed *= 0.75f; } float od_ar_hp_multiplier = 1.0f; if ((mods & MODS_HR) != 0) { od_ar_hp_multiplier = 1.4f; } if ((mods & MODS_EZ) != 0) { od_ar_hp_multiplier *= 0.5f; } if ((flags & APPLY_AR) != 0) { mapstats.ar *= od_ar_hp_multiplier; /* convert AR into milliseconds window */ double arms = mapstats.ar < 5.0f ? AR0_MS - AR_MS_STEP1 * mapstats.ar : AR5_MS - AR_MS_STEP2 * (mapstats.ar - 5.0f); /* stats must be capped to 0-10 before HT/DT which brings them to a range of -4.42->11.08 for OD and -5->11 for AR */ arms = Math.min(AR0_MS, Math.max(AR10_MS, arms)); arms /= mapstats.speed; mapstats.ar = (float)( arms > AR5_MS ? (AR0_MS - arms) / AR_MS_STEP1 : 5.0 + (AR5_MS - arms) / AR_MS_STEP2 ); } if ((flags & APPLY_OD) != 0) { mapstats.od *= od_ar_hp_multiplier; double odms = OD0_MS - Math.ceil(OD_MS_STEP * mapstats.od); odms = Math.min(OD0_MS, Math.max(OD10_MS, odms)); odms /= mapstats.speed; mapstats.od = (float)((OD0_MS - odms) / OD_MS_STEP); } if ((flags & APPLY_CS) != 0) { if ((mods & MODS_HR) != 0) { mapstats.cs *= 1.3f; } if ((mods & MODS_EZ) != 0) { mapstats.cs *= 0.5f; } mapstats.cs = Math.min(10.0f, mapstats.cs); } if ((flags & APPLY_HP) != 0) { mapstats.hp = Math.min(10.0f, mapstats.hp * od_ar_hp_multiplier); } return mapstats; } /* ------------------------------------------------------------- */ /* difficulty calculator */ /** * arbitrary thresholds to determine when a stream is spaced * enough that it becomes hard to alternate. */ private final static double SINGLE_SPACING = 125.0; /** strain decay per interval. */ private final static double[] DECAY_BASE = { 0.3, 0.15 }; /** balances speed and aim. */ private final static double[] WEIGHT_SCALING = { 1400.0, 26.25 }; /** * max strains are weighted from highest to lowest, this is how * much the weight decays. */ private final static double DECAY_WEIGHT = 0.9; /** * strains are calculated by analyzing the map in chunks and taking * the peak strains in each chunk. this is the length of a strain * interval in milliseconds */ private final static double STRAIN_STEP = 400.0; /** non-normalized diameter where the small circle buff starts. */ private final static double CIRCLESIZE_BUFF_THRESHOLD = 30.0; /** global stars multiplier. */ private final static double STAR_SCALING_FACTOR = 0.0675; /** in osu! pixels */ private final static double PLAYFIELD_WIDTH = 512.0, PLAYFIELD_HEIGHT = 384.0; private final static Vector2 PLAYFIELD_CENTER = new Vector2( PLAYFIELD_WIDTH / 2.0, PLAYFIELD_HEIGHT / 2.0 ); /** * 50% of the difference between aim and speed is added to total * star rating to compensate for aim/speed only maps */ private final static double EXTREME_SCALING_FACTOR = 0.5; private final static double MIN_SPEED_BONUS = 75.0; private final static double MAX_SPEED_BONUS = 45.0; private final static double ANGLE_BONUS_SCALE = 90.0; private final static double AIM_TIMING_THRESHOLD = 107; private final static double SPEED_ANGLE_BONUS_BEGIN = 5 * Math.PI / 6; private final static double AIM_ANGLE_BONUS_BEGIN = Math.PI / 3; private static double d_spacing_weight(int type, double distance, double delta_time, double prev_distance, double prev_delta_time, double angle) { double strain_time = Math.max(delta_time, 50.0); double prev_strain_time = Math.max(prev_delta_time, 50.0); double angle_bonus; switch (type) { case DIFF_AIM: { double result = 0.0; if (!Double.isNaN(angle) && angle > AIM_ANGLE_BONUS_BEGIN) { angle_bonus = Math.sqrt( Math.max(prev_distance - ANGLE_BONUS_SCALE, 0.0) * Math.pow(Math.sin(angle - AIM_ANGLE_BONUS_BEGIN), 2.0) * Math.max(distance - ANGLE_BONUS_SCALE, 0.0) ); result = ( 1.5 * Math.pow(Math.max(0.0, angle_bonus), 0.99) / Math.max(AIM_TIMING_THRESHOLD, prev_strain_time) ); } double weighted_distance = Math.pow(distance, 0.99); return Math.max(result + weighted_distance / Math.max(AIM_TIMING_THRESHOLD, strain_time), weighted_distance / strain_time); } case DIFF_SPEED: { distance = Math.min(distance, SINGLE_SPACING); delta_time = Math.max(delta_time, MAX_SPEED_BONUS); double speed_bonus = 1.0; if (delta_time < MIN_SPEED_BONUS) { speed_bonus += Math.pow((MIN_SPEED_BONUS - delta_time) / 40.0, 2); } angle_bonus = 1.0; if (!Double.isNaN(angle) && angle < SPEED_ANGLE_BONUS_BEGIN) { double s = Math.sin(1.5 * (SPEED_ANGLE_BONUS_BEGIN - angle)); angle_bonus += Math.pow(s, 2) / 3.57; if (angle < Math.PI / 2.0) { angle_bonus = 1.28; if (distance < ANGLE_BONUS_SCALE && angle < Math.PI / 4.0) { angle_bonus += (1.0 - angle_bonus) * Math.min((ANGLE_BONUS_SCALE - distance) / 10.0, 1.0); + } else if (distance < ANGLE_BONUS_SCALE) { + angle_bonus += (1.0 - angle_bonus) * + Math.min((ANGLE_BONUS_SCALE - distance) / 10.0, 1.0) * + Math.sin((Math.PI / 2.0 - angle) * 4.0 / Math.PI); } - } else if (distance < ANGLE_BONUS_SCALE) { - angle_bonus += (1.0 - angle_bonus) * - Math.min((ANGLE_BONUS_SCALE - distance) / 10.0, 1.0) * - Math.sin((Math.PI / 2.0 - angle) * 4.0 / Math.PI); } } return ( (1 + (speed_bonus - 1) * 0.75) * angle_bonus * (0.95 + speed_bonus * Math.pow(distance / SINGLE_SPACING, 3.5)) ) / strain_time; } } throw new UnsupportedOperationException( "this difficulty type does not exist" ); } /** * calculates the strain for one difficulty type and stores it in * obj. this assumes that normpos is already computed. * this also sets is_single if type is DIFF_SPEED */ private static void d_strain(int type, HitObject obj, HitObject prev, double speed_mul) { double value = 0.0; double time_elapsed = (obj.time - prev.time) / speed_mul; double decay = Math.pow(DECAY_BASE[type], time_elapsed / 1000.0); obj.delta_time = time_elapsed; /* this implementation doesn't account for sliders */ if ((obj.type & (OBJ_SLIDER | OBJ_CIRCLE)) != 0) { double distance = new Vector2(obj.normpos).sub(prev.normpos).len(); obj.d_distance = distance; if (type == DIFF_SPEED) { obj.is_single = distance > SINGLE_SPACING; } value = d_spacing_weight(type, distance, time_elapsed, prev.d_distance, prev.delta_time, obj.angle); value *= WEIGHT_SCALING[type]; } obj.strains[type] = prev.strains[type] * decay + value; } /** * difficulty calculator, can be reused in subsequent calc() calls. */ public static class DiffCalc { /** star rating. */ public double total; /** aim stars. */ public double aim; /** aim difficulty (used to calc length bonus) */ public double aim_difficulty; /** aim length bonus (unused at the moment) */ public double aim_length_bonus; /** speed stars. */ public double speed; /** speed difficulty (used to calc length bonus) */ public double speed_difficulty; /** speed length bonus (unused at the moment) */ public double speed_length_bonus; /** * number of notes that are considered singletaps by the * difficulty calculator. */ public int nsingles; /** * number of taps slower or equal to the singletap threshold * value. */ public int nsingles_threshold; /** * the beatmap we want to calculate the difficulty for. * must be set or passed to calc() explicitly. * persists across calc() calls unless it's changed or explicity * passed to calc() * @see DiffCalc#calc(Koohii.Map, int, double) * @see DiffCalc#calc(Koohii.Map, int) * @see DiffCalc#calc(Koohii.Map) */ public Map beatmap = null; private double speed_mul; private final ArrayList strains = new ArrayList(512); public DiffCalc() { reset(); } /** sets up the instance for re-use by resetting fields. */ private void reset() { total = aim = speed = 0.0; nsingles = nsingles_threshold = 0; speed_mul = 1.0; } public String toString() { return String.format("%s stars (%s aim, %s speed)", total, aim, speed); } private static double length_bonus(double stars, double difficulty) { return ( 0.32 + 0.5 * (Math.log10(difficulty + stars) - Math.log10(stars)) ); } private class DiffValues { public double difficulty, total; public DiffValues(double difficulty, double total) { this.difficulty = difficulty; this.total = total; } }; private DiffValues calc_individual(int type) { strains.clear(); double strain_step = STRAIN_STEP * speed_mul; - double interval_end = strain_step; + /* the first object doesn't generate a strain + * so we begin with an incremented interval end */ + double interval_end = ( + Math.ceil(beatmap.objects.get(0).time / strain_step) + * strain_step + ); double max_strain = 0.0; /* calculate all strains */ for (int i = 0; i < beatmap.objects.size(); ++i) { HitObject obj = beatmap.objects.get(i); HitObject prev = i > 0 ? beatmap.objects.get(i - 1) : null; if (prev != null) { d_strain(type, obj, prev, speed_mul); } while (obj.time > interval_end) { /* add max strain for this interval */ strains.add(max_strain); if (prev != null) { /* decay last object's strains until the next interval and use that as the initial max strain */ double decay = Math.pow(DECAY_BASE[type], (interval_end - prev.time) / 1000.0); max_strain = prev.strains[type] * decay; } else { max_strain = 0.0; } interval_end += strain_step; } max_strain = Math.max(max_strain, obj.strains[type]); } + /* don't forget to add the last strain interval */ + strains.add(max_strain); + /* weigh the top strains sorted from highest to lowest */ double weight = 1.0; double total = 0.0; double difficulty = 0.0; Collections.sort(strains, Collections.reverseOrder()); for (Double strain : strains) { total += Math.pow(strain, 1.2); difficulty += strain * weight; weight *= DECAY_WEIGHT; } return new DiffValues(difficulty, total); } /** * default value for singletap_threshold. * @see DiffCalc#calc */ public final static double DEFAULT_SINGLETAP_THRESHOLD = 125.0; /** * calculates beatmap difficulty and stores it in total, aim, * speed, nsingles, nsingles_speed fields. * @param singletap_threshold the smallest milliseconds interval * that will be considered singletappable. for example, * 125ms is 240 1/2 singletaps ((60000 / 240) / 2) * @return self */ public DiffCalc calc(int mods, double singletap_threshold) { reset(); MapStats mapstats = new MapStats(); mapstats.cs = beatmap.cs; mods_apply(mods, mapstats, APPLY_CS); speed_mul = mapstats.speed; double radius = (PLAYFIELD_WIDTH / 16.0) * (1.0 - 0.7 * (mapstats.cs - 5.0) / 5.0); /* positions are normalized on circle radius so that we can calc as if everything was the same circlesize */ double scaling_factor = 52.0 / radius; if (radius < CIRCLESIZE_BUFF_THRESHOLD) { scaling_factor *= 1.0 + Math.min(CIRCLESIZE_BUFF_THRESHOLD - radius, 5.0) / 50.0; } Vector2 normalized_center = new Vector2(PLAYFIELD_CENTER).mul(scaling_factor); HitObject prev1 = null; HitObject prev2 = null; int i = 0; /* calculate normalized positions */ for (HitObject obj : beatmap.objects) { if ((obj.type & OBJ_SPINNER) != 0) { obj.normpos = new Vector2(normalized_center); } else { Vector2 pos; if ((obj.type & OBJ_SLIDER) != 0) { pos = ((Slider)obj.data).pos; } else if ((obj.type & OBJ_CIRCLE) != 0) { pos = ((Circle)obj.data).pos; } else { info( "W: unknown object type %08X\n", obj.type ); pos = new Vector2(); } obj.normpos = new Vector2(pos).mul(scaling_factor); } if (i >= 2) { Vector2 v1 = new Vector2(prev2.normpos).sub(prev1.normpos); Vector2 v2 = new Vector2(obj.normpos).sub(prev1.normpos); double dot = v1.dot(v2); double det = v1.x * v2.y - v1.y * v2.x; obj.angle = Math.abs(Math.atan2(det, dot)); } else { obj.angle = Double.NaN; } prev2 = prev1; prev1 = obj; ++i; } /* speed and aim stars */ DiffValues aimvals = calc_individual(DIFF_AIM); aim = aimvals.difficulty; aim_difficulty = aimvals.total; aim_length_bonus = length_bonus(aim, aim_difficulty); DiffValues speedvals = calc_individual(DIFF_SPEED); speed = speedvals.difficulty; speed_difficulty = speedvals.total; speed_length_bonus = length_bonus(speed, speed_difficulty); aim = Math.sqrt(aim) * STAR_SCALING_FACTOR; speed = Math.sqrt(speed) * STAR_SCALING_FACTOR; if ((mods & MODS_TOUCH_DEVICE) != 0) { aim = Math.pow(aim, 0.8); } /* total stars */ total = aim + speed + Math.abs(speed - aim) * EXTREME_SCALING_FACTOR; /* singletap stats */ for (i = 1; i < beatmap.objects.size(); ++i) { HitObject prev = beatmap.objects.get(i - 1); HitObject obj = beatmap.objects.get(i); if (obj.is_single) { ++nsingles; } if ((obj.type & (OBJ_CIRCLE | OBJ_SLIDER)) == 0) { continue; } double interval = (obj.time - prev.time) / speed_mul; if (interval >= singletap_threshold) { ++nsingles_threshold; } } return this; } /** * @return calc(mods, DEFAULT_SINGLETAP_THRESHOLD) * @see DiffCalc#calc(int, double) * @see DiffCalc#DEFAULT_SINGLETAP_THRESHOLD */ public DiffCalc calc(int mods) { return calc(mods, DEFAULT_SINGLETAP_THRESHOLD); } /** * @return calc(MODS_NOMOD, DEFAULT_SINGLETAP_THRESHOLD) * @see DiffCalc#calc(int, double) * @see DiffCalc#DEFAULT_SINGLETAP_THRESHOLD */ public DiffCalc calc() { return calc(MODS_NOMOD, DEFAULT_SINGLETAP_THRESHOLD); } /** * sets beatmap field and calls * calc(mods, singletap_threshold). * @see DiffCalc#calc(int, double) */ public DiffCalc calc(Map beatmap, int mods, double singletap_threshold) { this.beatmap = beatmap; return calc(mods, singletap_threshold); } /** * sets beatmap field and calls * calc(mods, DEFAULT_SINGLETAP_THRESHOLD). * @see DiffCalc#calc(int, double) * @see DiffCalc#DEFAULT_SINGLETAP_THRESHOLD */ public DiffCalc calc(Map beatmap, int mods) { return calc(beatmap, mods, DEFAULT_SINGLETAP_THRESHOLD); } /** * sets beatmap field and calls * calc(MODS_NOMOD, DEFAULT_SINGLETAP_THRESHOLD). * @see DiffCalc#calc(int, double) * @see DiffCalc#DEFAULT_SINGLETAP_THRESHOLD */ public DiffCalc calc(Map beatmap) { return calc(beatmap, MODS_NOMOD, DEFAULT_SINGLETAP_THRESHOLD); } } /* ------------------------------------------------------------- */ /* acc calc */ public static class Accuracy { public int n300 = 0, n100 = 0, n50 = 0, nmisses = 0; public Accuracy() {} /** * @param n300 the number of 300s, if -1 it will be calculated * from the object count in Accuracy#value(int). */ public Accuracy(int n300, int n100, int n50, int nmisses) { this.n300 = n300; this.n100 = n100; this.n50 = n50; this.nmisses = nmisses; } /** * calls Accuracy(-1, n100, n50, nmisses) . * @see Accuracy#Accuracy(int, int, int, int) */ public Accuracy(int n100, int n50, int nmisses) { this(-1, n100, n50, nmisses); } /** * calls Accuracy(-1, n100, n50, 0) . * @see Accuracy#Accuracy(int, int, int, int) */ public Accuracy(int n100, int n50) { this(-1, n100, n50, 0); } /** * calls Accuracy(-1, n100, 0, 0) . * @see Accuracy#Accuracy(int, int, int, int) */ public Accuracy(int n100) { this(-1, n100, 0, 0); } /** * rounds to the closest amount of 300s, 100s, 50s for a given * accuracy percentage. * @param nobjects the total number of hits (n300 + n100 + n50 + * nmisses) */ - public Accuracy(double acc_percent, int nobjects, int nmisses) + public Accuracy(double acc_percent, int nmisses, Map beatmap) { + int nobjects = beatmap.objects.size(); nmisses = Math.min(nobjects, nmisses); int max300 = nobjects - nmisses; double maxacc = - new Accuracy(max300, 0, 0, nmisses).value() * 100.0; + new Accuracy(max300, 0, 0, nmisses).value(beatmap.mode) * 100.0; acc_percent = Math.max(0.0, Math.min(maxacc, acc_percent)); - /* just some black magic maths from wolfram alpha */ - n100 = (int) - Math.round( - -3.0 * - ((acc_percent * 0.01 - 1.0) * nobjects + nmisses) * - 0.5 - ); - - if (n100 > max300) - { - /* acc lower than all 100s, use 50s */ - n100 = 0; - - n50 = (int) - Math.round( - -6.0 * - ((acc_percent * 0.01 - 1.0) * nobjects + - nmisses) * 0.5 - ); - - n50 = Math.min(max300, n50); + /* + * CUSTOM: new logic below for computing n100 and n50 since vanilla koohii is kinda inaccurate + * also changed params for cross mode acc derivation + */ + + switch(beatmap.mode) { + case 0: //osu + n100 = (int) Math.min(max300, //guard against invalid n100s + Math.ceil( //instead of round to get highest n100 + -3.0 * + ((acc_percent * 0.01 - 1.0) * nobjects + nmisses) * + 0.5 + )); + //solve x (y = (x*100 + (n - x - m)*300)/(n*300)) where y = acc, n = nobjects, x = n100, m = nmisses + + //then use n50 to fill in the gaps; need round + n50 = (int) Math.round(-2/5.0 * (3 * nmisses + 3 * nobjects * (acc_percent * 0.01 - 1) + 2 * n100)); + //solve z (y = (z * 50 + x*100 + (n - z - x - m)*300)/(n*300)) where everything is same as above and z = n50 + + //use lowest discrepancy with n100-- until no more or until < epsilon = 0.002% + double lowestDiscrepancy = 1; + int n50_ = n50, n100_ = n100; + while((lowestDiscrepancy > 0.00002 || (n100_ + n50_) > max300) && n100_ > 0) { + //get current discrepancy + double discrepancy = Math.abs(((acc_percent/100.0) - (n50_*50.0 + n100_*100.0 + (nobjects - n100_ - n50_ - nmisses)*300.0)/(nobjects*300.0))); + + //only update if better and valid + if(discrepancy < lowestDiscrepancy && (n100_ + n50_) <= max300 && n50_ >= 0) { + lowestDiscrepancy = discrepancy; + n50 = n50_; n100 = n100_; + } + + //tick n100 down by 1 and calc new n50 + n50_ = (int) Math.round(-2/5.0 * (3 * nmisses + 3 * nobjects * (acc_percent * 0.01 - 1) + 2 * --n100_)); + } + + n300 = nobjects - n100 - n50 - nmisses; + break; + case 1: //taiko + n50 = 0; //taiko has no n50 + n100 = (int) Math.min(max300, //guard just like above + Math.round((1 - ((double) nmisses)/nobjects - acc_percent/100) * 2 * nobjects)); + + n300 = beatmap.ncircles - n100 - nmisses; //taiko only cares about circles + break; + default: + throw new IllegalArgumentException("This gamemode is not yet supported."); } - - n300 = nobjects - n100 - n50 - nmisses; + + if(n300 < 0) + throw new IllegalArgumentException("Impossible accuracy inputted. Did you forget to input misses?"); } /** * @param nobjects the total number of hits (n300 + n100 + n50 + * nmiss). if -1, n300 must have been set and * will be used to deduce this value. * @return the accuracy value (0.0-1.0) */ - public double value(int nobjects) + public double value(int nobjects, int mode) { if (nobjects < 0 && n300 < 0) { throw new IllegalArgumentException( "either nobjects or n300 must be specified" ); } - int n300_ = n300 > 0 ? n300 : + int n300_ = n300 >= 0 ? n300 : //CUSTOM: the guard is <, yet this checks > which makes no sense; changed to >= nobjects - n100 - n50 - nmisses; if (nobjects < 0) { nobjects = n300_ + n100 + n50 + nmisses; } - double res = (n50 * 50.0 + n100 * 100.0 + n300_ * 300.0) / - (nobjects * 300.0); + /* + * CUSTOM: new logic below for cross mode acc derivation with mode param + */ + + double res; + switch(mode) { + case 0: //osu + res = (n50 * 50.0 + n100 * 100.0 + n300_ * 300.0) / (nobjects * 300.0); + break; + case 1: //taiko + res = (n100 * 0.5 + n300) / (double) (nmisses + n100 + n300_); + break; + default: + throw new IllegalArgumentException("This gamemode is not yet supported."); + } return Math.max(0, Math.min(res, 1.0)); } /** * calls value(-1) . * @see Accuracy#value(int) */ - public double value() { - return value(-1); + public double value(int mode) { + return value(-1, mode); } } /* ------------------------------------------------------------- */ /* pp calc */ /* base pp value for stars, used internally by ppv2 */ private static double pp_base(double stars) { return Math.pow(5.0 * Math.max(1.0, stars / 0.0675) - 4.0, 3.0) / 100000.0; } /** * parameters to be passed to PPv2. * aim_stars, speed_stars, max_combo, nsliders, ncircles, nobjects, * base_ar, base_od are required. -* @see PPv2#PPv2(Koohii.PPv2Parameters) +* @see PPv2#PPv2(Koohii.PlayParameters) +* +* CUSTOM: no longer just used by PPv2, renamed to playparameters instead - it records the play info for pp calculation */ -public static class PPv2Parameters +public static class PlayParameters { /** * if not null, max_combo, nsliders, ncircles, nobjects, * base_ar, base_od will be obtained from this beatmap. */ public Map beatmap = null; public double aim_stars = 0.0; public double speed_stars = 0.0; public int max_combo = 0; public int nsliders = 0, ncircles = 0, nobjects = 0; /** the base AR (before applying mods). */ public float base_ar = 5.0f; /** the base OD (before applying mods). */ public float base_od = 5.0f; /** gamemode. */ public int mode = MODE_STD; /** the mods bitmask, same as osu! api, see MODS_* constants */ public int mods = MODS_NOMOD; /** * the maximum combo achieved, if -1 it will default to * max_combo - nmiss . */ public int combo = -1; /** * number of 300s, if -1 it will default to * nobjects - n100 - n50 - nmiss . */ public int n300 = -1; public int n100 = 0, n50 = 0, nmiss = 0; /** scorev1 (1) or scorev2 (2). */ public int score_version = 1; } public static class PPv2 { public double total, aim, speed, acc; public Accuracy computed_accuracy; - public double real_acc; /** * calculates ppv2, results are stored in total, aim, speed, * acc, acc_percent. - * @see PPv2Parameters + * @see PlayParameters */ private PPv2(double aim_stars, double speed_stars, int max_combo, int nsliders, int ncircles, int nobjects, float base_ar, float base_od, int mode, int mods, int combo, int n300, int n100, int n50, int nmiss, int score_version, Map beatmap) { if (beatmap != null) { mode = beatmap.mode; base_ar = beatmap.ar; base_od = beatmap.od; max_combo = beatmap.max_combo(); nsliders = beatmap.nsliders; ncircles = beatmap.ncircles; - if(nobjects == 0) { - nobjects = beatmap.objects.size(); - } + nobjects = beatmap.objects.size(); } if (mode != MODE_STD) { throw new UnsupportedOperationException( "this gamemode is not yet supported" ); } if (max_combo <= 0) { info("W: max_combo <= 0, changing to 1\n"); max_combo = 1; } if (combo < 0) { combo = max_combo - nmiss; } if (n300 < 0) { n300 = nobjects - n100 - n50 - nmiss; } /* accuracy -------------------------------------------- */ computed_accuracy = new Accuracy(n300, n100, n50, nmiss); - double accuracy = computed_accuracy.value(nobjects); - real_acc = accuracy; + double accuracy = computed_accuracy.value(0); + double real_acc = accuracy; + + /* scorev1 ignores sliders since they are free 300s + and for some reason also ignores spinners */ + int nspinners = nobjects - nsliders - ncircles; switch (score_version) { case 1: - /* scorev1 ignores sliders since they are free 300s - and for some reason also ignores spinners */ - int nspinners = nobjects - nsliders - ncircles; - - try { - real_acc = new Accuracy(n300 - nsliders - nspinners, - n100, n50, nmiss).value(); - } catch (IllegalArgumentException e) { - info(" Invalid values, using computed accuracy...\n"); - }; + real_acc = new Accuracy(Math.max(0, n300 - nsliders - nspinners), //CUSTOM: guard against going below zero + n100, n50, nmiss).value(0); real_acc = Math.max(0.0, real_acc); break; case 2: ncircles = nobjects; break; default: throw new UnsupportedOperationException( String.format("unsupported scorev%d", score_version) ); } /* global values --------------------------------------- */ double nobjects_over_2k = nobjects / 2000.0; double length_bonus = 0.95 + 0.4 * Math.min(1.0, nobjects_over_2k); if (nobjects > 2000) { length_bonus += Math.log10(nobjects_over_2k) * 0.5; } - double miss_penality = Math.pow(0.97, nmiss); + double miss_penality_aim = + 0.97 * Math.pow(1 - Math.pow((double)nmiss / nobjects, 0.775), nmiss); + double miss_penality_speed = + 0.97 * Math.pow(1 - Math.pow((double)nmiss / nobjects, 0.775), Math.pow(nmiss, 0.875)); double combo_break = Math.pow(combo, 0.8) / Math.pow(max_combo, 0.8); /* calculate stats with mods */ MapStats mapstats = new MapStats(); mapstats.ar = base_ar; mapstats.od = base_od; mods_apply(mods, mapstats, APPLY_AR | APPLY_OD); /* ar bonus -------------------------------------------- */ - double ar_bonus = 1.0; + double ar_bonus = 0.0; if (mapstats.ar > 10.33) { - ar_bonus += 0.3 * (mapstats.ar - 10.33); + ar_bonus += 0.4 * (mapstats.ar - 10.33); } else if (mapstats.ar < 8.0) { ar_bonus += 0.01 * (8.0 - mapstats.ar); } /* aim pp ---------------------------------------------- */ aim = pp_base(aim_stars); aim *= length_bonus; - aim *= miss_penality; + if (nmiss > 0) { + aim *= miss_penality_aim; + } aim *= combo_break; - aim *= ar_bonus; + aim *= 1.0 + Math.min(ar_bonus, ar_bonus * (nobjects / 1000.0)); double hd_bonus = 1.0; if ((mods & MODS_HD) != 0) { hd_bonus *= 1.0 + 0.04 * (12.0 - mapstats.ar); } aim *= hd_bonus; if ((mods & MODS_FL) != 0) { double fl_bonus = 1.0 + 0.35 * Math.min(1.0, nobjects / 200.0); if (nobjects > 200) { fl_bonus += 0.3 * Math.min(1.0, (nobjects - 200) / 300.0); } if (nobjects > 500) { fl_bonus += (nobjects - 500) / 1200.0; } aim *= fl_bonus; } double acc_bonus = 0.5 + accuracy / 2.0; double od_squared = mapstats.od * mapstats.od; double od_bonus = 0.98 + od_squared / 2500.0; aim *= acc_bonus; aim *= od_bonus; /* speed pp -------------------------------------------- */ speed = pp_base(speed_stars); speed *= length_bonus; - speed *= miss_penality; + if (nmiss > 0) { + speed *= miss_penality_speed; + } speed *= combo_break; if (mapstats.ar > 10.33) { - speed *= ar_bonus; + speed *= 1.0 + Math.min(ar_bonus, ar_bonus * (nobjects / 1000.0)); } speed *= hd_bonus; /* similar to aim acc and od bonus */ - speed *= 0.02 + accuracy; - speed *= 0.96 + od_squared / 1600.0; + speed *= (0.95 + od_squared / 750.0) * + Math.pow(accuracy, (14.5 - Math.max(mapstats.od, 8)) / 2); + speed *= Math.pow(0.98, n50 < nobjects / 500.0 ? 0.00 : n50 - nobjects / 500.0); /* acc pp ---------------------------------------------- */ acc = Math.pow(1.52163, mapstats.od) * Math.pow(real_acc, 24.0) * 2.83; acc *= Math.min(1.15, Math.pow(ncircles / 1000.0, 0.3)); if ((mods & MODS_HD) != 0) { acc *= 1.08; } if ((mods & MODS_FL) != 0) { acc *= 1.02; } /* total pp -------------------------------------------- */ double final_multiplier = 1.12; if ((mods & MODS_NF) != 0) { - final_multiplier *= 0.90; + final_multiplier *= Math.max(0.9, 1.0 - 0.2 * nmiss); } if ((mods & MODS_SO) != 0) { - final_multiplier *= 0.95; + final_multiplier *= 1.0 - Math.pow((double)nspinners / nobjects, 0.85); } total = Math.pow( Math.pow(aim, 1.1) + Math.pow(speed, 1.1) + Math.pow(acc, 1.1), 1.0 / 1.1 ) * final_multiplier; } - /** @see PPv2Parameters */ - public PPv2(PPv2Parameters p) + /** @see PlayParameters */ + public PPv2(PlayParameters p) { this(p.aim_stars, p.speed_stars, p.max_combo, p.nsliders, p.ncircles, p.nobjects, p.base_ar, p.base_od, p.mode, p.mods, p.combo, p.n300, p.n100, p.n50, p.nmiss, p.score_version, p.beatmap); } /** * simplest possible call, calculates ppv2 for SS scorev1. - * @see PPv2#PPv2(Koohii.PPv2Parameters) + * @see PPv2#PPv2(Koohii.PlayParameters) */ public PPv2(double aim_stars, double speed_stars, Map b) { this(aim_stars, speed_stars, -1, b.nsliders, b.ncircles, b.objects.size(), b.ar, b.od, b.mode, MODS_NOMOD, -1, -1, 0, 0, 0, 1, b); } } +/** + * CUSTOM: Taiko support added by me + * Heavily modified from https://mon.im/taikopp/ to fit Koohii + */ + public static class TaikoPP { public Map map; public double stars; public int totalHits; public int misses; public int combo; public int mods; public double acc; public double computedAcc; public double rawod; public double strain; public double pp; - public TaikoPP(double stars, double acc, PPv2Parameters p) { - this(stars, p.beatmap.ncircles + p.beatmap.nsliders, p.mods, acc, p.nmiss, p.beatmap); - if(p.combo == p.max_combo) p.combo = this.totalHits; - p.nobjects = p.max_combo = this.totalHits; - p.n50 = 0; - p.n100 = (int) Math.round((1 - ((double) misses/p.nobjects) - acc/100) * 2 * p.nobjects); - if(p.n100 < 0) { - p.n100 = 0; - this.acc = (p.nobjects - p.nmiss) / (double) (p.nobjects); - } - p.n300 = p.nobjects - p.n100 - p.nmiss; - } - - public TaikoPP(double stars, PPv2Parameters p) { //DO NOT USE THIS WITH DIFFCALC - this(stars, p.beatmap.ncircles + p.beatmap.nsliders, p.mods, (p.n100 * 0.5 + p.n300) / (double) (p.nmiss + p.n100 + p.n300), p.nmiss, p.beatmap); + //technically taiko dont have aim stars and the only other star val is speed stars, so imma just borrow that variable + public TaikoPP(PlayParameters p) { + this(p.speed_stars, p.combo, p.mods, new Accuracy(p.n300, p.n100, p.n50, p.nmiss).value(1), p.nmiss, p.beatmap); } public TaikoPP(double stars, int combo, int mods, double acc, int misses, Map beatmap) { this.map = beatmap; this.stars = stars; - this.totalHits = beatmap.ncircles + beatmap.nsliders; + this.totalHits = beatmap.ncircles; this.misses = misses; this.combo = combo; this.mods = mods; this.acc = acc; this.rawod = beatmap.od; if(this.acc > 1) { this.acc /= 100; } this.strain = computeStrain(this.combo, this.misses, this.acc); this.computedAcc = computeAcc(this.acc); this.pp = computeTotal(this.strain, this.computedAcc); } private double computeTotal(double strain, double acc) { double multiplier = 1.1; if ((mods & MODS_NF) != 0) multiplier *= 0.9; if ((mods & MODS_HD) != 0) multiplier *= 1.1; return Math.pow(Math.pow(strain, 1.1) + Math.pow(acc, 1.1), 1.0 / 1.1) * multiplier; } private double computeStrain(int combo, int misses, double acc) { double strainValue = Math.pow(5 * Math.max(1, this.stars / 0.0075) - 4, 2) / 100000; double lengthBonus = 1 + 0.1 * Math.min(1, (this.totalHits / 1500.0)); strainValue *= lengthBonus * Math.pow(0.985, misses); - if(combo > 0) strainValue *= Math.min(Math.pow(this.totalHits, 0.5) / Math.pow(combo, 0.5), 1); + //if(combo > 0) strainValue *= Math.min(Math.pow(this.totalHits, 0.5) / Math.pow(combo, 0.5), 1); if ((mods & MODS_HD) != 0) strainValue *= 1.025; if ((mods & MODS_FL) != 0) strainValue *= (1.05 * lengthBonus); strainValue *= acc; return strainValue; } private double computeAcc(double acc) { double hitWindow300 = hitWindow(); if(hitWindow300 <= 0) return -1; return Math.pow(150 / hitWindow300, 1.1) * Math.pow(acc, 15) * 22 * Math.min(Math.pow(this.totalHits / 1500.0, 0.3), 1.15); } private double hitWindow() { double od = scaleOD(); double max = 20, min = 50; double result = Math.floor(min + (max - min) * od / 10) - 0.5; if ((mods & MODS_HT) != 0) result /= 0.75; - if ((mods & MODS_DT) != 0) result /= 1.5; + if ((mods & (MODS_DT | MODS_NC)) != 0) result /= 1.5; return Math.round(result * 100) / 100.0; } private double scaleOD() { double od = this.rawod; if ((mods & MODS_EZ) != 0) od /= 2; if ((mods & MODS_HR) != 0) od *= 1.4; od = Math.max(Math.min(od, 10), 0); return od; } + //just realized how weird this method was implemented with how it modifies stats similar to pass by ref and still returns that object + //but hey imma stick with it for consistency's sake + public MapStats taiko_mods_apply(int mods, MapStats stats) { + stats.od = (float) scaleOD(); + mods_apply(mods, stats, APPLY_HP); //hp scaling works the same in taiko, just reuse + return stats; + } + } } /* public final class Koohii */ diff --git a/src/me/despawningbone/discordbot/command/games/Osu.java b/src/me/despawningbone/discordbot/command/games/Osu.java index ee9fc26..a640424 100644 --- a/src/me/despawningbone/discordbot/command/games/Osu.java +++ b/src/me/despawningbone/discordbot/command/games/Osu.java @@ -1,1174 +1,1159 @@ package me.despawningbone.discordbot.command.games; import java.awt.Color; import java.io.BufferedReader; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.net.MalformedURLException; import java.net.URL; import java.net.URLConnection; import java.net.URLEncoder; import java.sql.Connection; import java.sql.ResultSet; import java.sql.SQLException; import java.text.DateFormat; import java.text.DecimalFormat; import java.text.SimpleDateFormat; import java.time.OffsetDateTime; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Comparator; -import java.util.HashMap; import java.util.List; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; import javax.xml.bind.DatatypeConverter; import org.apache.commons.lang3.exception.ExceptionUtils; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import org.json.JSONTokener; import org.knowm.xchart.BitmapEncoder; import org.knowm.xchart.XYChart; import org.knowm.xchart.XYChartBuilder; import org.knowm.xchart.BitmapEncoder.BitmapFormat; import org.knowm.xchart.XYSeries.XYSeriesRenderStyle; import org.knowm.xchart.style.Styler.LegendPosition; import me.despawningbone.discordbot.DiscordBot; import me.despawningbone.discordbot.command.Command; import me.despawningbone.discordbot.command.CommandResult; import me.despawningbone.discordbot.command.CommandResult.CommandResultType; import me.despawningbone.discordbot.command.games.Koohii.Accuracy; import me.despawningbone.discordbot.command.games.Koohii.DiffCalc; import me.despawningbone.discordbot.command.games.Koohii.Map; import me.despawningbone.discordbot.command.games.Koohii.PPv2; -import me.despawningbone.discordbot.command.games.Koohii.PPv2Parameters; +import me.despawningbone.discordbot.command.games.Koohii.PlayParameters; import me.despawningbone.discordbot.command.games.Koohii.MapStats; import me.despawningbone.discordbot.command.games.Koohii.TaikoPP; import me.despawningbone.discordbot.utils.MiscUtils; import net.dv8tion.jda.api.EmbedBuilder; import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.MessageEmbed; import net.dv8tion.jda.api.entities.User; public class Osu extends Command { - private final static String osuAPI = DiscordBot.tokens.getProperty("osu"); + private static final String osuAPI = DiscordBot.tokens.getProperty("osu"); - private static DecimalFormat df = new DecimalFormat("#.##"); + private static final DecimalFormat df = new DecimalFormat("#.##"); - private final static HashMap modes = new HashMap<>(); - static { - modes.put(0, "osu!"); - modes.put(1, "osu!taiko"); - modes.put(2, "osu!catch"); - modes.put(3, "osu!mania"); - } + private static final String[] modes = new String[] {"osu!", "osu!taiko", "osu!catch", "osu!mania"}; public Osu() { //TODO add back the todos to respective sub command, automate desc for subcmds, add back typing; CLOSE ALL STREAMS this.desc = "All the info you need with osu!"; this.usage = ""; //TODO add a command to parse replays? registerSubCommand("pp", Arrays.asList("map"), (channel, user, msg, words) -> { - //OffsetDateTime timesent = OffsetDateTime.now(); List amend = new ArrayList(Arrays.asList(words)); - int temp = amend.indexOf("-w"); + int wParamIndex = amend.indexOf("-w"); + + //parse weight param and uid; doesnt work if -w is immediately followed by pp params like 100x etc String uid = null; - boolean weight = temp != -1; + boolean weight = wParamIndex != -1; if(weight) { try { - uid = amend.get(temp + 1); - if(!uid.contains("osu.ppy.sh/u") && (uid.startsWith("http") && uid.contains("://"))) { + int wParamLength = 1; + + uid = amend.get(wParamIndex + 1); + if(!uid.contains("osu.ppy.sh/u") && (uid.startsWith("http") && uid.contains("://"))) { //url thats not user url means its most likely a beatmap, aka no username param uid = getPlayer(user); + } else { + wParamLength = 2; //has username } - amend.subList(temp, temp + 1).clear(); + amend.subList(wParamIndex, wParamIndex + wParamLength).clear(); //remove } catch (IndexOutOfBoundsException e) { uid = getPlayer(user); } } + String initmap; try { initmap = amend.get(0); } catch (IndexOutOfBoundsException e) { - initmap = "null"; + initmap = "null"; //dud } - List mid = new ArrayList(); - if (!initmap.startsWith("https://") && !initmap.startsWith("http://")) { //check if no map input, use discord rich presence + + CompletableFuture msgId = new CompletableFuture<>(); + + //check if no map input, use discord rich presence + if (!initmap.startsWith("https://") && !initmap.startsWith("http://")) { + + //get map name from status String details = null; try (Connection con = DiscordBot.db.getConnection()) { ResultSet rs = con.createStatement().executeQuery("SELECT game FROM users WHERE id = " + user.getId() + ";"); if(rs.next()) { details = rs.getString(1).substring(rs.getString(1).indexOf("||")); } rs.close(); } catch (SQLException e) { return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e)); } - channel.sendMessage("Trying to retrieve map from discord status...").queue(m -> mid.add(m.getId())); //DONE custom status masks presence now, will not be as effective; any way to get manually? //JDA 4 API change fixed this + + channel.sendMessage("Trying to retrieve map from discord status...").queue(m -> msgId.complete(m.getId())); //DONE custom status masks presence now, will not be as effective; any way to get manually? //JDA 4 API change fixed this channel.sendTyping().queue(); - /*OffsetDateTime timeReceived = OffsetDateTime.now(); - long ms = Math.abs(timesent.until(timeReceived, ChronoUnit.MILLIS)); - System.out.println("Time taken: " + ms + "ms");*/ - if (details != null) { //TODO if name is sth like `Feryquitous - (S).0ngs//---::compilation.[TQR-f3] [-[//mission:#FC.0011-excindell.defer.abferibus]-]` it breaks (but reasonable break tbh) - //if(game.getName().equals("osu!") && game.isRich()) { + + //parse map name and search + if (details != null) { //TODO if name is sth like `Feryquitous - (S).0ngs//---::compilation.[TQR-f3] [-[//mission:#FC.0011-excindell.defer.abferibus]-]` it breaks (but reasonable break tbh); also breaks if difficulty has " in it try { String title = URLEncoder.encode(details.substring(details.indexOf(" - ") + 3, details.lastIndexOf("[")).trim(), "UTF-8"); String diff = URLEncoder.encode(details.substring(details.lastIndexOf("[") + 1, details.lastIndexOf("]")).trim(), "UTF-8"); String url = "https://osusearch.com/query/?title=" + title + "&diff_name=" + diff + "&query_order=play_count&offset=0"; URLConnection stream = new URL(url).openConnection(); stream.addRequestProperty("User-Agent", "Mozilla/4.0"); JSONTokener tokener = new JSONTokener(stream.getInputStream()); initmap = "https://osu.ppy.sh/beatmaps/" + new JSONObject(tokener).getJSONArray("beatmaps").getJSONObject(0).getInt("beatmap_id"); } catch (IOException e) { return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e)); } catch (JSONException e) { e.printStackTrace(); System.out.println(details); return new CommandResult(CommandResultType.NORESULT); //returns if osusearch isnt updated fast enough } - - /*SELENIUM DEPRECATED*/ - - /*String url = "http://osusearch.com/search/?title=" + title + "&diff_name=" + diff - + "&query_order=play_count"; - System.out.println(url); - //MsgListener.driver.manage().timeouts().implicitlyWait(2000, TimeUnit.MILLISECONDS); - DiscordBot.driver.get(url); - WebDriverWait wait = new WebDriverWait(DiscordBot.driver, 10); - wait.until(ExpectedConditions - .elementToBeClickable(By.cssSelector("div[class~=beatmap-list]"))); - Document document = Jsoup.parse(DiscordBot.driver.getPageSource()); - //System.out.println(document.toString()); - initmap = document.body() - .select("div[class~=beatmap-list]") - .first() - .child(0) - .select("div.truncate.beatmap-title a").get(0).attr("href");*/ } else { return new CommandResult(CommandResultType.FAILURE, "There is no account of your rich presence, therefore I cannot get the beatmap from your status."); } - /* - * } else { throw new - * IllegalArgumentException("Rich presence is not an instance of osu!, therefore I cannot get the beatmap from your status." - * ); } - */ } + + //if still dud aka no presence nor url if(initmap.equals("null")) { //shouldnt throw at all return new CommandResult(CommandResultType.FAILURE, "You haven't played any maps I can recognize yet!"); } - List params = Arrays.asList(words); - double dacc = 100; - int combo = -1, mods = 0, miss = 0; - PPv2Parameters p = new Koohii.PPv2Parameters(); + + //parse beatmap + Map beatmap = null; try { - p.beatmap = new Koohii.Parser() + beatmap = new Koohii.Parser() .map(new BufferedReader(new InputStreamReader(getMap(initmap), "UTF-8"))); } catch (IOException e1) { return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e1)); } catch (UnsupportedOperationException e1) { return new CommandResult(CommandResultType.FAILURE, "This gamemode is not yet supported."); } catch (IllegalArgumentException e1) { return new CommandResult(CommandResultType.INVALIDARGS, e1.getMessage()); } - if (p.beatmap.title.isEmpty()) { + + if (beatmap.title.isEmpty()) { return new CommandResult(CommandResultType.INVALIDARGS, "Invalid beatmap."); } + + //parse pp params + double dacc = 100; + int combo = beatmap.max_combo(), mods = 0, miss = 0; try { - for (int i = 0; i < params.size(); i++) { - String param = params.get(i); + for (int i = 0; i < amend.size(); i++) { + String param = amend.get(i); if (param.startsWith("+")) { mods = Koohii.mods_from_str(param.substring(1).toUpperCase()); + //invalid mods just get ignored + } else if (param.toLowerCase().endsWith("m")) { miss = Integer.parseInt(param.substring(0, param.length() - 1)); + if(miss < 0 || miss > beatmap.objects.size()) + return new CommandResult(CommandResultType.INVALIDARGS, "Invalid miss count specified."); + } else if (param.endsWith("%")) { dacc = Double.parseDouble(param.substring(0, param.length() - 1)); + if(dacc < 0) + return new CommandResult(CommandResultType.INVALIDARGS, "Invalid accuracy specified."); + } else if (param.toLowerCase().endsWith("x")) { combo = Integer.parseInt(param.substring(0, param.length() - 1)); + if(combo < 0 || combo > beatmap.max_combo()) + return new CommandResult(CommandResultType.INVALIDARGS, "Invalid combo specified."); } } } catch (NumberFormatException e) { - return new CommandResult(CommandResultType.INVALIDARGS, "Invalid value inputted."); + return new CommandResult(CommandResultType.INVALIDARGS, "Invalid params specified."); } - DiffCalc stars = new Koohii.DiffCalc().calc(p.beatmap, mods); - Accuracy acc = new Accuracy(dacc, p.beatmap.objects.size(), miss); - p.n50 = acc.n50; - p.n100 = acc.n100; - p.n300 = acc.n300; - p.nmiss = miss; - p.aim_stars = stars.aim; - p.speed_stars = stars.speed; - p.mods = mods; - if (combo > 0) { - p.combo = combo; - } else { - p.combo = p.beatmap.max_combo(); - } - EmbedBuilder eb = new EmbedBuilder(); - JSONTokener result = null; - InputStream stream = null; - URL url = null; - try { - String addparam = ""; - if(p.beatmap.mode == 1) { - if((mods & 1<<8) != 0) addparam = "&mods=256"; - if((mods & 1<<6) != 0) if(addparam.isEmpty()) addparam = "&mods=64"; - else addparam = ""; - } - url = new URL("https://osu.ppy.sh/api/get_beatmaps?k=" + osuAPI + "&b=" + initmap.substring(initmap.lastIndexOf("/") + 1).split("&")[0] + addparam); - stream = url.openStream(); - } catch (IOException ex) { - ex.printStackTrace(); - } - result = new JSONTokener(stream); - JSONObject jbm = new JSONArray(result).getJSONObject(0); - String setid = jbm.getString("beatmapset_id"); - double accVal, ppVal, starsVal; - MapStats stats = new MapStats(p.beatmap); + + //compute pp + Accuracy acc = null; + JSONObject jbm = null; try { - PPv2 pp = new PPv2(p); - ppVal = pp.total; - accVal = acc.value(); //somehow real_acc aint correct, pretty sure i screwed sth up - starsVal = stars.total; - Koohii.mods_apply(mods, stats, 1|2|4|8); - } catch (UnsupportedOperationException e) { //should always only be taiko - starsVal = jbm.getDouble("difficultyrating"); - TaikoPP pp = new TaikoPP(starsVal, dacc, p); - ppVal = pp.pp; - accVal = pp.acc; - Koohii.mods_apply(mods, stats, 0); //want nothing but speed to be changed + acc = new Accuracy(dacc, miss, beatmap); + jbm = computePP(beatmap, initmap.substring(initmap.lastIndexOf("/") + 1).split("&")[0], + mods, acc.n50, acc.n100, acc.n300, miss, combo); + } catch (IOException e) { + return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e)); + } catch (IllegalArgumentException e) { + return new CommandResult(CommandResultType.INVALIDARGS, e.getMessage()); } - String totaldur = MiscUtils.convertMillis(TimeUnit.SECONDS.toMillis((int)(jbm.getInt("total_length") / stats.speed))); - String draindur = MiscUtils.convertMillis(TimeUnit.SECONDS.toMillis((int)(jbm.getInt("hit_length") / stats.speed))); + + //build embed + EmbedBuilder eb = new EmbedBuilder(); if(jbm.has("source") && !jbm.getString("source").isEmpty()) eb.setTitle("Source: " + jbm.getString("source")); - eb.setDescription("Mode: " + modes.get(p.beatmap.mode)); - eb.setAuthor("PP information for " + (p.beatmap.title_unicode.isEmpty() ? p.beatmap.title : p.beatmap.title_unicode), - initmap, "https://b.ppy.sh/thumb/" + setid + ".jpg"); - eb.addField("Artist", (p.beatmap.artist_unicode.isEmpty() ? p.beatmap.artist : p.beatmap.artist_unicode), - true); - eb.addField("Created by", p.beatmap.creator, true); + eb.setDescription("Mode: " + modes[beatmap.mode]); + eb.setAuthor("PP information for " + (beatmap.title_unicode.isEmpty() ? beatmap.title : beatmap.title_unicode), + initmap, "https://b.ppy.sh/thumb/" + jbm.getString("beatmapset_id") + ".jpg"); + + eb.addField("Artist", (beatmap.artist_unicode.isEmpty() ? beatmap.artist : beatmap.artist_unicode), true); + eb.addField("Created by", beatmap.creator, true); + + String totaldur = MiscUtils.convertMillis(TimeUnit.SECONDS.toMillis((int)(jbm.getInt("total_length") / jbm.getDouble("speed")))); + String draindur = MiscUtils.convertMillis(TimeUnit.SECONDS.toMillis((int)(jbm.getInt("hit_length") / jbm.getDouble("speed")))); eb.addField("Duration", totaldur + " | Drain " + draindur, true); - eb.addField("Difficulty", p.beatmap.version, true); - eb.addField("Mods", (p.mods == 0 ? "None" : Koohii.mods_str(p.mods)), true); - eb.addField("BPM", df.format(jbm.getDouble("bpm") * stats.speed), true); - eb.addField("Accuracy", df.format(accVal * 100), true); - eb.addField("Combo", String.valueOf(p.combo), true); - eb.addField("Misses", String.valueOf(p.nmiss), true); - eb.addField("300", String.valueOf(p.n300), true); - eb.addField("100", String.valueOf(p.n100), true); - eb.addField("50", String.valueOf(p.n50), true); - eb.addField("PP", df.format(ppVal), true); + + eb.addField("Difficulty", beatmap.version, true); + eb.addField("Mods", (mods == 0 ? "None" : Koohii.mods_str(mods)), true); + eb.addField("BPM", df.format(jbm.getDouble("bpm") * jbm.getDouble("speed")), true); + + eb.addField("Accuracy", df.format(jbm.getDouble("accVal") * 100) + "%", true); + eb.addField("Combo", String.valueOf(combo) + "x", true); + eb.addField("Misses", String.valueOf(miss), true); + eb.addField("300", String.valueOf(acc.n300), true); + eb.addField("100", String.valueOf(acc.n100), true); + eb.addField("50", String.valueOf(acc.n50), true); + + eb.addField("PP", df.format(jbm.getDouble("ppVal")), true); + if(weight) { try { - String gain = df.format(weightForPP(ppVal, jbm.getString("beatmap_id"), getUserBest(uid, p.beatmap.mode))); - eb.addField("Actual pp gain for " + uid, (gain.equals("-1") ? "User has a better score than this" : gain + "pp"), true); - } catch (IllegalArgumentException e1) { - return new CommandResult(CommandResultType.INVALIDARGS, e1.getMessage()); + JSONObject res = getPlayerData("get_user_best", uid, beatmap.mode, user); + if(res.has("result")) { + String gain = df.format(weightForPP(jbm.getDouble("ppVal"), jbm.getString("beatmap_id"), res.getJSONArray("result"))); + eb.addField("Actual pp gain for " + uid, (gain.equals("-1") ? "User has a better score than this" : gain + "pp"), true); + } else { + channel.sendMessage("Unknown user `" + res.getString("search") + "` specified for pp checking, ignoring...").queue(); + } + } catch (IOException e) { + return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e)); } } - eb.setFooter("Stars: " + df.format(starsVal) + " | CS: " + df.format(stats.cs) + " | HP: " + df.format(stats.hp) - + " | AR: " + df.format(stats.ar) + " | OD: " + df.format(stats.od), null); - //e.setColor(new Color(248, 124, 248)); + + eb.setFooter("Stars: " + df.format(jbm.getDouble("starsVal")) + " | CS: " + df.format(jbm.getDouble("cs")) + " | HP: " + df.format(jbm.getDouble("hp")) + + " | AR: " + df.format(jbm.getDouble("ar")) + " | OD: " + df.format(jbm.getDouble("od")), null); eb.setColor(new Color(239, 109, 167)); - if(!mid.isEmpty()) { - channel.deleteMessageById(mid.get(0)).queue(); + + //remove retrieve msg if sent + if(msgId.isDone()) { + msgId.thenAccept(m -> channel.deleteMessageById(m).queue()); } - //channel.sendMessage(e.build()).queue(); + channel.sendMessage(eb.build()).queue(); return new CommandResult(CommandResultType.SUCCESS); }, "[beatmap URL] [+|%|x|m] [-w [username]]", Arrays.asList("-w", "+DT", "https://osu.ppy.sh/b/1817768 93.43% -w", "https://osu.ppy.sh/beatmaps/1154509 +HDDT 96.05% 147x 2m -w despawningbone"), "Check PP information about that beatmap!", Arrays.asList( " * Available parameters: +[mod alias], [accuracy]%, [combo]x, [misses]m", " * specify the `-w` parameter with a username (or none for your own) to check how many actual pp the play will get them!", " * You can also not input the URL, if you are currently playing osu! and discord sensed it.")); registerSubCommand("weight", Arrays.asList("w"), (channel, user, msg, words) -> { - //System.out.println(weightForPP(95.91, null, null)); - //String debugMax = "", debugMin = "", debugData = "", debugAll = ""; List params = new ArrayList<>(Arrays.asList(words)); int modeId = getMode(params); + final double precision = 1e-4, iterations = 500; + + //get user String uid; int index = params.size() < 2 ? 0 : 1; try { uid = params.get(index - 1); if(index == 0 || (!uid.contains("osu.ppy.sh/u") && (uid.startsWith("http") && uid.contains("://")))) { uid = getPlayer(user); } } catch (IndexOutOfBoundsException e) { uid = getPlayer(user); } + + //get user top JSONArray arr = null; try { - arr = getUserBest(uid, modeId); - } catch (IllegalArgumentException e) { - return new CommandResult(CommandResultType.INVALIDARGS, e.getMessage()); + JSONObject res = getPlayerData("get_user_best", uid.trim(), modeId, user); + if(!res.has("result")) + return new CommandResult(CommandResultType.INVALIDARGS, "Unknown player `" + res.getString("search") + "`."); + + arr = res.getJSONArray("result"); + } catch (IOException e) { + return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e)); } - double maxPP = arr.getJSONObject(0).getDouble("pp") + 100; //fricc those who wanna get pp over 100pp more than they can lmao + + double limit = arr.getJSONObject(0).getDouble("pp") + 100; //fricc those who wanna get pp over 100pp more than they can lmao + + //perform bisection search try { - //double basePP = new JSONArray(new JSONTokener(new URL("https://osu.ppy.sh/api/get_user?k=" + osuAPI + "&u=" + uid).openStream())).getJSONObject(0).getDouble("pp_raw"); double targetPP = params.size() > index ? Double.parseDouble(params.get(index)) : 1; - int i = 0; double minPP = 0, mid = 0; - /*for(i = 0; i < 600; i++) { - //debugData += weightForPP(i, null, arr) + "\n"; - debugData += i + " " + weightForPP(i, null, arr) + "\n"; - }*/ - //System.out.println(debugData); - i = 0; + int i = 0; double minPP = 0, maxPP = limit, mid = 0; while(i < iterations && Math.abs(maxPP - minPP) > precision) { mid = (maxPP + minPP) / 2; double temp = weightForPP(mid, null, arr); - //System.out.println(i + ", " + mid + ", " + temp); if(temp > targetPP) { maxPP = mid; } else { minPP = mid; } i++; - //System.out.println(maxPP + " " + minPP); - //debugAll += maxPP + " " + minPP + "\n"; - //debugMax += maxPP + ""; - //debugMin += minPP + " "; } - //System.out.println(debugAll); - //System.out.println(debugMax); - //System.out.println(debugMin); - if(!df.format(mid).equals(df.format(arr.getJSONObject(0).getDouble("pp") + 100))) { - channel.sendMessage("For " + uid + " to actually gain " + df.format(targetPP) + "pp in " + modes.get(modeId) + ", they have to play a map worth approximately **" + df.format(mid) + "pp** raw.").queue(); + + if(!df.format(mid).equals(df.format(limit))) { + channel.sendMessage("For " + uid + " to actually gain " + df.format(targetPP) + "pp in " + modes[modeId] + ", they have to play a map worth approximately **" + df.format(mid) + "pp** raw.").queue(); return new CommandResult(CommandResultType.SUCCESS); } else { return new CommandResult(CommandResultType.INVALIDARGS, "You can't really achieve such large pp jumps without people thinking you are hacking :P"); } } catch (NumberFormatException e) { return new CommandResult(CommandResultType.INVALIDARGS, "Invalid target pp specified."); } - //return ""; }, "[username] [wished pp gain]", Arrays.asList("", "10", "despawningbone 20"), "Estimate how much raw pp a map needs to have in order to gain you pp!", Arrays.asList(" * Supports `-t` for taiko, `-c` for catch, and `-m` for mania (Defaults to standard).")); - //TODO NEED EXTREME TIDYING UP - //TODO merge parts of the code with getRecent() and getPP(); + //DONE NEED EXTREME TIDYING UP + //DONE merge parts of the code with getRecent() and getPP(); registerSubCommand("recent", Arrays.asList("r"), (channel, user, msg, words) -> { List params = new ArrayList<>(Arrays.asList(words)); int modeId = getMode(params); - if(modeId > 1) modeId = 0; + if(modeId > 1) modeId = 0; //ignore unsupported modes and default to standard + boolean passOnly = params.removeAll(Collections.singleton("-p")); //no need space, as spaced ones are split (if a player has a name with spaced -p it might be a problem) + + //parse params String[] split = String.join(" ", params).split("\\|"); String search = split[0].trim(); int nrecent = 0; try { nrecent = Integer.parseInt(split[1].trim()) - 1; } catch (NumberFormatException e) { //print to channel? } catch (ArrayIndexOutOfBoundsException e) { ; //nothing special about this } if(nrecent > 50) { return new CommandResult(CommandResultType.FAILURE, "Only the 50 most recent plays can be recalled!"); } - String id = ""; - boolean noturl = false; - try { - new URL(search); - id = search.substring(search.lastIndexOf("/") + 1); - } catch (MalformedURLException e) { - noturl = true; - id = search; - } - if(id.isEmpty()) { - id = getPlayer(user); - } - JSONTokener result = null; - InputStream stream = null; - URL url = null; + + //fetch recent plays + JSONArray array; + String name; try { - String addparam = ""; - if (noturl) { - addparam = "&type=string"; - } - if (modeId != 0) { - addparam += ("&m=" + modeId); - } - url = new URL( - "https://osu.ppy.sh/api/get_user_recent?k=" + osuAPI + "&u=" + URLEncoder.encode(id, "UTF-8") + addparam + "&limit=50"); - stream = url.openStream(); - if (stream.available() < 4) { - return new CommandResult(CommandResultType.INVALIDARGS, "Unknown player `" + id + "` or the player has not been playing in the last 24h."); - } - } catch (IOException ex) { - ex.printStackTrace(); - return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(ex)); + JSONObject res = getPlayerData("get_user_recent", search, modeId, user); + if(!res.has("result")) + return new CommandResult(CommandResultType.INVALIDARGS, "Unknown player `" + res.getString("search") + "` or the player has not been playing in the last 24h."); + + array = res.getJSONArray("result"); + + if(array.length() == 0) + return new CommandResult(CommandResultType.FAILURE, "You have no recent plays in this 24h!"); + + //set name according to supported formats + name = res.getBoolean("isId") ? //isId might return true on cases inputted as https://osu.ppy.sh/users/despawningbone for example, which would make the fetching redundant but still works + getPlayerData("get_user", search, 0, user).getJSONArray("result").getJSONObject(0).getString("username"): + res.getString("search"); + } catch (IOException e) { + return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e)); } - result = new JSONTokener(stream); - JSONArray array = new JSONArray(result); - //System.out.println(array); - if(array.length() > 0) { - JSONObject mostRecent = null; - if(passOnly) { - int times = 0; - for(int i = 0; i < array.length(); i++) { - if(!array.getJSONObject(i).getString("rank").equals("F")) { - times++; - if(times - 1 >= nrecent) { - mostRecent = array.getJSONObject(i); - nrecent = i; - break; - } + + //get which play to return + JSONObject mostRecent = null; + if(passOnly) { + int times = 0; + for(int i = 0; i < array.length(); i++) { + if(!array.getJSONObject(i).getString("rank").equals("F")) { + times++; + if(times - 1 >= nrecent) { + mostRecent = array.getJSONObject(i); + nrecent = i; + break; } } - if(mostRecent == null) { - return new CommandResult(CommandResultType.INVALIDARGS, "You did not have this much passed plays in the recent 50 plays!"); - } - } else { - try { - mostRecent = array.getJSONObject(nrecent); - } catch (JSONException e) { - return new CommandResult(CommandResultType.FAILURE, "You only played " + array.length() + " times recently!"); - } - } - String beatmap = mostRecent.getString("beatmap_id"); - String name; - if (noturl) { - name = id; - id = mostRecent.getString("user_id"); - } else { - try { - Long.parseLong(id); - //get more info from here? - name = new JSONObject(new JSONTokener(new URL("https://osu.ppy.sh/api/get_user?k=" + osuAPI + "&u=" + id).openStream())).getString("username"); - } catch (NumberFormatException e) { - name = id; - } catch (IOException e) { - return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e)); - } - } - int i; - String more = ""; - for(i = nrecent + 1; i < array.length(); i++) { - if(!array.getJSONObject(i).getString("beatmap_id").equals(beatmap)) { - break; - } - if(i == array.length() - 1) { - more = " (or more)"; - } - } - if(i == array.length()) { - more = " (or more)"; - } - PPv2Parameters p = new Koohii.PPv2Parameters(); - try { - p.beatmap = new Koohii.Parser() - .map(new BufferedReader(new InputStreamReader(getMap(beatmap), "UTF-8"))); - } catch (IOException e1) { - return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e1)); - } catch (UnsupportedOperationException e1) { - return new CommandResult(CommandResultType.FAILURE, "This gamemode is not yet supported."); - } catch (IllegalArgumentException e1) { - return new CommandResult(CommandResultType.INVALIDARGS, e1.getMessage()); } - int mods = mostRecent.getInt("enabled_mods"); - if (p.beatmap.title.isEmpty()) { - return new CommandResult(CommandResultType.INVALIDARGS, "Invalid beatmap."); + if(mostRecent == null) { + return new CommandResult(CommandResultType.INVALIDARGS, "You did not have this much passed plays in the recent 50 plays!"); } - DiffCalc stars = new Koohii.DiffCalc().calc(p.beatmap, mods); - p.n50 = mostRecent.getInt("count50"); - p.n100 = mostRecent.getInt("count100"); - p.n300 = mostRecent.getInt("count300"); - p.nmiss = mostRecent.getInt("countmiss"); - //System.out.println(p.n300); - p.nobjects = p.n300 + p.n100 + p.n50 + p.nmiss; - //System.out.println(p.nobjects); - p.aim_stars = stars.aim; - p.speed_stars = stars.speed; - p.mods = mods; - p.combo = mostRecent.getInt("maxcombo"); - EmbedBuilder eb = new EmbedBuilder(); - //System.out.println(mods); - String modStr = Koohii.mods_str(mods); - eb.setColor(new Color(0, 0, 0)); - double ppVal, ppMax, starVal, accVal; - JSONObject obj; + } else { try { - String addparam = ""; - if(p.beatmap.mode == 1) { - if((mods & 1<<8) != 0) addparam = "&mods=256"; - if((mods & 1<<6) != 0) if(addparam.isEmpty()) addparam = "&mods=64"; - else addparam = ""; - } - obj = new JSONArray(new JSONTokener(new URL("https://osu.ppy.sh/api/get_beatmaps?k=" + osuAPI + "&b=" + mostRecent.getInt("beatmap_id") + addparam).openStream())).getJSONObject(0); - } catch (JSONException | IOException e) { - return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e)); + mostRecent = array.getJSONObject(nrecent); + } catch (JSONException e) { + return new CommandResult(CommandResultType.FAILURE, "You only played " + array.length() + " times recently!"); } - try { - PPv2 pp = new PPv2(p); - starVal = stars.total; - accVal = pp.computed_accuracy.value(); - ppVal = pp.total; - ppMax = constructPP(stars.aim, stars.speed, p.beatmap, mods).total; - } catch (UnsupportedOperationException e) { - starVal = obj.getDouble("difficultyrating"); - TaikoPP pp = new TaikoPP(starVal, p); - ppVal = pp.pp; - accVal = pp.acc; - ppMax = new TaikoPP(starVal, p.max_combo, mods, 1, 0, p.beatmap).pp; + } + + String bid = mostRecent.getString("beatmap_id"); + + //get consecutive tries + int i; + for(i = nrecent + 1; i < array.length(); i++) { + if(!array.getJSONObject(i).getString("beatmap_id").equals(bid)) { + break; } - eb.setTitle((p.beatmap.artist_unicode.isEmpty() ? p.beatmap.artist : p.beatmap.artist_unicode) + " - " + (p.beatmap.title_unicode.isEmpty() ? p.beatmap.title : p.beatmap.title_unicode) + " [" + p.beatmap.version + "]" + (modStr.isEmpty() ? "" : " +" + modStr) + " (" + df.format(starVal) + "*)", "https://osu.ppy.sh/beatmaps/" + beatmap); - eb.setAuthor(MiscUtils.ordinal(nrecent + 1) + " most recent " + modes.get(modeId) + " play by " + name, "https://osu.ppy.sh/users/" + id, "https://a.ppy.sh/" + id); - eb.setDescription(MiscUtils.ordinal(i - nrecent) + more + " consecutive try\nRanking: " + mostRecent.getString("rank").replaceAll("H", "+").replaceAll("X", "SS") + (mostRecent.getString("rank").equals("F") ? " (Estimated completion percentage: " + df.format(p.nobjects * 100.0 / p.beatmap.objects.size()) + "%)" : "")); - eb.setThumbnail("https://b.ppy.sh/thumb/" + obj.getString("beatmapset_id") + ".jpg"); - eb.addField("Score", String.valueOf(mostRecent.getInt("score")), true); - eb.addField("Accuracy", df.format(accVal * 100) + " (" + p.n300 + "/" + p.n100 + "/" + p.n50 + "/" + p.nmiss + ")", true); - eb.addField("Combo", (mostRecent.getInt("perfect") == 0 ? p.combo + "x / " + p.beatmap.max_combo() + "x" : "FC (" + p.combo + "x)"), true); - eb.addField("PP", df.format(ppVal) + "pp / " + df.format(ppMax) + "pp", true); - eb.setFooter("Score submitted", null); - eb.setTimestamp(OffsetDateTime.parse(mostRecent.getString("date").replace(" ", "T") + "+00:00")); - channel.sendMessage(eb.build()).queue(); - return new CommandResult(CommandResultType.SUCCESS); - } else { - return new CommandResult(CommandResultType.FAILURE, "You have no recent plays in this 24h!"); } + //if we reached the end of the list, there might be more that we missed since we can only fetch 50 recent plays + String more = ""; + if(i == array.length()) { + more = " (or more)"; + } + + //parse map of the recent play and compute pp + int n50 = mostRecent.getInt("count50"), n100 = mostRecent.getInt("count100"), n300 = mostRecent.getInt("count300"), + miss = mostRecent.getInt("countmiss"), combo = mostRecent.getInt("maxcombo"), mods = mostRecent.getInt("enabled_mods"); + Map beatmap = null; + JSONObject jbm = null; + try { + beatmap = new Koohii.Parser() + .map(new BufferedReader(new InputStreamReader(getMap(bid), "UTF-8"))); + jbm = computePP(beatmap, bid, mods, n50, n100, n300, miss, combo); + } catch (IOException e1) { + return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e1)); + } catch (UnsupportedOperationException e1) { + return new CommandResult(CommandResultType.FAILURE, "This gamemode is not yet supported."); + } catch (IllegalArgumentException e1) { + return new CommandResult(CommandResultType.INVALIDARGS, e1.getMessage()); + } + + //build embed + String modStr = Koohii.mods_str(mods); + EmbedBuilder eb = new EmbedBuilder(); + eb.setColor(new Color(0, 0, 0)); + eb.setTitle((beatmap.artist_unicode.isEmpty() ? beatmap.artist : beatmap.artist_unicode) + + " - " + (beatmap.title_unicode.isEmpty() ? beatmap.title : beatmap.title_unicode) + " [" + beatmap.version + "]" + + (modStr.isEmpty() ? "" : " +" + modStr) + " (" + df.format(jbm.getDouble("starsVal")) + "*)" + , "https://osu.ppy.sh/beatmaps/" + bid); + eb.setAuthor(MiscUtils.ordinal(nrecent + 1) + " most recent " + modes[modeId] + " play by " + name, + "https://osu.ppy.sh/users/" + mostRecent.getString("user_id"), "https://a.ppy.sh/" + mostRecent.getString("user_id")); + + eb.setDescription(MiscUtils.ordinal(i - nrecent) + more + " consecutive try\n" + + "Ranking: " + mostRecent.getString("rank").replaceAll("H", "+").replaceAll("X", "SS") + + (mostRecent.getString("rank").equals("F") ? " (Estimated completion percentage: " + df.format((n50 + n100 + n300 + miss) * 100.0 / beatmap.objects.size()) + "%)" : "")); + + eb.setThumbnail("https://b.ppy.sh/thumb/" + jbm.getString("beatmapset_id") + ".jpg"); + eb.addField("Score", String.valueOf(mostRecent.getInt("score")), true); + eb.addField("Accuracy", df.format(jbm.getDouble("accVal") * 100) + " (" + n300 + "/" + n100 + "/" + n50 + "/" + miss + ")", true); + eb.addField("Combo", (mostRecent.getInt("perfect") == 0 ? combo + "x / " + beatmap.max_combo() + "x" : "FC (" + combo + "x)"), true); + eb.addField("PP", df.format(jbm.getDouble("ppVal")) + "pp / " + df.format(jbm.getDouble("ppMax")) + "pp", true); + eb.setFooter("Score submitted", null); + eb.setTimestamp(OffsetDateTime.parse(mostRecent.getString("date").replace(" ", "T") + "+00:00")); + + channel.sendMessage(eb.build()).queue(); + return new CommandResult(CommandResultType.SUCCESS); }, "[-p] [username] [| index]", Arrays.asList("", "-p | 2", "-p despawningbone"), "Check a user's recent play!", Arrays.asList( " * Specifying the `-p` parameter will only search for plays that did not fail.", " * Supports `-t` for taiko (Defaults to standard).")); - //TODO tidy up first part, merge with getTopPlays()? + //DONE tidy up first part, merge with getTopPlays()? registerSubCommand("ppchart", Arrays.asList("ppc"), (channel, user, msg, words) -> { if(words.length < 30) { List params = new ArrayList<>(Arrays.asList(words)); int modeId = getMode(params); if(params.size() < 1) { params.add(getPlayer(user)); } + + //get all usernames String[] split = String.join(" ", params).split("\\|"); String concName = String.join(" | ", split); - XYChart chart = new XYChartBuilder().width(800).height(600).title("Top PP plays for " + concName + " (" + modes.get(modeId) + ")").yAxisTitle("PP").xAxisTitle("Plays (100 = top)").build(); + + //init chart + XYChart chart = new XYChartBuilder().width(800).height(600).title("Top PP plays for " + concName + " (" + modes[modeId] + ")").yAxisTitle("PP").xAxisTitle("Plays (100 = top)").build(); chart.getStyler().setDefaultSeriesRenderStyle(XYSeriesRenderStyle.Scatter); chart.getStyler().setLegendPosition(LegendPosition.InsideSE); chart.getStyler().setPlotContentSize(0.91); chart.getStyler().setMarkerSize(7); - //chart.getStyler().setAxisTitlePadding(24); chart.getStyler().setChartTitlePadding(12); chart.getStyler().setChartPadding(12); chart.getStyler().setChartBackgroundColor(new Color(200, 220, 230)); - //ThreadLocalRandom ran = ThreadLocalRandom.current(); + + //plot each user's top plays onto chart for(String name : split) { List pps = new ArrayList<>(); try { - JSONArray array = getUserBest(name.trim(), modeId); + JSONObject res = getPlayerData("get_user_best", name.trim(), modeId, user); + if(!res.has("result")) + return new CommandResult(CommandResultType.INVALIDARGS, "Unknown player `" + res.getString("search") + "`."); + + JSONArray array = res.getJSONArray("result"); for(int i = 0; i < array.length(); i++) { JSONObject obj = array.getJSONObject(i); pps.add(obj.getDouble("pp")); } - } catch (IllegalArgumentException e) { - return new CommandResult(CommandResultType.INVALIDARGS, e.getMessage()); + } catch (IOException e) { + return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e)); } - Collections.reverse(pps); - /*XYSeries series =*/ chart.addSeries(name.trim() + "'s PP", pps); - //use series.setXYSeriesRenderStyle() for average line - //series.setMarkerColor(new Color(ran.nextInt(1, 255), ran.nextInt(1, 255), ran.nextInt(1, 255))); + + Collections.reverse(pps); //so it orders from low to high left to right + chart.addSeries(name.trim() + "'s PP", pps); } + + //write to png ByteArrayOutputStream os = new ByteArrayOutputStream(); try { BitmapEncoder.saveBitmap(chart, os, BitmapFormat.PNG); } catch (IOException e) { return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e)); } channel.sendFile(new ByteArrayInputStream(os.toByteArray()), "ppchart.png").queue(); return new CommandResult(CommandResultType.SUCCESS); } else { return new CommandResult(CommandResultType.INVALIDARGS, "Please do not include so many players at once."); } }, "[mode] [username] [| usernames]...", Arrays.asList("", "taiko", "FlyingTuna | Rafis | despawningbone"), "Get a graph with the users' top plays!", Arrays.asList( " * You can specify up to 30 players at once, seperated by `|`." + " * Supports `-t` for taiko, `-c` for catch, and `-m` for mania (Defaults to standard).")); + //TODO reaction based paging? registerSubCommand("set", Arrays.asList("s"), (channel, user, msg, words) -> { int page = 1; if (words.length < 1) { return new CommandResult(CommandResultType.INVALIDARGS, "Please enter search words or an URL."); - }/* else if (words.length > 2) { - try { - page = Integer.parseInt(words[2]); - } catch (NumberFormatException e) { - throw new IllegalArgumentException("Please enter a valid page number."); - } - }*/ + } + String id = "", set = ""; try { new URL(words[0]); } catch (MalformedURLException e) { - //throw new IllgalArgumentException("Invalid URL."); //NOFIX: for some reason, discord seems to continue the typing after this has been sent //its because of queue() + //if not url, search try { String[] split = String.join(" ", words).split("\\|"); String search = URLEncoder.encode(split[0].trim(), "UTF-8"); try { if(split.length > 1) page = Integer.parseInt(split[1].trim()); } catch (NumberFormatException e1) { return new CommandResult(CommandResultType.INVALIDARGS, "Please enter a valid page number."); } String url = "https://osusearch.com/query/?title=" + search + "&query_order=play_count&offset=0"; - URLConnection stream; - stream = new URL(url).openConnection(); + URLConnection stream = new URL(url).openConnection(); stream.addRequestProperty("User-Agent", "Mozilla/4.0"); JSONTokener tokener = new JSONTokener(stream.getInputStream()); id = String.valueOf(new JSONObject(tokener).getJSONArray("beatmaps").getJSONObject(0).getInt("beatmapset_id")); set = "https://osu.ppy.sh/beatmapset/" + id; - } catch (IOException | JSONException e1) { - if(e1 instanceof JSONException) { - return new CommandResult(CommandResultType.NORESULT); - } else { - return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e1)); - } + } catch (IOException e1) { + return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e1)); + } catch (JSONException e1) { + return new CommandResult(CommandResultType.NORESULT); } } + + //empty id means words[0] was a valid url - accept and parse if(id.equals("")) { set = words[0]; id = set.substring(set.lastIndexOf("/") + 1); if (set.contains("/beatmapsets/")) { id = set.substring(set.lastIndexOf("/beatmapsets/")); id = id.substring(id.lastIndexOf("/") + 1); - } else { + } else { //old urls if (set.contains("/b/") || set.contains("#")) { return new CommandResult(CommandResultType.INVALIDARGS, "This is a specific beatmap, not a beatmap set."); } id = id.split("&")[0]; } } - JSONTokener result = null; + + //get beatmap set info InputStream stream = null; - URL url = null; try { - url = new URL("https://osu.ppy.sh/api/get_beatmaps?k=" + osuAPI + "&s=" + id); - stream = url.openStream(); + stream = new URL("https://osu.ppy.sh/api/get_beatmaps?k=" + osuAPI + "&s=" + id).openStream(); } catch (IOException ex) { - ex.printStackTrace(); + return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(ex)); } - result = new JSONTokener(stream); - JSONArray arr = new JSONArray(result); + JSONArray arr = new JSONArray(new JSONTokener(stream)); + + //convert it to a list of object for streaming List obj = new ArrayList(); for (int i = 0; i < arr.length(); i++) { obj.add(arr.getJSONObject(i)); } + //group by mode sorted by difficulty and flatten back to a list List fobj = obj.stream().sorted(Comparator.comparing(e -> e.getDouble("difficultyrating"))) .collect(Collectors.groupingBy(e -> e.getInt("mode"))).values().stream().flatMap(List::stream) .collect(Collectors.toList()); + + EmbedBuilder em = new EmbedBuilder(); - JSONObject fo; + + JSONObject fo; //use first object's metadata for universal info like author try { fo = fobj.get(0); } catch (IndexOutOfBoundsException e) { return new CommandResult(CommandResultType.INVALIDARGS, "Invalid beatmap URL."); } - if(page > Math.ceil((double) fobj.size() / 3)) { + + if(page > Math.ceil((double) fobj.size() / 3)) { //each page is 3 entries return new CommandResult(CommandResultType.INVALIDARGS, "The beatmap set does not have this many difficulties!"); } + + //build embed page heading em.setAuthor("Beatmap set information of " + fo.getString("title"), set, "https://b.ppy.sh/thumb/" + id + ".jpg"); em.setDescription("Author: " + fo.getString("artist")); em.addField("BPM", fo.getString("bpm"), true); em.addField("Creator", fo.getString("creator"), true); em.addField("Approved/Ranked", (fo.getInt("approved") >= 1 ? "Yes" : "No"), true); em.addBlankField(false); + + //build each entry int n = ((page - 1) * 3); - //String desc = ""; for (int i = n; (n + 3 <= fobj.size() ? i < n + 3 : i < fobj.size()); i++) { JSONObject json = fobj.get(i); - String mode = modes.get(json.getInt("mode")); - /*desc += "**[" + json.getString("version") + "](https://osu.ppy.sh/b/" + json.getString("beatmap_id") + ")** (" + mode + ")\n"; - desc += df.format(json.getDouble("difficultyrating")) + "* " + (json.get("max_combo").toString() + "*").replaceAll("null*", "N/A") + "";*/ - em.addField("Difficulty", "[" + json.getString("version") + "](https://osu.ppy.sh/b/" + json.getString("beatmap_id") + ")" + " ([Preview](http://bloodcat.com/osu/preview.html#" + json.getString("beatmap_id") + "))", false); - /*em.addField("URL", "https://osu.ppy.sh/b/" + json.getString("beatmap_id"), true); - em.addBlankField(true);*/ + em.addField("Difficulty", "[" + json.getString("version") + "](https://osu.ppy.sh/b/" + json.getString("beatmap_id") + ")" + " ([Preview](https://jmir.xyz/osu/#" + json.getString("beatmap_id") + "))", false); //bloodcat is no longer a thing but someone hosted a copy for previewing here em.addField("Max combo", json.get("max_combo").toString().replaceAll("null", "N/A"), true); em.addField("Stars", df.format(json.getDouble("difficultyrating")), true); - em.addField("Mode", mode, true); + em.addField("Mode", modes[json.getInt("mode")], true); } - em.setFooter("Page: " + String.valueOf(page) + "/" + String.valueOf((int) Math.ceil((double) fobj.size() / 3)), - null); + + //build footer and send + em.setFooter("Page: " + String.valueOf(page) + "/" + String.valueOf((int) Math.ceil((double) fobj.size() / 3)), null); channel.sendMessage(em.build()).queue(); return new CommandResult(CommandResultType.SUCCESS); }, " [| page]\n", Arrays.asList("big black", "high free spirits | 2"), "Check info about a beatmap set!", Arrays.asList( " * Useful to get the specific difficulty URL for !desp osu pp.")); + //TODO add userset for setting username to db so getPlayer wont be wrong? registerSubCommand("user", Arrays.asList("u"), (channel, user, msg, words) -> { List params = new ArrayList<>(Arrays.asList(words)); int modeId = getMode(params); String search = String.join(" ", params); - String id = ""; - boolean noturl = false; - try { - new URL(search); - id = search.substring(search.lastIndexOf("/") + 1); - } catch (MalformedURLException e) { - noturl = true; - id = search; - } - if(id.isEmpty()) { - id = getPlayer(user); - } - JSONTokener result = null; - InputStream stream = null; - URL url = null; + + //fetch user data + JSONObject usr; try { - String addparam = ""; - if (noturl) { - addparam = "&type=string"; - } - if (modeId != 0) { - addparam += ("&m=" + modeId); - } - url = new URL( - "https://osu.ppy.sh/api/get_user?k=" + osuAPI + "&u=" + URLEncoder.encode(id, "UTF-8") + addparam); - stream = url.openStream(); - if (stream.available() < 4) { - return new CommandResult(CommandResultType.INVALIDARGS, "Unknown user `" + id + "`."); - } - } catch (IOException ex) { - ex.printStackTrace(); + JSONObject res = getPlayerData("get_user", search, modeId, user); + if(!res.has("result")) + return new CommandResult(CommandResultType.INVALIDARGS, "Unknown player `" + res.getString("search") + "`."); + + usr = res.getJSONArray("result").getJSONObject(0); + } catch (IOException e) { + return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e)); } - result = new JSONTokener(stream); - JSONObject usr = new JSONArray(result).getJSONObject(0); - id = usr.getString("user_id"); //no matter if its url or not user_id is still gonna be the most accurate one + String id = usr.getString("user_id"); //no matter if its url or not user_id is still gonna be the most accurate one + //build embed EmbedBuilder em = new EmbedBuilder(); - em.setAuthor("User info of " + usr.getString("username") + " (" + modes.get(modeId) + ")", "https://osu.ppy.sh/users/" + id); + em.setAuthor("User info of " + usr.getString("username") + " (" + modes[modeId] + ")", "https://osu.ppy.sh/users/" + id); + + //check if pfp exists, if not fallback to blank String img = "https://a.ppy.sh/" + id; try { new URL(img).openStream(); } catch (IOException e) { img = "https://s.ppy.sh/images/blank.jpg"; } em.setThumbnail(img); - try { - em.addField("PP", usr.getDouble("pp_raw") == 0 ? "No Recent Plays" : usr.getString("pp_raw"), true); - em.addField("Accuracy", df.format(usr.getDouble("accuracy")), true); - } catch (JSONException e) { - return new CommandResult(CommandResultType.FAILURE, "This user has not played any map in " + modes.get(modeId) + " before."); - } - StringBuffer unibuff = new StringBuffer(); - char[] ch = usr.getString("country").toLowerCase().toCharArray(); - for (char c : ch) { - int temp = (int) c; - int temp_integer = 96; //for lower case - if (temp <= 122 & temp >= 97) - unibuff.append(Character.toChars(127461 + (temp - temp_integer))); - } - em.addField("Rank", (usr.getInt("pp_rank") == 0 ? "N/A" : "#" + usr.getString("pp_rank")) + " | Country #" + usr.getString("pp_country_rank"), true); - em.addField("Country", unibuff.toString(), true); + + //if user doesnt have pp, there wont be any data pretty much - also might be a result of inactivity (?) + if(usr.isNull("pp_raw")) + return new CommandResult(CommandResultType.FAILURE, "This user has not played any map in " + modes[modeId] + " before."); + + em.appendDescription("Joined " + usr.getString("join_date")); + em.addField("PP", usr.getDouble("pp_raw") == 0 ? "No Recent Plays" : usr.getString("pp_raw"), true); + em.addField("Accuracy", df.format(usr.getDouble("accuracy")), true); + em.addField("Rank", (usr.getInt("pp_rank") == 0 ? "N/A" : "#" + usr.getString("pp_rank")) + + " | " + MiscUtils.countryNameToUnicode(usr.getString("country")) + " #" + usr.getString("pp_country_rank"), true); + em.addField("Play count", usr.getString("playcount") + + " (" + df.format(Long.parseLong(usr.getString("total_seconds_played")) / 3600.0) + "h)", true); + em.addField("Total score", usr.getString("total_score"), true); + em.addField("Ranked score", usr.getString("ranked_score") + + " (" + df.format((usr.getDouble("ranked_score") / usr.getDouble("total_score")) * 100) + "%)", true); em.addField("Level", df.format(usr.getDouble("level")), true); - em.addField("Play count", usr.getString("playcount"), true); - em.addField("SS+", usr.getString("count_rank_ssh"), true); - em.addField("SS", usr.getString("count_rank_ss"), true); - double total = usr.getDouble("total_score"); - em.addField("Total score", String.valueOf((long) total), true); em.addField("S+", usr.getString("count_rank_sh"), true); + em.addField("SS+", usr.getString("count_rank_ssh"), true); + em.addField("A", usr.getString("count_rank_a"), true); em.addField("S", usr.getString("count_rank_s"), true); - double ranked = usr.getDouble("ranked_score"); - em.addField("Ranked score", String.valueOf((long) ranked) + " (" + df.format((ranked / total) * 100) + "%)", true); + em.addField("SS", usr.getString("count_rank_ss"), true); + em.setColor(new Color(66, 134, 244)); - em.setFooter("300: " + usr.getString("count300") + " | 100: " + usr.getString("count100") + " | 50: " - + usr.getString("count50"), null); //TODO display percentage? + em.setFooter("300: " + usr.getString("count300") + " | 100: " + usr.getString("count100") + " | 50: " + usr.getString("count50"), null); //TODO display percentage? channel.sendMessage(em.build()).queue(); return new CommandResult(CommandResultType.SUCCESS); - }, "[gamemode] [user URL|keyword]", Arrays.asList("", "despawningbone", "taiko despawningbone"), + }, "[gamemode] [user URL|keyword]", Arrays.asList("", "despawningbone", "-t despawningbone"), "Check info about a user!", Arrays.asList( " * Supports `-t` for taiko, `-c` for catch, and `-m` for mania (Defaults to standard).")); //DONE api demanding; need to add cooldown //nvm i changed it to use XHR now //parts mergeable with getRecent() registerSubCommand("topplays", Arrays.asList("top", "t"), (channel, user, msg, words) -> { List params = new ArrayList<>(Arrays.asList(words)); int modeId = getMode(params), page; + + //get page number String[] split = String.join(" ", params).split("\\|"); String search = split[0]; try { page = Integer.parseInt(split[1].trim()); if(page > 10) { return new CommandResult(CommandResultType.INVALIDARGS, "You can only request your top 100 plays!"); } } catch (NumberFormatException e) { return new CommandResult(CommandResultType.INVALIDARGS, "Invalid page number!"); } catch (ArrayIndexOutOfBoundsException e) { page = 1; //its normal for people to not input index } - String id = ""; - boolean noturl = false; - try { - new URL(search); - id = search.substring(search.lastIndexOf("/") + 1); - } catch (MalformedURLException e) { - noturl = true; - id = search; - } - if(id.isEmpty()) { - id = getPlayer(user); - } - JSONTokener result = null; - InputStream stream = null; - String name, addparam = ""; + + //get user id then fetch top plays from XHR req + JSONArray array; + String name, id; try { - if (noturl) { - addparam = "&type=string"; - } - JSONObject userObj = new JSONArray(new JSONTokener(new URL("https://osu.ppy.sh/api/get_user?k=" + osuAPI + "&u=" + id + addparam).openStream())).getJSONObject(0); - name = userObj.getString("username"); - id = userObj.getString("user_id"); - URLConnection con = new URL("https://osu.ppy.sh/users/" + id + "/scores/best?mode=" + (modeId == 0 ? "osu" : modeId == 1 ? "taiko" : modeId == 2 ? "fruits" : modeId == 3 ? "mania" : "osu") + "&offset=" + (page - 1) * 10 + "&limit=10").openConnection(); - stream = con.getInputStream(); + JSONObject res = getPlayerData("get_user", search, modeId, user); + if(!res.has("result")) + return new CommandResult(CommandResultType.INVALIDARGS, "Unknown player `" + res.getString("search") + "`."); + + JSONObject usr = res.getJSONArray("result").getJSONObject(0); + name = usr.getString("username"); + id = usr.getString("user_id"); + + //top plays internal api use different names for modes than listed + String[] m = new String[]{"osu", "taiko", "fruits", "mania"}; + + InputStream stream = new URL("https://osu.ppy.sh/users/" + id + "/scores/best" + + "?mode=" + m[modeId] + "&offset=" + (page - 1) * 10 + "&limit=10").openStream(); + if (stream.available() < 4) { return new CommandResult(CommandResultType.FAILURE, "This player does not have this many plays in this gamemode!"); } - } catch (IOException ex) { - return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(ex)); - } catch (JSONException ex) { - return new CommandResult(CommandResultType.INVALIDARGS, "Unknown user `" + id + "`."); + + array = new JSONArray(new JSONTokener(stream)); + } catch (IOException e) { + return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e)); } - result = new JSONTokener(stream); - JSONArray array = new JSONArray(result); + + //build embed EmbedBuilder eb = new EmbedBuilder(); - eb.setAuthor(name + "'s top plays (" + modes.get(modeId) + ")", "https://osu.ppy.sh/users/" + id, "https://a.ppy.sh/" + id); + eb.setAuthor(name + "'s top plays (" + modes[modeId] + ")", "https://osu.ppy.sh/users/" + id, "https://a.ppy.sh/" + id); DateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); - try { - for(int i = 0; i < array.length(); i++) { - JSONObject obj = array.getJSONObject(i); - String mods = obj.getJSONArray("mods").join("").replaceAll("\"", ""); - /*bInfo = new JSONArray(new JSONTokener(new URL("https://osu.ppy.sh/api/get_beatmaps?k=" + osuAPI + "&b=" + bId).openStream())).getJSONObject(0); - String info = "**" + obj.getString("rank").replaceAll("H", "+").replaceAll("X", "SS") + "** " - + df.format(bInfo.getDouble("difficultyrating")) + "*" - + (!mods.isEmpty() ? " **" + mods + "** " : " ") - + " ([link](https://osu.ppy.sh/beatmap/" + bId + "))" - + "\n" + (obj.getDouble("pp") + "pp (" + obj.getInt("count300") + "/" + obj.getInt("count100") + "/" + obj.getInt("count50") + "/" + obj.getInt("countmiss") + ")") - + " " + (obj.getInt("perfect") == 1 ? "FC " + obj.getInt("maxcombo") + "x" : obj.getInt("maxcombo") + (!bInfo.isNull("max_combo") ? "/" + bInfo.getInt("max_combo") + "x" : "x")) - + "\nPlayed on " + obj.getString("date"); - eb.addField((i + 1) + ". " + bInfo.getString("artist") + " - " + bInfo.getString("title") + " [" + bInfo.getString("version") + "]", info, false); //no unicode unfortunately*/ - JSONObject stats = obj.getJSONObject("statistics"); - JSONObject bInfo = obj.getJSONObject("beatmap"); - JSONObject bsInfo = obj.getJSONObject("beatmapset"); - int bId = bInfo.getInt("id"); - String info = "**" + obj.getString("rank").replaceAll("H", "+").replaceAll("X", "SS") + "**" - + (!mods.isEmpty() ? " **" + mods + "** " : " ") - + df.format(bInfo.getDouble("difficulty_rating")) + "*" - + " ([map link](https://osu.ppy.sh/beatmaps/" + bId + "))" - + "\nScore: " + obj.getInt("score") - + "\n**" + (obj.getDouble("pp") + "pp** | Weighted " + df.format(obj.getJSONObject("weight").getDouble("pp")) + "pp (" + df.format(obj.getJSONObject("weight").getDouble("percentage")) + "%)" - + "\n" + df.format(obj.getDouble("accuracy") * 100) + "% " + (obj.getBoolean("perfect") ? "FC " + obj.getInt("max_combo") + "x" : obj.getInt("max_combo") + "x") + " (" + stats.getInt("count_300") + "/" + stats.getInt("count_100") + "/" + stats.getInt("count_50") + "/" + stats.getInt("count_miss") + ")") - + "\nPlayed on " + format.format(DatatypeConverter.parseDateTime(obj.getString("created_at")).getTime()); - eb.addField(((page - 1) * 10 + (i + 1)) + ". " + bsInfo.getString("artist") + " - " + bsInfo.getString("title") + " [" + bInfo.getString("version") + "]", info, false); //no unicode unfortunately*/ - } - eb.setFooter("Page: " + page, null); - eb.setColor(new Color(5, 255, 162)); - } catch (JSONException e1) { - return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e1)); + + for(int i = 0; i < array.length(); i++) { + JSONObject obj = array.getJSONObject(i); + String mods = obj.getJSONArray("mods").join("").replaceAll("\"", ""); + JSONObject stats = obj.getJSONObject("statistics"); + JSONObject bInfo = obj.getJSONObject("beatmap"); + JSONObject bsInfo = obj.getJSONObject("beatmapset"); + int bId = bInfo.getInt("id"); + + String info = "**" + obj.getString("rank").replaceAll("H", "+").replaceAll("X", "SS") + "**" + + (!mods.isEmpty() ? " **" + mods + "** " : " ") + + df.format(bInfo.getDouble("difficulty_rating")) + "*" + + " ([map link](https://osu.ppy.sh/beatmaps/" + bId + "))" + + "\nScore: " + obj.getInt("score") + + "\n**" + (obj.getDouble("pp") + "pp** | Weighted " + df.format(obj.getJSONObject("weight").getDouble("pp")) + "pp (" + df.format(obj.getJSONObject("weight").getDouble("percentage")) + "%)" + + "\n" + df.format(obj.getDouble("accuracy") * 100) + "% " + (obj.getBoolean("perfect") ? "FC " + obj.getInt("max_combo") + "x" : obj.getInt("max_combo") + "x") + " (" + stats.getInt("count_300") + "/" + stats.getInt("count_100") + "/" + stats.getInt("count_50") + "/" + stats.getInt("count_miss") + ")") + + "\nPlayed on " + format.format(DatatypeConverter.parseDateTime(obj.getString("created_at")).getTime()); + + eb.addField(((page - 1) * 10 + (i + 1)) + ". " + bsInfo.getString("artist") + " - " + bsInfo.getString("title") + " [" + bInfo.getString("version") + "]", info, false); //no unicode unfortunately*/ } + + eb.setFooter("Page: " + page, null); + eb.setColor(new Color(5, 255, 162)); channel.sendMessage(eb.build()).queue(); return new CommandResult(CommandResultType.SUCCESS); }, "[username] [|page]", Arrays.asList("", "| 2", "despawningbone", "-m despawningbone"), "Get the top plays of a user!", Arrays.asList( " * Supports `-t` for taiko, `-c` for catch, and `-m` for mania (Defaults to standard).")); - - /*registerSubCommand("search", Arrays.asList("s"), (channel, user, msg, words) -> { //need to deprecate google search //no longer works at all - String search = String.join(" ", Arrays.asList(words)); - List> url = new ArrayList<>(); - try { - url = GoogleSearch - .search(URLEncoder.encode("site:https://osu.ppy.sh/ " + search, "UTF-8"), 10); - } catch (IOException e1) { - e1.printStackTrace(); - } - if (!url.isEmpty()) { - String list = ""; - Stream> stream = url.stream(); - if (words[0].equals("mapsearch")) { - stream = stream.filter(n -> n.getValue().contains("/b/") || n.getValue().contains("/s/") - || n.getValue().contains("/beatmapsets/")); - } else if (words[0].equals("usersearch")) { - stream = stream.filter(n -> n.getValue().contains("/u/") || n.getValue().contains("/users/")); - } - list = "Search results for " + search + ":\n\n"; - list += stream - .map(e -> (e.getKey() + "\n" + e.getValue() + "\n").replaceAll("", "").replaceAll("", "")) - .collect(Collectors.joining("\n")); - if (!list.isEmpty()) { - channel.sendMessage(list).queue(); - return new CommandResult(CommandResultType.SUCCESS); - } else { - return new CommandResult(CommandResultType.NORESULT); - } - } else { - return new CommandResult(CommandResultType.NORESULT); - } - }, "search|usersearch|mapsearch [keywords]", Arrays.asList("usersearch cookiezi", "search big black"), - "Search the osu website!", null);*/ - + registerSubCommand("compare", Arrays.asList("c", "comp"), (channel, user, msg, words) -> { String map = "", name = "", img = "", mode = ""; + + //get which card to compare with, starting from newest to oldest int index = 1; String[] param = String.join(" ", words).split("\\|"); try { if(param.length > 1) index = Integer.parseInt(param[1].trim()); } catch(NumberFormatException e) { channel.sendMessage("Invalid index specified. Defaulting to the most recent scorecard...").queue(); } + + //iterate through recent 100 messsages for(Message card : channel.getHistory().retrievePast(100).complete()) { List embeds = card.getEmbeds(); if(embeds.size() > 0) { MessageEmbed embed = embeds.get(0); if(embed.getAuthor() == null) continue; String author = embed.getAuthor().getName(); + + //our own cards if(card.getAuthor().getId().equals(DiscordBot.BotID)) { + //recent if(author.contains("most recent osu!")) { map = embed.getUrl(); name = embed.getTitle().lastIndexOf("+") == -1 ? embed.getTitle().substring(0, embed.getTitle().lastIndexOf("(")) : embed.getTitle().substring(0, embed.getTitle().lastIndexOf("+")); img = embed.getThumbnail().getUrl(); mode = author.split("most recent osu!")[1].split(" play")[0]; + + //pp } else if(author.startsWith("PP information for")) { map = embed.getAuthor().getUrl(); name = author.split("PP information for ")[1] + " [" +embed.getFields().get(3).getValue() + "]"; img = embed.getAuthor().getIconUrl(); mode = embed.getDescription().replace("Mode: osu!", ""); } - } else if(card.getAuthor().getId().equals("289066747443675143")) { //owobot support + + //owobot support + } else if(card.getAuthor().getId().equals("289066747443675143")) { + //new score tracker if(author.startsWith("New") && author.contains("in osu!")) { String markdown = embed.getDescription().substring(7, embed.getDescription().indexOf("\n")).trim(); map = markdown.substring(markdown.lastIndexOf("(") + 1, markdown.length() - 2); name = markdown.substring(0, markdown.indexOf("__**]")); img = embed.getThumbnail().getUrl(); mode = author.substring(author.lastIndexOf("in osu! "), author.length()).trim(); - } else if(card.getContentDisplay().contains("Most Recent ")) { + + //>rs + } else if(card.getContentDisplay().contains("Recent ")) { map = embed.getAuthor().getUrl(); name = author.substring(0, author.lastIndexOf("+")); img = embed.getThumbnail().getUrl(); - mode = card.getContentDisplay().contains("Mania") ? "mania" : card.getContentDisplay().split("Most Recent ")[1].split(" ")[0]; //ye fucking blame owobot for being so inconsistent + mode = card.getContentDisplay().contains("Mania") ? "mania" : card.getContentDisplay().split("Recent ")[1].split(" ")[0]; //ye fucking blame owobot for being so inconsistent + + //map url response } else if(card.getContentDisplay().contains("map(s).")) { map = embed.getAuthor().getUrl(); map = map.substring(0, map.length() - (map.endsWith("/") ? 1 : 0)); //fucking hell the url actually changes according to how ppl trigger it name = author.split(" – ")[1]; String[] split = embed.getFields().get(0).getName().split("__", 3); name = name.substring(0, name.lastIndexOf(" by ")) + " [" + split[1] + "]"; img = "https://b.ppy.sh/thumb/" + embed.getDescription().split("\\[map\\]\\(https:\\/\\/osu\\.ppy\\.sh\\/d\\/", 2)[1].split("\\)", 2)[0] + ".jpg"; mode = split[2].isEmpty() ? "std" : split[2].substring(2, split[2].lastIndexOf("]") + 1); } } - if(index > 1) { + + //if card hit check index and decrement + reset if neded + if(index > 1 && !map.isEmpty()) { index--; map = ""; } + + //short circuit if found if(!map.isEmpty()) break; } } + + //yet another set of mode names so we have to parse int modeId = 0; if(mode.equalsIgnoreCase("taiko")) modeId = 1; else if(mode.equalsIgnoreCase("catch") || mode.equals("ctb")) modeId = 2; else if(mode.equalsIgnoreCase("mania")) modeId = 3; + + //if still doesnt exist after iteration, fail if(map.isEmpty()) return new CommandResult(CommandResultType.FAILURE, "There are no recent map/score cards to compare with in the past 100 messages!"); + + //if name or id is provided in the command use it else fallback to requester String uid = param[0].isEmpty() ? getPlayer(user) : param[0].replaceAll(" ", "%20"); + + //get scores to compare try { URL url = new URL("https://osu.ppy.sh/api/get_scores?k=" + osuAPI + "&b=" + map.substring(map.lastIndexOf("/") + 1) + "&u=" + uid + "&m=" + modeId); JSONArray arr = new JSONArray(new JSONTokener(url.openStream())); + + //build embed EmbedBuilder eb = new EmbedBuilder(); eb.setTitle("Top plays for " + arr.getJSONObject(0).getString("username") + " on " + name); eb.setColor(new Color(237, 154, 70)); eb.setThumbnail(img); + + //iterate through existing scores for this beatmap and add it to the embed for(int i = 0; i < arr.length(); i++) { JSONObject score = arr.getJSONObject(i); String mods = Koohii.mods_str(score.getInt("enabled_mods")); Accuracy acc = new Accuracy(score.getInt("count300"), score.getInt("count100"), score.getInt("count50"), score.getInt("countmiss")); + String info = "**`" + (mods.isEmpty() ? "No mods" : mods) + "` Score:\n" + score.getString("rank").replaceAll("H", "+").replaceAll("X", "SS") + " " + (score.isNull("pp") ? "No " : score.getDouble("pp")) + "pp** | Score **" + score.getInt("score") + "**\n" - + df.format(acc.value() * 100) + "% " + (score.getInt("perfect") == 1 ? "FC " + score.getInt("maxcombo") + "x" : score.getInt("maxcombo") + "x") + " (" + acc.n300 + "/" + acc.n100 + "/" + acc.n50 + "/" + acc.nmisses + ")\n" + + df.format(acc.value(modeId) * 100) + "% " + (score.getInt("perfect") == 1 ? "FC " + score.getInt("maxcombo") + "x" : score.getInt("maxcombo") + "x") + " (" + acc.n300 + "/" + acc.n100 + "/" + acc.n50 + "/" + acc.nmisses + ")\n" + "Played on " + score.getString("date") + (score.getInt("replay_available") == 1 ? " (Replay available)" : ""); eb.appendDescription(info + "\n\n"); } + channel.sendMessage(eb.build()).queue(); return new CommandResult(CommandResultType.SUCCESS); } catch (IOException e) { return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e)); } catch (JSONException e) { e.printStackTrace(); return new CommandResult(CommandResultType.INVALIDARGS, "Unknown player `" + uid + "` or the player has no scores on this map."); } }, "[username] [| index]", null, "Compare your score with the most recent map/score card in the channel!", Arrays.asList(" * Specify the index to skip to the nth recent score card in the channel.")); } private static double weightForPP(double pp, String ibid, JSONArray userBest) { - //String[] dataset = test.split(" "); - //List datas = Arrays.asList(dataset).stream().map(s -> Double.parseDouble(s)).collect(Collectors.toList()); - double net = 0, change = -1; List pps = new ArrayList<>(); + + //get new top pp list, replacing old score if needed for(int i = 0; i < userBest.length(); i++) { //something like a treemap might work more elegantly, but i need to add pp to the list anyways so its fine i guess - //for(int i = 0; i < datas.size(); i++) { JSONObject obj = userBest.getJSONObject(i); double v = obj.getDouble("pp"); - //double v = datas.get(i); - //System.out.println("raw" + v); String bid = obj.getString("beatmap_id"); if(bid.equals(ibid)) { if(v >= pp) { return -1; } else { change = v; } } - if(v <= pp && !pps.contains(pp)) { + if(v <= pp && !pps.contains(pp)) { //insert on first smaller occurence pps.add(pp); } pps.add(v); } + + //doesnt even get on top list - no pp gain if(pps.indexOf(pp) == -1) { return 0; } - //System.out.println(pps.indexOf(pp)); + + //calculate net weighted gain if(change == -1) { double last = pps.size() > 100 ? pps.remove(100) * (Math.pow(0.95, 99)): 0; for(int i = pps.indexOf(pp); i < pps.size(); i++) { double c = pps.get(i); double w = c*(Math.pow(0.95, i)); - //System.out.println("pp" + w); if(i == pps.indexOf(pp)) { net += w; } else { net += c*(Math.pow(0.95, i - 1)) * (0.95 - 1); } - //System.out.println("t1" + net); } - //System.out.println("last" + last); net -= last; //because the last one is completely not counted, not just shifted to a lower value } else { int old = pps.indexOf(change) - 1; pps.remove(change); for(int i = pps.indexOf(pp); i <= old; i++) { double c = pps.get(i); double w = c*(Math.pow(0.95, i)); if(c == pp) { net += w - change*(Math.pow(0.95, old)); } else { net += w * (0.95 - 1); } } } return net; } - private static JSONArray getUserBest(String id, int mode) { + //note: stripped ids are considered usernames - if you are sure that its an id (eg result from api) then wrap it in an url + private static JSONObject getPlayerData(String api, String input, int modeId, User requester) throws IOException { + //check if user url provided, if not assume username + String id = ""; boolean noturl = false; try { - new URL(id); - id = id.substring(id.lastIndexOf("/") + 1); + new URL(input); + id = input.substring(input.lastIndexOf("/") + 1); } catch (MalformedURLException e) { noturl = true; + id = input; } - JSONTokener result = null; - InputStream stream = null; - URL url = null; - try { - String addparam = ""; - if (noturl) { - addparam = "&type=string"; - } - //System.out.println("https://osu.ppy.sh/api/get_user_best?k=" + osuAPI + "&m=" + mode + "&u=" + URLEncoder.encode(id, "UTF-8") + addparam + "&limit=100"); - url = new URL( - "https://osu.ppy.sh/api/get_user_best?k=" + osuAPI + "&m=" + mode + "&u=" + URLEncoder.encode(id, "UTF-8") + addparam + "&limit=100"); - stream = url.openStream(); - if (stream.available() < 4) { - throw new IllegalArgumentException("Unknown player `" + id + "`."); - } - } catch (IOException ex) { - ex.printStackTrace(); + + //if empty fallback to own + if(id.isEmpty()) { + id = getPlayer(requester); + } + + //set params and fetch + String addparam = ""; + if (noturl) { //search as usernames + addparam = "&type=string"; + } + if (modeId != 0) { //change mode + addparam += ("&m=" + modeId); + } + + //limit either gets ignored or truncated to highest - is fine if i leave it as 100 (highest of any apis i use) + JSONArray a = new JSONArray(new JSONTokener(new URL("https://osu.ppy.sh/api/" + api + "?k=" + osuAPI + "&u=" + URLEncoder.encode(id, "UTF-8") + addparam + "&limit=100").openStream())); + + //always return the val we did the search with, and only result if it exists + JSONObject wrap = new JSONObject("{\"search\": \"" + id + "\", \"isId\": " + !noturl + "}"); + if (a.length() > 0) { + wrap.put("result", a); + } + + return wrap; + } + + //TODO find a way to parallelize map fetching and meta fetching if possible? + private static JSONObject computePP(Map beatmap, String bid, int mods, int n50, int n100, int n300, int miss, int combo) throws IllegalArgumentException, IOException { + //set osu-wide beatmap params (non mode specific) + PlayParameters p = new Koohii.PlayParameters(); + p.beatmap = beatmap; + p.n50 = n50; + p.n100 = n100; + p.n300 = n300; + p.nmiss = miss; + p.nobjects = p.n300 + p.n100 + p.n50 + p.nmiss; + p.mods = mods; + if (combo > 0) { + p.combo = combo; + } else { + p.combo = p.beatmap.max_combo(); } - result = new JSONTokener(stream); - return new JSONArray(result); + + //get beatmap metadata + String addparam = ""; + if(p.beatmap.mode == 1) { //these mods alter metadata that taiko needs + if((mods & Koohii.MODS_HT) != 0) addparam = "&mods=256"; + if((mods & (Koohii.MODS_DT | Koohii.MODS_NC)) != 0) if(addparam.isEmpty()) addparam = "&mods=64"; + else addparam = ""; + } + URL url = new URL("https://osu.ppy.sh/api/get_beatmaps?k=" + osuAPI + "&b=" + bid + addparam); + JSONObject jbm = new JSONArray(new JSONTokener(url.openStream())).getJSONObject(0); + + //finally calc pp and put it in metadata + MapStats stats = new MapStats(p.beatmap); + switch(p.beatmap.mode) { + case 0: //osu + //std specific params - calc diff and acc + DiffCalc stars = new Koohii.DiffCalc().calc(p.beatmap, mods); + p.speed_stars = stars.speed; + p.aim_stars = stars.aim; + + PPv2 oPP = new PPv2(p); + jbm.put("ppVal", oPP.total); + jbm.put("accVal", oPP.computed_accuracy.value(0)); //use computed + jbm.put("starsVal", stars.total); + jbm.put("ppMax", constructPP(stars.aim, stars.speed, p.beatmap, mods).total); + Koohii.mods_apply(mods, stats, 1|2|4|8); + break; + case 1: //taiko + //taiko specific params - get diff from metadata since theres no calculating mechanism + p.speed_stars = jbm.getDouble("difficultyrating"); + + TaikoPP tPP = new TaikoPP(p); + jbm.put("ppVal", tPP.pp); + jbm.put("accVal", tPP.acc); + jbm.put("starsVal", p.speed_stars); + jbm.put("ppMax", new TaikoPP(p.speed_stars, p.max_combo, mods, 1, 0, p.beatmap).pp); + tPP.taiko_mods_apply(mods, stats); + break; + default: + throw new UnsupportedOperationException("This gamemode is not yet supported."); + } + + //mods_apply updates the values, so we cant use beatmap.cs etc and we gotta give back via jbm + jbm.put("cs", stats.cs); + jbm.put("hp", stats.hp); + jbm.put("ar", stats.ar); + jbm.put("od", stats.od); + jbm.put("speed", stats.speed); + + return jbm; } private static PPv2 constructPP(double aim_stars, double speed_stars, Map b, int mods) { - PPv2Parameters p = new PPv2Parameters(); + PlayParameters p = new PlayParameters(); p.aim_stars = aim_stars; p.speed_stars = speed_stars; p.beatmap = b; p.nobjects = b.objects.size(); p.mods = mods; return new PPv2(p); } private static String getPlayer(User user) { //say im getting from presence/name? try (Connection con = DiscordBot.db.getConnection()){ ResultSet rs = con.createStatement().executeQuery("SELECT game FROM users WHERE id = " + user.getId() + ";"); String player = rs.next() ? player = rs.getString(1).substring(0, rs.getString(1).indexOf("||")) : user.getName(); rs.close(); return player; } catch (SQLException e) { return user.getName(); } } private static int getMode(List params) { - int modeId = 0; String mode = ""; - params.replaceAll(String::toLowerCase); - if(params.contains("-t")) { - mode = "-t"; + int modeId = 0; + params.replaceAll(String::toLowerCase); //all searching should be case insensitive anyways, is fine if we change the entire thing + + //pass by "ref" so it removes from the list; + //iterates over all mode params and remove, giving priority according to descending id and defaulting to standard (0) + if(params.removeAll(Collections.singleton("-t"))) modeId = 1; - } - if(params.contains("-c")) { - mode = "-c"; + + if(params.removeAll(Collections.singleton("-c"))) modeId = 2; - } - if(params.contains("-m")) { - mode = "-m"; + + if(params.removeAll(Collections.singleton("-m"))) modeId = 3; - } - params.removeAll(Collections.singleton(mode)); //pass by ref so it removes from the list + return modeId; } private static InputStream getMap(String origURL) throws IOException { + //get beatmap id String id = origURL.substring(origURL.lastIndexOf("/") + 1); - if (origURL.contains("/beatmapsets/")) { + if (origURL.contains("/beatmapsets/")) { //new urls with this format must include # to specify beatmap, or else its a set if (!origURL.contains("#")) throw new IllegalArgumentException("Please specify a difficulty, instead of giving a set URL."); - } else { + } else { //either /beatmap/ or old url /b/ /s/, latter of which we dont want if (origURL.contains("/s/")) throw new IllegalArgumentException("Please specify a difficulty, instead of giving a set URL."); id = id.split("&")[0]; //remove things like &m=0, which is no need } + + //fetch map URLConnection mapURL; try { - mapURL = new URL("https://osu.ppy.sh/osu/" + id).openConnection(); - mapURL.setRequestProperty("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:57.0)"); - } catch (IOException e) { - //e.printStackTrace(); - throw new IllegalArgumentException("Invalid beatmap URL."); - } - try { - return mapURL.getInputStream(); - } catch (IOException e) { - if(e.getMessage().contains("503")) { //most likely cloudflare, unless they change the request method which is very unlikely - mapURL = new URL("https://bloodcat.com/osu/b/" + id).openConnection(); - mapURL.setRequestProperty("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:57.0)"); - return mapURL.getInputStream(); //not catching this, if even this doesnt work just throw something went wrong. - } else { - throw e; - } + mapURL = new URL("https://osu.ppy.sh/osu/" + id).openConnection(); //no longer has any IOExceptions, always return 200 no matter what + mapURL.setRequestProperty("User-Agent", "Mozilla/4.0"); + + InputStream stream = mapURL.getInputStream(); + + if (stream.available() < 4) throw new IOException(); //to fallback + + return stream; + } catch (IOException e) { //try fallback regardless of exception reason + //fallback to bloodcat successor - much more convoluted and slower to fetch a single map now + //get map info + mapURL = new URL("https://api.chimu.moe/v1/map/" + id).openConnection(); + mapURL.setRequestProperty("User-Agent", "Mozilla/4.0"); + + JSONObject mapData = new JSONObject(new JSONTokener(mapURL.getInputStream())).getJSONObject("data"); + + if(mapData.length() == 0) throw new IOException(e); //invalid beatmap + + String mapName = mapData.getString("OsuFile").replaceAll("[\\\\/:*?\"<>|]", ""); //apparently the name returned isnt windows safe, so sanitize + + //fetch beatmapset + mapURL = new URL("https://chimu.moe" + mapData.getString("DownloadPath")).openConnection(); + mapURL.setRequestProperty("User-Agent", "Mozilla/4.0"); + + ZipInputStream set = new ZipInputStream(mapURL.getInputStream()); + + //iterate to get correct map + ZipEntry map = null; + while((map = set.getNextEntry()) != null) //assume always have the file specified in map info + if(map.getName().equals(mapName)) + return set; //found at input stream current pointer + + throw new IOException(e); //not catching this, if even this doesnt work just throw something went wrong. } } }