/**
 * @file
 * @brief deal with reading and writing of highscore file
**/

/*
 * ----------- MODIFYING THE PRINTED SCORE FORMAT ---------------------
 *   Do this at your leisure. Change hiscores_format_single() as much
 * as you like.
 *
 */

#include "AppHdr.h"

#include "hiscores.h"

#include <algorithm>
#include <cctype>
#include <cstdio>
#include <cstdlib>
#include <memory>
#if defined(UNIX) || defined(TARGET_COMPILER_MINGW)
#include <unistd.h>
#endif

#include "branch.h"
#include "chardump.h"
#include "cio.h"
#include "dungeon.h"
#include "end.h"
#include "english.h"
#include "files.h"
#include "initfile.h"
#include "item-prop.h"
#include "item-status-flag-type.h"
#include "items.h"
#include "jobs.h"
#ifdef USE_TILE_WEB
# include "json.h"
# include "json-wrapper.h"
#endif
#include "kills.h"
#include "libutil.h"
#include "menu.h"
#include "misc.h"
#include "mon-util.h"
#include "options.h"
#include "ouch.h"
#include "place.h"
#include "religion.h"
#include "scroller.h"
#include "skills.h"
#include "state.h"
#include "status.h"
#include "stringutil.h"
#ifdef USE_TILE
 #include "tilepick.h"
#endif
#ifdef USE_TILE_LOCAL
 #include "tilereg-crt.h"
#endif
#include "unwind.h"
#include "version.h"
#include "outer-menu.h"

using namespace ui;

#define SCORE_VERSION "0.1"

// enough memory allocated to snarf in the scorefile entries
static unique_ptr<scorefile_entry> hs_list[SCORE_FILE_ENTRIES];
static int hs_list_size = 0;
static bool hs_list_initialized = false;

static FILE *_hs_open(const char *mode, const string &filename);
static void  _hs_close(FILE *handle);
static bool  _hs_read(FILE *scores, scorefile_entry &dest);
static void  _hs_write(FILE *scores, scorefile_entry &entry);
static time_t _parse_time(const string &st);
static string _xlog_escape(const string &s);
static string _xlog_unescape(const string &s);
static vector<string> _xlog_split_fields(const string &s);

static string _score_file_name()
{
    string ret;
    if (!SysEnv.scorefile.empty())
        ret = SysEnv.scorefile;
    else
        ret = catpath(Options.shared_dir, "scores");

    ret += crawl_state.game_type_qualifier();
    if (crawl_state.game_is_sprint() && !crawl_state.map.empty())
        ret += "-" + crawl_state.map;

    return ret;
}

static string _log_file_name(bool milestones=false)
{
    return catpath(Options.shared_dir,
        (milestones ? "milestones" : "logfile")
        + crawl_state.game_type_qualifier());
}

int hiscores_new_entry(const scorefile_entry &ne)
{
    unwind_bool score_update(crawl_state.updating_scores, true);

    FILE *scores;
    int i;
    bool inserted = false;
    int newest_entry = -1;

    // open highscore file (reading) -- nullptr is fatal!
    //
    // Opening as a+ instead of r+ to force an exclusive lock (see
    // hs_open) and to create the file if it's not there already.
    scores = _hs_open("a+", _score_file_name());
    if (scores == nullptr)
        end(1, true, "failed to open score file for writing");

    // we're at the end of the file, seek back to beginning.
    fseek(scores, 0, SEEK_SET);

    // read highscore file, inserting new entry at appropriate point,
    for (i = 0; i < SCORE_FILE_ENTRIES; i++)
    {
        hs_list[i].reset(new scorefile_entry);
        if (_hs_read(scores, *hs_list[i]) == false)
            break;

        // compare points..
        if (!inserted && ne.get_score() >= hs_list[i]->get_score())
        {
            newest_entry = i;           // for later printing
            inserted = true;
            // copy read entry to i+1th position
            // Fixed a nasty overflow bug here -- Sharp
            if (i+1 < SCORE_FILE_ENTRIES)
            {
                hs_list[i + 1] = std::move(hs_list[i]);
                hs_list[i].reset(new scorefile_entry(ne));
                i++;
            }
            else
                *hs_list[i] = ne; // copy new entry to current position
        }
    }

    // special case: lowest score, with room
    if (!inserted && i < SCORE_FILE_ENTRIES)
    {
        newest_entry = i;
        inserted = true;
        // copy new entry
        hs_list[i].reset(new scorefile_entry(ne));
        i++;
    }

    hs_list_size = i;
    hs_list_initialized = true;

    // If we've still not inserted it, it's not a highscore.
    if (!inserted)
    {
        _hs_close(scores);
        return -1;
    }

    // The old code closed and reopened the score file, leading to a
    // race condition where one Crawl process could overwrite the
    // other's highscore. Now we truncate and rewrite the file without
    // closing it.
    if (ftruncate(fileno(scores), 0))
        end(1, true, "unable to truncate scorefile");

    rewind(scores);

    // write scorefile entries.
    for (i = 0; i < hs_list_size; i++)
    {
        _hs_write(scores, *hs_list[i]);

        // Leave in memory. Does this anyway if !inserted.
        // Can write cleanup function if nessicary??
        // hs_list[i].reset(nullptr);
    }

    // close scorefile.
    _hs_close(scores);
    return newest_entry;
}

void logfile_new_entry(const scorefile_entry &ne)
{
    unwind_bool logfile_update(crawl_state.updating_scores, true);

    FILE *logfile;
    scorefile_entry le = ne;

    // open logfile (appending) -- nullptr *is* fatal here.
    logfile = _hs_open("a", _log_file_name());
    if (logfile == nullptr)
    {
        mprf(MSGCH_ERROR, "ERROR: failure writing to the logfile.");
        return;
    }

    _hs_write(logfile, le);

    // close logfile.
    _hs_close(logfile);
}

template <class t_printf>
static void _hiscores_print_entry(const scorefile_entry &se,
                                  int index, int format, t_printf pf)
{
    char buf[200];
    // print position (tracked implicitly by order score file)
    snprintf(buf, sizeof buf, "%3d.", index + 1);

    pf("%s", buf);

    string entry;
    // format the entry
    if (format == SCORE_TERSE)
        entry = hiscores_format_single(se);
    else
        entry = hiscores_format_single_long(se, (format == SCORE_VERBOSE));

    entry += "\n";
    pf("%s", entry.c_str());
}

// Reads hiscores file to memory
void hiscores_read_to_memory()
{
    FILE *scores;
    int i;

    // open highscore file (reading)
    scores = _hs_open("r", _score_file_name());
    if (scores == nullptr)
        return;

    // read highscore file
    for (i = 0; i < SCORE_FILE_ENTRIES; i++)
    {
        hs_list[i].reset(new scorefile_entry);
        if (_hs_read(scores, *hs_list[i]) == false)
            break;
    }

    hs_list_size = i;
    hs_list_initialized = true;

    //close off
    _hs_close(scores);
}

// Writes all entries in the scorefile to stdout in human-readable form.
void hiscores_print_all(int display_count, int format)
{
    unwind_bool scorefile_display(crawl_state.updating_scores, true);

    FILE *scores = _hs_open("r", _score_file_name());
    if (scores == nullptr)
    {
        // will only happen from command line
        puts("No scores.");
        return;
    }

    for (int entry = 0; display_count <= 0 || entry < display_count; ++entry)
    {
        scorefile_entry se;
        if (!_hs_read(scores, se))
            break;

        if (format == -1)
            printf("%s", se.raw_string().c_str());
        else
            _hiscores_print_entry(se, entry, format, printf);
    }

    _hs_close(scores);
}

// Displays high scores using curses. For output to the console, use
// hiscores_print_all.
string hiscores_print_list(int display_count, int format, int newest_entry, int& start_out)
{
    unwind_bool scorefile_display(crawl_state.updating_scores, true);
    string ret;

    // Additional check to preserve previous functionality
    if (!hs_list_initialized)
        hiscores_read_to_memory();

    int i, total_entries;

    if (display_count <= 0)
        return "";

    total_entries = hs_list_size;

    int start = newest_entry - display_count / 2;

    if (start + display_count > total_entries)
        start = total_entries - display_count;

    if (start < 0)
        start = 0;

    const int finish = start + display_count;

    for (i = start; i < finish && i < total_entries; i++)
    {
        // check for recently added entry
        if (i == newest_entry)
            ret += "<yellow>";

        _hiscores_print_entry(*hs_list[i], i, format, [&ret](const char */*fmt*/, const char *s){
            ret += string(s);
        });

        // return to normal color for next entry
        if (i == newest_entry)
            ret += "<lightgrey>";
    }

    start_out = start;
    return ret;
}

static void _show_morgue(scorefile_entry& se)
{
    int flags = FS_PREWRAPPED_TEXT;
    formatted_scroller morgue_file(flags);

    morgue_file.set_tag("morgue");
    morgue_file.set_more();

    string morgue_base = morgue_name(se.get_name(), se.get_death_time());
    string morgue_path = catpath(morgue_directory(),
                            strip_filename_unsafe_chars(morgue_base) + ".txt");
    FILE* morgue = lk_open("r", morgue_path);

    if (!morgue) // TODO: add an error message
        return;

    char buf[200];
    string morgue_text = "";

    while (fgets(buf, sizeof buf, morgue) != nullptr)
    {
        string line = string(buf);
        size_t newline_pos = line.find_last_of('\n');
        if (newline_pos != string::npos)
            line.erase(newline_pos);
        morgue_text += "<w>" + replace_all(line, "<", "<<") + "</w>" + '\n';
    }

    lk_close(morgue);

    column_composer cols(2, 40);
    cols.add_formatted(
            0,
            morgue_text,
            true);

    vector<formatted_string> blines = cols.formatted_lines();

    unsigned i;
    for (i = 0; i < blines.size(); ++i)
        morgue_file.add_formatted_string(blines[i], true);

    morgue_file.show();
}

class UIHiscoresMenu : public Widget
{
public:
    UIHiscoresMenu();

    virtual shared_ptr<Widget> get_child_at_offset(int, int) override {
        return static_pointer_cast<Widget>(m_root);
    }

    virtual void _render() override;
    virtual SizeReq _get_preferred_size(Direction dim, int prosp_width) override;
    virtual void _allocate_region() override;

    void on_show();

    bool done = false;

private:
    void _construct_hiscore_table();
    void _add_hiscore_row(scorefile_entry& se, int id);

    Widget* initial_focus = nullptr;
    bool have_allocated {false};

    shared_ptr<Box> m_root;
    shared_ptr<Text> m_description;
    shared_ptr<OuterMenu> m_score_entries;
};

static int nhsr;

UIHiscoresMenu::UIHiscoresMenu()
{
    m_root = make_shared<Box>(Widget::VERT);
    add_internal_child(m_root);
    m_root->set_cross_alignment(Widget::STRETCH);

    auto title_hbox = make_shared<Box>(Widget::HORZ);
    title_hbox->set_margin_for_sdl(0, 0, 20, 0);
    title_hbox->set_margin_for_crt(0, 0, 1, 0);

#ifdef USE_TILE
    auto tile = make_shared<Image>();
    tile->set_tile(tile_def(TILEG_STARTUP_HIGH_SCORES));
    title_hbox->add_child(std::move(tile));
#endif

    auto title = make_shared<Text>(formatted_string(
                CRAWL ": High Scores", YELLOW));
    title->set_margin_for_sdl(0, 0, 0, 16);
    title_hbox->add_child(std::move(title));

    title_hbox->set_main_alignment(Widget::CENTER);
    title_hbox->set_cross_alignment(Widget::CENTER);

    m_description = make_shared<Text>(string(9, '\n'));

    m_score_entries= make_shared<OuterMenu>(true, 1, 100);
    nhsr = 0;
    _construct_hiscore_table();

    m_root->add_child(std::move(title_hbox));
    if (initial_focus)
    {
        m_root->add_child(m_description);
        m_root->add_child(m_score_entries);
    }
    else
    {
        auto placeholder = formatted_string("No high scores yet...", DARKGRAY);
        m_root->add_child(make_shared<Text>(placeholder));
        initial_focus = this;
    }

    on_hotkey_event([this](const KeyEvent& ev) {
        return done = (key_is_escape(ev.key()) || ev.key() == CK_MOUSE_CMD);
    });
}

void UIHiscoresMenu::_construct_hiscore_table()
{
    FILE *scores = _hs_open("r", _score_file_name());

    if (scores == nullptr)
        return;

    int i;
    // read highscore file
    for (i = 0; i < SCORE_FILE_ENTRIES; i++)
    {
        hs_list[i].reset(new scorefile_entry);
        if (_hs_read(scores, *hs_list[i]) == false)
            break;
    }

    _hs_close(scores);

    for (int j=0; j<i; j++)
        _add_hiscore_row(*hs_list[j], j);
}

void UIHiscoresMenu::_add_hiscore_row(scorefile_entry& se, int id)
{
    auto tmp = make_shared<Text>();

    tmp->set_text(hiscores_format_single(se));
    auto btn = make_shared<MenuButton>();
    tmp->set_margin_for_sdl(2);
    btn->set_child(std::move(tmp));
    btn->on_activate_event([id](const ActivateEvent&) {
        _show_morgue(*hs_list[id]);
        return true;
    });
    btn->on_focusin_event([this, se](const FocusEvent&) {
        formatted_string desc(hiscores_format_single_long(se, true));
        desc.cprintf(string(max(0, 9-count_linebreaks(desc)), '\n'));
        m_description->set_text(std::move(desc));
        return false;
    });

    if (!initial_focus)
        initial_focus = btn.get();
    m_score_entries->add_button(std::move(btn), 0, nhsr++);
}

void UIHiscoresMenu::_render()
{
    m_root->render();
}

void UIHiscoresMenu::on_show()
{
    ui::set_focused_widget(initial_focus);
}

SizeReq UIHiscoresMenu::_get_preferred_size(Direction dim, int prosp_width)
{
    return m_root->get_preferred_size(dim, prosp_width);
}

void UIHiscoresMenu::_allocate_region()
{
    if (!have_allocated)
    {
        have_allocated = true;
        on_show();
    }
    m_root->allocate_region(m_region);
}

void show_hiscore_table()
{
    unwind_var<string> sprintmap(crawl_state.map, crawl_state.sprint_map);
    auto hiscore_ui = make_shared<UIHiscoresMenu>();
    auto popup = make_shared<ui::Popup>(hiscore_ui);
    ui::run_layout(std::move(popup), hiscore_ui->done);
}

// Trying to supply an appropriate verb for the attack type. -- bwr
static const char *_range_type_verb(const char *const aux)
{
    if (strncmp(aux, "Shot ", 5) == 0)                // launched
        return "shot";
    else if (aux[0] == 0                                // unknown
             || strncmp(aux, "Hit ", 4) == 0          // thrown
             || strncmp(aux, "volley ", 7) == 0)      // manticore spikes
    {
        return "hit from afar";
    }

    return "blasted";                                 // spells, wands
}

string hiscores_format_single(const scorefile_entry &se)
{
    return se.hiscore_line(scorefile_entry::DDV_ONELINE);
}

static bool _hiscore_same_day(time_t t1, time_t t2)
{
    struct tm *d1  = TIME_FN(&t1);
    const int year = d1->tm_year;
    const int mon  = d1->tm_mon;
    const int day  = d1->tm_mday;

    struct tm *d2  = TIME_FN(&t2);

    return d2->tm_mday == day && d2->tm_mon == mon && d2->tm_year == year;
}

static string _hiscore_date_string(time_t time)
{
    struct tm *date = TIME_FN(&time);

    const char *mons[12] = { "Jan", "Feb", "Mar", "Apr", "May", "June",
                             "July", "Aug", "Sept", "Oct", "Nov", "Dec" };

    return make_stringf("%s %d, %d", mons[date->tm_mon], date->tm_mday,
                                     date->tm_year + 1900);
}

static string _hiscore_newline_string()
{
    return "\n             ";
}

string hiscores_format_single_long(const scorefile_entry &se, bool verbose)
{
    return se.hiscore_line(verbose ? scorefile_entry::DDV_VERBOSE
                                   : scorefile_entry::DDV_NORMAL);
}

// --------------------------------------------------------------------------
// BEGIN private functions
// --------------------------------------------------------------------------

static FILE *_hs_open(const char *mode, const string &scores)
{
    // allow reading from standard input
    if (scores == "-")
        return stdin;

    return lk_open(mode, scores);
}

static void _hs_close(FILE *handle)
{
    lk_close(handle);
}

static bool _hs_read(FILE *scores, scorefile_entry &dest)
{
    char inbuf[1500];
    if (!scores || feof(scores))
        return false;

    memset(inbuf, 0, sizeof inbuf);
    dest.reset();

    if (!fgets(inbuf, sizeof inbuf, scores))
        return false;

    return dest.parse(inbuf);
}

static int _val_char(char digit)
{
    return digit - '0';
}

static time_t _parse_time(const string &st)
{
    struct tm  date;

    if (st.length() < 15)
        return static_cast<time_t>(0);

    date.tm_year  =   _val_char(st[0]) * 1000 + _val_char(st[1]) * 100
                    + _val_char(st[2]) *   10 + _val_char(st[3]) - 1900;

    date.tm_mon   = _val_char(st[4])  * 10 + _val_char(st[5]);
    date.tm_mday  = _val_char(st[6])  * 10 + _val_char(st[7]);
    date.tm_hour  = _val_char(st[8])  * 10 + _val_char(st[9]);
    date.tm_min   = _val_char(st[10]) * 10 + _val_char(st[11]);
    date.tm_sec   = _val_char(st[12]) * 10 + _val_char(st[13]);
    date.tm_isdst = (st[14] == 'D');

    return mktime(&date);
}

static void _hs_write(FILE *scores, scorefile_entry &se)
{
    fprintf(scores, "%s", se.raw_string().c_str());
}

static const char *kill_method_names[] =
{
    "mon", "pois", "cloud", "beam", "lava", "water",
    "stupidity", "weakness", "clumsiness", "trap", "leaving", "winning",
    "quitting", "wizmode", "draining", "starvation", "freezing", "burning",
    "wild_magic", "xom", "rotting", "targeting", "death_explosion",
    "tso_smiting", "petrification", "something",
    "falling_down_stairs", "acid", "curare",
    "beogh_smiting", "divine_wrath", "bounce", "reflect", "self_aimed",
    "falling_through_gate", "disintegration", "headbutt", "rolling",
    "mirror_damage", "spines", "frailty", "barbs", "being_thrown",
    "collision", "zot", "constriction", "exploremode", "blinking",
    "death curse",
};

static const char *_kill_method_name(kill_method_type kmt)
{
    COMPILE_CHECK(NUM_KILLBY == ARRAYSZ(kill_method_names));

    if (kmt == NUM_KILLBY)
        return "";

    return kill_method_names[kmt];
}

static kill_method_type _str_to_kill_method(const string &s)
{
    COMPILE_CHECK(NUM_KILLBY == ARRAYSZ(kill_method_names));

    for (int i = 0; i < NUM_KILLBY; ++i)
    {
        if (s == kill_method_names[i])
            return static_cast<kill_method_type>(i);
    }

    return NUM_KILLBY;
}

//////////////////////////////////////////////////////////////////////////
// scorefile_entry

scorefile_entry::scorefile_entry(int dam, mid_t dsource, int dtype,
                                 const char *aux, bool death_cause_only,
                                 const char *dsource_name, time_t dt)
{
    reset();

    init_death_cause(dam, dsource, dtype, aux, dsource_name);
    if (!death_cause_only)
        init(dt);
}

scorefile_entry::scorefile_entry()
{
    // Completely uninitialised, caveat user.
    reset();
}

scorefile_entry::scorefile_entry(const scorefile_entry &se)
{
    init_from(se);
}

scorefile_entry &scorefile_entry::operator = (const scorefile_entry &se)
{
    init_from(se);
    return *this;
}

void scorefile_entry::init_from(const scorefile_entry &se)
{
    version            = se.version;
    save_rcs_version   = se.save_rcs_version;
    save_tag_version   = se.save_tag_version;
    tiles              = se.tiles;
    points             = se.points;
    name               = se.name;
    race               = se.race;
    job                = se.job;
    race_class_name    = se.race_class_name;
    lvl                = se.lvl;
    best_skill         = se.best_skill;
    best_skill_lvl     = se.best_skill_lvl;
    title              = se.title;
    death_type         = se.death_type;
    death_source       = se.death_source;
    death_source_name  = se.death_source_name;
    death_source_flags = se.death_source_flags;
    auxkilldata        = se.auxkilldata;
    indirectkiller     = se.indirectkiller;
    killerpath         = se.killerpath;
    last_banisher      = se.last_banisher;
    dlvl               = se.dlvl;
    absdepth           = se.absdepth;
    branch             = se.branch;
    map                = se.map;
    mapdesc            = se.mapdesc;
    killer_map         = se.killer_map;
    final_hp           = se.final_hp;
    final_max_hp       = se.final_max_hp;
    final_max_max_hp   = se.final_max_max_hp;
    final_mp           = se.final_mp;
    final_max_mp       = se.final_max_mp;
    final_base_max_mp  = se.final_base_max_mp;
    damage             = se.damage;
    source_damage      = se.source_damage;
    turn_damage        = se.turn_damage;
    str                = se.str;
    intel              = se.intel;
    dex                = se.dex;
    ac                 = se.ac;
    ev                 = se.ev;
    sh                 = se.sh;
    god                = se.god;
    piety              = se.piety;
    penance            = se.penance;
    wiz_mode           = se.wiz_mode;
    explore_mode       = se.explore_mode;
    birth_time         = se.birth_time;
    death_time         = se.death_time;
    real_time          = se.real_time;
    num_turns          = se.num_turns;
    num_aut            = se.num_aut;
    num_diff_runes     = se.num_diff_runes;
    num_runes          = se.num_runes;
    gems_found         = se.gems_found;
    gems_intact        = se.gems_intact;
    kills              = se.kills;
    maxed_skills       = se.maxed_skills;
    fifteen_skills     = se.fifteen_skills;
    status_effects     = se.status_effects;
    gold               = se.gold;
    gold_spent         = se.gold_spent;
    gold_found         = se.gold_found;
    zigs               = se.zigs;
    zigmax             = se.zigmax;
    scrolls_used       = se.scrolls_used;
    potions_used       = se.potions_used;
    seed               = se.seed;
    fixup_char_name();

    // We could just reset raw_line to "" instead.
    raw_line          = se.raw_line;
}

actor* scorefile_entry::killer() const
{
    return actor_by_mid(death_source);
}

xlog_fields scorefile_entry::get_fields() const
{
    if (!fields)
        return xlog_fields();
    else
        return *fields;
}

bool scorefile_entry::parse(const string &line)
{
    // Scorefile formats down the ages:
    //
    // 1) old-style lines which were 80 character blocks
    // 2) 4.0 pr1 through pr7 versions which were newline terminated
    // 3) 4.0 pr8 and onwards which are colon-separated fields (and
    //    start with a colon), and may exceed 80 characters!
    // 4) 0.2 and onwards, which are xlogfile format - no leading
    //    colon, fields separated by colons, each field specified as
    //    key=value. Colons are not allowed in key names, must be escaped to
    //    :: in values.
    //
    // 0.3 only reads and writes entries of type (4).

    // Leading colon implies 4.0 style line:
    if (line[0] == ':')
    {
        dprf("Corrupted xlog-line: %s", line.c_str());
        return false;
    }

    raw_line = line;
    return parse_scoreline(line);
}

string scorefile_entry::raw_string() const
{
    if (!raw_line.empty())
        return raw_line;

    set_score_fields();

    if (!fields)
        return "";

    return fields->xlog_line() + "\n";
}

bool scorefile_entry::parse_scoreline(const string &line)
{
    fields.reset(new xlog_fields(line));
    init_with_fields();

    return true;
}

static const char* _short_branch_name(int branch)
{
    if (branch >= 0 && branch < NUM_BRANCHES)
        return branches[branch].abbrevname;
    return "";
}

enum old_job_type
{
    OLD_JOB_THIEF        = -1,
    OLD_JOB_DEATH_KNIGHT = -2,
    OLD_JOB_PALADIN      = -3,
    OLD_JOB_REAVER       = -4,
    OLD_JOB_STALKER      = -5,
    OLD_JOB_JESTER       = -6,
    OLD_JOB_PRIEST       = -7,
    OLD_JOB_HEALER       = -8,
    OLD_JOB_SKALD        = -9,
    NUM_OLD_JOBS = -OLD_JOB_SKALD
};

static const char* _job_name(int job)
{
    switch (job)
    {
    case OLD_JOB_THIEF:
        return "Thief";
    case OLD_JOB_DEATH_KNIGHT:
        return "Death Knight";
    case OLD_JOB_PALADIN:
        return "Paladin";
    case OLD_JOB_REAVER:
        return "Reaver";
    case OLD_JOB_STALKER:
        return "Stalker";
    case OLD_JOB_JESTER:
        return "Jester";
    case OLD_JOB_PRIEST:
        return "Priest";
    case OLD_JOB_HEALER:
        return "Healer";
    case OLD_JOB_SKALD:
        return "Skald";
    }

    return get_job_name(static_cast<job_type>(job));
}

static const char* _job_abbrev(int job)
{
    switch (job)
    {
    case OLD_JOB_THIEF:
        return "Th";
    case OLD_JOB_DEATH_KNIGHT:
        return "DK";
    case OLD_JOB_PALADIN:
        return "Pa";
    case OLD_JOB_REAVER:
        return "Re";
    case OLD_JOB_STALKER:
        return "St";
    case OLD_JOB_JESTER:
        return "Jr";
    case OLD_JOB_PRIEST:
        return "Pr";
    case OLD_JOB_HEALER:
        return "He";
    case OLD_JOB_SKALD:
        return "Sk";
    }

    return get_job_abbrev(static_cast<job_type>(job));
}

static int _job_by_name(const string& name)
{
    int job = get_job_by_name(name.c_str());

    if (job != JOB_UNKNOWN)
        return job;

    for (job = -1; job >= -NUM_OLD_JOBS; job--)
        if (name == _job_name(job))
            return job;

    return JOB_UNKNOWN;
}

enum old_species_type
{
    OLD_SP_ELF = -1,
    OLD_SP_HILL_DWARF = -2,
    OLD_SP_OGRE_MAGE = -3,
    OLD_SP_GREY_ELF = -4,
    OLD_SP_GNOME = -5,
    OLD_SP_MOUNTAIN_DWARF = -6,
    OLD_SP_SLUDGE_ELF = -7,
    OLD_SP_DJINNI = -8,
    OLD_SP_LAVA_ORC = -9,
    NUM_OLD_SPECIES = -OLD_SP_LAVA_ORC
};

static string _species_name(int race)
{
    switch (race)
    {
    case OLD_SP_ELF: return "Elf";
    case OLD_SP_HILL_DWARF: return "Hill Dwarf";
    case OLD_SP_OGRE_MAGE: return "Ogre-Mage";
    case OLD_SP_GREY_ELF: return "Grey Elf";
    case OLD_SP_GNOME: return "Gnome";
    case OLD_SP_MOUNTAIN_DWARF: return "Mountain Dwarf";
    case OLD_SP_SLUDGE_ELF: return "Sludge Elf";
    case OLD_SP_DJINNI: return "Djinni";
    case OLD_SP_LAVA_ORC: return "Lava Orc";
    }

    // Guard against an ASSERT in get_species_def; it's really bad if the game
    // crashes at this point while trying to clean up a dead/quit player.
    // (This doesn't seem to even impact what is shown in the score list?)
    if (race < 0 || race >= NUM_SPECIES)
        return "Unknown (buggy) species!";

    return species::name(static_cast<species_type>(race));
}

static const char* _species_abbrev(int race)
{
    switch (race)
    {
    case OLD_SP_ELF: return "El";
    case OLD_SP_HILL_DWARF: return "HD";
    case OLD_SP_OGRE_MAGE: return "OM";
    case OLD_SP_GREY_ELF: return "GE";
    case OLD_SP_GNOME: return "Gn";
    case OLD_SP_MOUNTAIN_DWARF: return "MD";
    case OLD_SP_SLUDGE_ELF: return "SE";
    case OLD_SP_DJINNI: return "Dj";
    case OLD_SP_LAVA_ORC: return "LO";
    }

    // see note in _species_name: don't ASSERT in get_species_def.
    if (race < 0 || race >= NUM_SPECIES)
        return "??";

    return species::get_abbrev(static_cast<species_type>(race));
}

static int _species_by_name(const string& name)
{
    int race = species::from_str(name);

    if (race != SP_UNKNOWN)
        return race;

    for (race = -1; race >= -NUM_OLD_SPECIES; race--)
        if (name == _species_name(race))
            return race;

    return SP_UNKNOWN;
}

void scorefile_entry::init_with_fields()
{
    version = fields->str_field("v");
    save_rcs_version = fields->str_field("vsavrv");
    save_tag_version = fields->str_field("vsav");

    tiles   = fields->int_field("tiles");
    points  = fields->int_field("sc");

    name    = fields->str_field("name");
    race    = _species_by_name(fields->str_field("race"));
    job     = _job_by_name(fields->str_field("cls"));
    lvl     = fields->int_field("xl");
    race_class_name = fields->str_field("char");

    best_skill     = str_to_skill_safe(fields->str_field("sk"));
    best_skill_lvl = fields->int_field("sklev");
    title          = fields->str_field("title");

    death_type        = _str_to_kill_method(fields->str_field("ktyp"));
    death_source_name = fields->str_field("killer");
    const vector<string> kflags =
        split_string(" ", fields->str_field("killer_flags"));
    death_source_flags = set<string>(kflags.begin(), kflags.end());

    auxkilldata       = fields->str_field("kaux");
    indirectkiller    = fields->str_field("ikiller");
    if (indirectkiller.empty())
        indirectkiller = death_source_name;
    killerpath        = fields->str_field("kpath");
    last_banisher     = fields->str_field("banisher");

    branch     = branch_by_abbrevname(fields->str_field("br"), BRANCH_DUNGEON);
    dlvl       = fields->int_field("lvl");
    absdepth   = fields->int_field("absdepth");

    map        = fields->str_field("map");
    mapdesc    = fields->str_field("mapdesc");
    killer_map = fields->str_field("killermap");

    final_hp         = fields->int_field("hp");
    final_max_hp     = fields->int_field("mhp");
    final_max_max_hp = fields->int_field("mmhp");
    final_mp          = fields->int_field("mp");
    final_max_mp      = fields->int_field("mmp");
    final_base_max_mp = fields->int_field("bmmp");

    damage        = fields->int_field("dam");
    source_damage = fields->int_field("sdam");
    turn_damage   = fields->int_field("tdam");

    str   = fields->int_field("str");
    intel = fields->int_field("int");
    dex   = fields->int_field("dex");

    ac    = fields->int_field("ac");
    ev    = fields->int_field("ev");
    sh    = fields->int_field("sh");

    god          = str_to_god(fields->str_field("god"));
    piety        = fields->int_field("piety");
    penance      = fields->int_field("pen");
    wiz_mode     = fields->int_field("wiz");
    explore_mode = fields->int_field("explore");

    birth_time = _parse_time(fields->str_field("start"));
    death_time = _parse_time(fields->str_field("end"));
    real_time  = fields->int_field("dur");
    num_turns  = fields->int_field("turn");
    num_aut    = fields->int_field("aut");

    num_diff_runes = fields->int_field("urune");
    num_runes      = fields->int_field("nrune");
    gems_found     = fields->int_field("fgem");
    gems_intact    = fields->int_field("igem");

    kills = fields->int_field("kills");
    maxed_skills = fields->str_field("maxskills");
    fifteen_skills = fields->str_field("fifteenskills");
    status_effects = fields->str_field("status");

    gold       = fields->int_field("gold");
    gold_found = fields->int_field("goldfound");
    gold_spent = fields->int_field("goldspent");

    zigs       = fields->int_field("zigscompleted");
    zigmax     = fields->int_field("zigdeepest");

    scrolls_used = fields->int_field("scrollsused");
    potions_used = fields->int_field("potionsused");

    seed = fields->str_field("seed");

    fixup_char_name();
}

void scorefile_entry::set_base_xlog_fields() const
{
    if (!fields)
        fields.reset(new xlog_fields);

    string score_version = SCORE_VERSION;
    if (crawl_state.game_is_sprint())
    {
        /* XXX: hmmm, something better here? */
        score_version += "-sprint.1";
    }
    fields->add_field("v", "%s", Version::Short);
    fields->add_field("vlong", "%s", Version::Long);
    fields->add_field("lv", "%s", score_version.c_str());
    if (!save_rcs_version.empty())
        fields->add_field("vsavrv", "%s", save_rcs_version.c_str());
    if (!save_tag_version.empty())
        fields->add_field("vsav", "%s", save_tag_version.c_str());

#ifdef EXPERIMENTAL_BRANCH
    fields->add_field("explbr", EXPERIMENTAL_BRANCH);
#endif
    if (tiles)
        fields->add_field("tiles", "%d", tiles);
    fields->add_field("name", "%s", name.c_str());
    fields->add_field("race", "%s", _species_name(race).c_str());
    fields->add_field("cls",  "%s", _job_name(job));
    fields->add_field("char", "%s", race_class_name.c_str());
    fields->add_field("xl",    "%d", lvl);
    fields->add_field("sk",    "%s", skill_name(best_skill));
    fields->add_field("sklev", "%d", best_skill_lvl);
    fields->add_field("title", "%s", title.c_str());

    fields->add_field("place", "%s",
                      level_id(branch, dlvl).describe().c_str());

    if (!last_banisher.empty())
        fields->add_field("banisher", "%s", last_banisher.c_str());

    // Note: "br", "lvl" (and former "ltyp") are redundant with "place"
    // but may still be used by DGL logs.
    fields->add_field("br",   "%s", _short_branch_name(branch));
    fields->add_field("lvl",  "%d", dlvl);
    fields->add_field("absdepth", "%d", absdepth);

    fields->add_field("hp",   "%d", final_hp);
    fields->add_field("mhp",  "%d", final_max_hp);
    fields->add_field("mmhp", "%d", final_max_max_hp);
    fields->add_field("mp",   "%d", final_mp);
    fields->add_field("mmp",  "%d", final_max_mp);
    fields->add_field("bmmp", "%d", final_base_max_mp);
    fields->add_field("str", "%d", str);
    fields->add_field("int", "%d", intel);
    fields->add_field("dex", "%d", dex);
    fields->add_field("ac", "%d", ac);
    fields->add_field("ev", "%d", ev);
    fields->add_field("sh", "%d", sh);

    fields->add_field("god", "%s", god == GOD_NO_GOD ? "" :
                      god_name(god).c_str());

    if (wiz_mode)
        fields->add_field("wiz", "%d", wiz_mode);
    if (explore_mode)
        fields->add_field("explore", "%d", explore_mode);

    fields->add_field("start", "%s", make_date_string(birth_time).c_str());
    fields->add_field("dur",   "%d", (int)real_time);
    fields->add_field("turn",  "%d", num_turns);
    fields->add_field("aut",   "%d", num_aut);

    if (num_diff_runes)
        fields->add_field("urune", "%d", num_diff_runes);

    if (num_runes)
        fields->add_field("nrune", "%d", num_runes);

    if (gems_found)
        fields->add_field("fgem", "%d", gems_found);
    if (gems_intact)
        fields->add_field("igem", "%d", gems_intact);

    fields->add_field("kills", "%d", kills);
    if (!maxed_skills.empty())
        fields->add_field("maxskills", "%s", maxed_skills.c_str());
    if (!fifteen_skills.empty())
        fields->add_field("fifteenskills", "%s", fifteen_skills.c_str());
    if (!status_effects.empty())
        fields->add_field("status", "%s", status_effects.c_str());

    fields->add_field("gold", "%d", gold);
    fields->add_field("goldfound", "%d", gold_found);
    fields->add_field("goldspent", "%d", gold_spent);
    if (zigs)
        fields->add_field("zigscompleted", "%d", zigs);
    if (zigmax)
        fields->add_field("zigdeepest", "%d", zigmax);
    fields->add_field("scrollsused", "%d", scrolls_used);
    fields->add_field("potionsused", "%d", potions_used);
}

void scorefile_entry::set_score_fields() const
{
    fields.reset(new xlog_fields);

    if (!fields)
        return;

    set_base_xlog_fields();

    fields->add_field("sc", "%d", points);
    fields->add_field("ktyp", "%s", _kill_method_name(kill_method_type(death_type)));

    fields->add_field("killer", "%s", death_source_desc().c_str());
    if (!death_source_flags.empty())
    {
        const string kflags = comma_separated_line(
            death_source_flags.begin(),
            death_source_flags.end(),
            " ", " ");
        fields->add_field("killer_flags", "%s", kflags.c_str());
    }
    fields->add_field("dam", "%d", damage);
    fields->add_field("sdam", "%d", source_damage);
    fields->add_field("tdam", "%d", turn_damage);

    fields->add_field("kaux", "%s", auxkilldata.c_str());

    if (indirectkiller != death_source_desc())
        fields->add_field("ikiller", "%s", indirectkiller.c_str());

    if (!killerpath.empty())
        fields->add_field("kpath", "%s", killerpath.c_str());

    if (piety > 0)
        fields->add_field("piety", "%d", piety);
    if (penance > 0)
        fields->add_field("pen", "%d", penance);

    fields->add_field("end", "%s", make_date_string(death_time).c_str());

    if (!map.empty())
    {
        fields->add_field("map", "%s", map.c_str());
        if (!mapdesc.empty())
            fields->add_field("mapdesc", "%s", mapdesc.c_str());
    }

    if (!killer_map.empty())
        fields->add_field("killermap", "%s", killer_map.c_str());

    fields->add_field("seed", "%s", seed.c_str());

#ifdef DGL_EXTENDED_LOGFILES
    const string short_msg = short_kill_message();
    fields->add_field("tmsg", "%s", short_msg.c_str());
    const string long_msg = long_kill_message();
    if (long_msg != short_msg)
        fields->add_field("vmsg", "%s", long_msg.c_str());
#endif
}

string scorefile_entry::make_oneline(const string &ml) const
{
    vector<string> lines = split_string("\n", ml);
    for (string &s : lines)
    {
        if (starts_with(s, "..."))
        {
            s = s.substr(3);
            trim_string(s);
        }
    }
    return comma_separated_line(lines.begin(), lines.end(), " ", " ");
}

string scorefile_entry::long_kill_message() const
{
    string msg = death_description(DDV_LOGVERBOSE);
    msg = make_oneline(msg);
    msg[0] = tolower_safe(msg[0]);
    trim_string(msg);
    return msg;
}

string scorefile_entry::short_kill_message() const
{
    string msg = death_description(DDV_ONELINE);
    msg = make_oneline(msg);
    msg[0] = tolower_safe(msg[0]);
    trim_string(msg);
    return msg;
}

/**
 * Remove from a string everything up to and including a given infix.
 *
 * @param[in,out] str   The string to modify.
 * @param[in]     infix The infix to remove.
 * @post If \c infix occurred as a substring of <tt>str</tt>, \c str is updated
 *       by removing all characters up to and including the last character
 *       of the the first occurrence. Otherwise, \c str is unchanged.
 * @return \c true if \c str was modified, \c false otherwise.
 */
static bool _strip_to(string &str, const char *infix)
{
    // Don't treat stripping the empty string as a change.
    if (*infix == '\0')
        return false;

    size_t pos = str.find(infix);
    if (pos != string::npos)
    {
        str.erase(0, pos + strlen(infix));
        return true;
    }
    return false;
}

void scorefile_entry::init_death_cause(int dam, mid_t dsrc,
                                       int dtype, const char *aux,
                                       const char *dsrc_name)
{
    death_source = dsrc;
    death_type   = dtype;
    damage       = dam;

    // Try searching for both a living monster and a dead-but-cached monster
    const monster *source_monster = monster_by_mid(death_source)
                                     ? monster_by_mid(death_source)
                                     : cached_monster_copy_by_mid(death_source);
    if (source_monster)
        killer_map = source_monster->originating_map();

    // Set the default aux data value...
    // If aux is passed in (ie for a trap), we'll default to that.
    if (aux == nullptr)
        auxkilldata.clear();
    else
        auxkilldata = aux;

    // for death by monster
    if ((death_type == KILLED_BY_MONSTER
            || death_type == KILLED_BY_HEADBUTT
            || death_type == KILLED_BY_BEAM
            || death_type == KILLED_BY_FREEZING
            || death_type == KILLED_BY_DISINT
            || death_type == KILLED_BY_ACID
            || death_type == KILLED_BY_DRAINING
            || death_type == KILLED_BY_BURNING
            || death_type == KILLED_BY_DEATH_EXPLOSION
            || death_type == KILLED_BY_CLOUD
            || death_type == KILLED_BY_ROTTING
            || death_type == KILLED_BY_REFLECTION
            || death_type == KILLED_BY_ROLLING
            || death_type == KILLED_BY_SPINES
            || death_type == KILLED_BY_WATER
            || death_type == KILLED_BY_BEING_THROWN
            || death_type == KILLED_BY_COLLISION
            || death_type == KILLED_BY_CONSTRICTION)
        && source_monster)
    {
        const monster* mons = source_monster;
        ASSERT(mons);

        // Previously the weapon was only used for dancing weapons,
        // but now we pass it in as a string through the scorefile
        // entry to be appended in hiscores_format_single in long or
        // medium scorefile formats.
        if (death_type == KILLED_BY_MONSTER
            && mons->inv[MSLOT_WEAPON] != NON_ITEM)
        {
            // [ds] The highscore entry may be constructed while the player
            // is alive (for notes), so make sure we don't reveal info we
            // shouldn't.
            if (you.hp <= 0)
                identify_item(env.item[mons->inv[MSLOT_WEAPON]]);

            // Setting this is redundant for dancing weapons, however
            // we do care about the above identification. -- bwr
            if (!mons_class_is_animated_weapon(mons->type))
            {
                auxkilldata = env.item[mons->inv[MSLOT_WEAPON]].name(DESC_A);
                if (mons->has_ench(ENCH_ARMED))
                    auxkilldata += " (from an undying armoury)";
            }
        }

        const bool death = (you.hp <= 0 || death_type == KILLED_BY_DRAINING);

        const description_level_type desc =
            death_type == KILLED_BY_DEATH_EXPLOSION ? DESC_PLAIN : DESC_A;

        death_source_name = mons->name(desc, death);

        if (death || you.can_see(*mons))
            death_source_name = mons->full_name(desc);

        if (mons_is_player_shadow(*mons))
            death_source_name = "their own shadow"; // heh

        if (mons->mid == MID_YOU_FAULTLESS)
            death_source_name = "themself";

        if (mons->mid == MID_ANON_FRIEND)
            death_source_name = "an ally";

        if (mons->has_ench(ENCH_SHAPESHIFTER))
            death_source_name += " (shapeshifter)";
        else if (mons->has_ench(ENCH_GLOWING_SHAPESHIFTER))
            death_source_name += " (glowing shapeshifter)";

        if (mons->type == MONS_PANDEMONIUM_LORD)
            death_source_name += " the pandemonium lord";

        if (mons->has_ench(ENCH_PHANTOM_MIRROR))
            death_source_name += " (illusionary)";

        if (mons_is_unique(mons->type))
            death_source_flags.insert("unique");

        if (mons->props.exists(BLAME_KEY))
        {
            const CrawlVector& blame = mons->props[BLAME_KEY].get_vector();

            indirectkiller = blame[blame.size() - 1].get_string();
            _strip_to(indirectkiller, " by ");
            _strip_to(indirectkiller, "ed to "); // "attached to" and similar
            _strip_to(indirectkiller, "ed from "); // "spawned from" and similar

            // XXX: We want to keep a more appropriate death message, but still
            //      link the deaths to Cassandra for tracking purposes.
            if (indirectkiller == "an inevitable fate")
                indirectkiller = "Cassandra";

            vector<string> path_parts;
            for (const auto &bl : blame)
                path_parts.push_back(_xlog_escape(bl.get_string()));
            killerpath = join_strings(path_parts.begin(), path_parts.end(), ":");
        }
        else
        {
            indirectkiller = death_source_name;
            killerpath = "";
        }
    }
    else if (death_type == KILLED_BY_DISINT
             || death_type == KILLED_BY_CLOUD)
    {
        death_source_name = dsrc_name ? dsrc_name :
                            dsrc == MHITYOU ? "you" :
                            "";
        indirectkiller = killerpath = "";
    }
    else
    {
        if (dsrc_name)
            death_source_name = dsrc_name;
        else
            death_source_name.clear();
        indirectkiller = killerpath = "";
    }

    if (death_type == KILLED_BY_WEAKNESS
        || death_type == KILLED_BY_STUPIDITY
        || death_type == KILLED_BY_CLUMSINESS)
    {
        if (auxkilldata.empty())
            auxkilldata = "unknown source";
    }

    if (death_type == KILLED_BY_POISON)
    {
        death_source_name = you.props[POISONER_KEY].get_string();
        auxkilldata = you.props[POISON_AUX_KEY].get_string();
    }

    if (death_type == KILLED_BY_BURNING)
    {
        death_source_name = you.props[STICKY_FLAMER_KEY].get_string();
        auxkilldata = you.props[STICKY_FLAME_AUX_KEY].get_string();
    }

    // Deaths without a living source must (at least in modern Crawl) be from Flooding.
    if (death_type == KILLED_BY_WATER && !source_monster)
        death_source_name = you.props[WATER_HOLDER_NAME_KEY].get_string();

    if (death_type == KILLED_BY_BLINKING)
    {
        death_source_name = you.props[BLINKITIS_SOURCE_KEY].get_string();
        auxkilldata = you.props[BLINKITIS_AUX_KEY].get_string();
    }
}

void scorefile_entry::reset()
{
    // simple init
    raw_line.clear();
    version.clear();
    save_rcs_version.clear();
    save_tag_version.clear();
    tiles                = 0;
    points               = -1;
    name.clear();
    race                 = SP_UNKNOWN;
    job                  = JOB_UNKNOWN;
    lvl                  = 0;
    race_class_name.clear();
    best_skill           = SK_NONE;
    best_skill_lvl       = 0;
    title.clear();
    death_type           = KILLED_BY_SOMETHING;
    death_source         = MID_NOBODY;
    death_source_name.clear();
    auxkilldata.clear();
    indirectkiller.clear();
    killerpath.clear();
    last_banisher.clear();
    dlvl                 = 0;
    absdepth             = 1;
    branch               = BRANCH_DUNGEON;
    map.clear();
    mapdesc.clear();
    final_hp             = -1;
    final_max_hp         = -1;
    final_max_max_hp     = -1;
    final_mp             = -1;
    final_max_mp         = -1;
    final_base_max_mp    = -1;
    str                  = -1;
    intel                = -1;
    dex                  = -1;
    ac                   = -1;
    ev                   = -1;
    sh                   = -1;
    damage               = -1;
    source_damage        = -1;
    turn_damage          = -1;
    god                  = GOD_NO_GOD;
    piety                = -1;
    penance              = -1;
    wiz_mode             = 0;
    explore_mode         = 0;
    birth_time           = 0;
    death_time           = 0;
    real_time            = -1;
    num_turns            = -1;
    num_aut              = -1;
    num_diff_runes       = 0;
    num_runes            = 0;
    gems_found           = 0;
    gems_intact          = 0;
    kills                = 0;
    maxed_skills.clear();
    fifteen_skills.clear();
    status_effects.clear();
    gold                 = 0;
    gold_found           = 0;
    gold_spent           = 0;
    zigs                 = 0;
    zigmax               = 0;
    scrolls_used         = 0;
    potions_used         = 0;
    seed.clear();
}

static int _award_modified_experience()
{
    int xp = you.experience;
    int result = 0;

    if (xp <= 250000)
        return xp * 7 / 10;

    result += 250000 * 7 / 10;
    xp -= 250000;

    if (xp <= 750000)
    {
        result += xp * 4 / 10;
        return result;
    }

    result += 750000 * 4 / 10;
    xp -= 750000;

    if (xp <= 2000000)
    {
        result += xp * 2 / 10;
        return result;
    }

    result += 2000000 * 2 / 10;
    xp -= 2000000;

    result += xp / 10;

    return result;
}

void scorefile_entry::init(time_t dt)
{
    // Score file entry version:
    //
    // 4.0      - original versioned entry
    // 4.1      - added real_time and num_turn fields
    // 4.2      - stats and god info

    version = Version::Short;
#ifdef USE_TILE_LOCAL
    tiles   = 1;
#elif defined (USE_TILE_WEB)
    tiles   = ::tiles.is_controlled_from_web();
#else
    tiles   = 0;
#endif
    name    = you.your_name;

    save_rcs_version = crawl_state.save_rcs_version;
    if (crawl_state.minor_version > 0)
    {
        save_tag_version = make_stringf("%d.%d", TAG_MAJOR_VERSION,
                                        crawl_state.minor_version);
    }

    /*
     *  old scoring system (0.1-0.3):
     *
     *    Gold
     *    + 0.7 * Experience
     *    + (distinct Runes +2)^2 * 1000, winners with distinct runes >= 3 only
     *    + value of Inventory, for winners only
     *
     *
     *  0.4 scoring system, as suggested by Lemuel:
     *
     *    Gold
     *    + 0.7 * Experience up to 250,000
     *    + 0.4 * Experience between 250,000 and 1,000,000
     *    + 0.2 * Experience between 1,000,000 and 3,000,000
     *    + 0.1 * Experience above 3,000,000
     *    + (distinct Runes +2)^2 * 1000, winners with distinct runes >= 3 only
     *    + value of Inventory, for winners only
     *    + (250,000 * d. runes) * (25,000/(turns/d. runes)), for winners only
     *
     *  current scoring system (mostly the same as above):
     *
     *    Experience terms as above
     *    + runes * (runes + 12) * 1000        (for everyone)
     *    + (250000 + 2 * (runes + 2) * 1000)  (winners only)
     *    + 250000 * 25000 * runes^2 / turns   (winners only)
     */

    // do points first.
    points = 0;
    bool base_score = true;

    dlua.callfn("dgn.persist.calc_score", "b>db",
                death_type == KILLED_BY_WINNING, &points, &base_score);

    num_runes      = runes_in_pack();
    num_diff_runes = num_runes;
    gems_found     = ::gems_found();
    gems_intact    = gems_found - gems_lost();

    // If calc_score didn't exist, or returned true as its second value,
    // use the default formula.
    if (base_score)
    {
        // sprint games could overflow a 32 bit value
        uint64_t pt = points + _award_modified_experience();

        // There's no point in rewarding lugging artefacts. Thus, no points
        // for the value of the inventory. -- 1KB
        if (death_type == KILLED_BY_WINNING)
        {
            pt += 250000; // the Orb
            pt += num_runes * 2000 + 4000;
            pt += ((uint64_t)250000) * 25000 * num_runes * num_runes
                / (1+you.num_turns);
        }
        // Add a little score for gems so that newer players who find one
        // feel rewarded, but not so much that it impacts high score play.
        pt += gems_found * 10000 * ((death_type == KILLED_BY_WINNING) ? 10 : 1);
        pt += num_runes * 10000;
        pt += num_runes * (num_runes + 2) * 1000;

        points = pt;
    }
    else
        ASSERT(crawl_state.game_is_sprint());
        // only sprint should use custom scores

    race = you.species;
    job  = you.char_class;

    race_class_name.clear();
    fixup_char_name();

    lvl            = you.experience_level;
    best_skill     = ::best_skill(SK_FIRST_SKILL, SK_LAST_SKILL);
    best_skill_lvl = you.skills[ best_skill ];
    title          = player_title(false);

    // Note all skills at level 27, and also all skills at level >= 15.
    for (skill_type sk = SK_FIRST_SKILL; sk < NUM_SKILLS; ++sk)
    {
        if (you.skills[sk] == 27)
        {
            if (!maxed_skills.empty())
                maxed_skills += ",";
            maxed_skills += skill_name(sk);
        }
        if (you.skills[sk] >= 15)
        {
            if (!fifteen_skills.empty())
                fifteen_skills += ",";
            fifteen_skills += skill_name(sk);
        }
    }

    status_info inf;
    for (unsigned i = 0; i <= STATUS_LAST_STATUS; ++i)
    {
        if (fill_status_info(i, inf) && !inf.short_text.empty())
        {
            if (!status_effects.empty())
                status_effects += ",";
            status_effects += inf.short_text;
        }
    }

    kills            = you.kills.total_kills();

    final_hp         = you.hp;
    final_max_hp     = you.hp_max;
    final_max_max_hp = get_real_hp(true, false);

    final_mp          = you.magic_points;
    final_max_mp      = you.max_magic_points;
    final_base_max_mp = get_real_mp(false);

    source_damage    = you.source_damage;
    turn_damage      = you.turn_damage;

    // Use possibly negative stat values.
    str   = you.stat(STAT_STR, false);
    intel = you.stat(STAT_INT, false);
    dex   = you.stat(STAT_DEX, false);

    ac    = you.armour_class_scaled(1);
    ev    = you.evasion_scaled(1);
    sh    = player_displayed_shield_class();

    god = you.religion;
    if (!you_worship(GOD_NO_GOD))
    {
        piety   = you.raw_piety;
        penance = you.penance[you.religion];
    }

    branch     = you.where_are_you;  // no adjustments necessary.
    dlvl       = you.depth;

    absdepth   = env.absdepth0 + 1;  // 1-based absolute depth.

    last_banisher = you.banished_by;

    if (const vault_placement *vp = dgn_vault_at(you.pos()))
    {
        map     = vp->map_name_at(you.pos());
        mapdesc = vp->map.description;
    }

    birth_time = you.birth_time;     // start time of game
    death_time = (dt != 0 ? dt : time(nullptr)); // end time of game

    handle_real_time(chrono::system_clock::from_time_t(death_time));
    real_time = you.real_time();

    num_turns = you.num_turns;
    num_aut = you.elapsed_time;

    gold       = you.gold;
    gold_found = you.attribute[ATTR_GOLD_FOUND];
    gold_spent = you.attribute[ATTR_PURCHASES];

    zigs       = you.zigs_completed;
    zigmax     = you.zig_max;

    scrolls_used = 0;
    pair<caction_type, int> p(CACT_USE, caction_compound(OBJ_SCROLLS));

    const int maxlev = min<int>(you.max_level, 27);
    if (you.action_count.count(p))
        for (int i = 0; i < maxlev; i++)
            scrolls_used += you.action_count[p][i];

    potions_used = 0;
    p = make_pair(CACT_USE, caction_compound(OBJ_POTIONS));
    if (you.action_count.count(p))
        for (int i = 0; i < maxlev; i++)
            potions_used += you.action_count[p][i];

    wiz_mode = (you.wizard || you.suppress_wizard ? 1 : 0);
    explore_mode = (you.explore ? 1 : 0);
    seed = make_stringf("%" PRIu64, crawl_state.seed);
}

string scorefile_entry::hiscore_line(death_desc_verbosity verbosity) const
{
    string line = character_description(verbosity);
    line += death_description(verbosity);
    line += death_place(verbosity);
    line += game_time(verbosity);

    return line;
}

string scorefile_entry::game_time(death_desc_verbosity verbosity) const
{
    string line;

    if (verbosity == DDV_VERBOSE)
    {
        line += make_stringf("The game lasted %s (%d turns).",
                             make_time_string(real_time).c_str(), num_turns);

        line += _hiscore_newline_string();
    }

    return line;
}

const char *scorefile_entry::damage_verb() const
{
    // GDL: here's an example of using final_hp. Verbiage could be better.
    // bwr: changed "blasted" since this is for melee
    return (final_hp > -6)  ? "Slain"   :
           (final_hp > -14) ? "Mangled" :
           (final_hp > -22) ? "Demolished"
                            : "Annihilated";
}

string scorefile_entry::death_source_desc() const
{
    return death_source_name;
}

string scorefile_entry::damage_string(bool terse) const
{
    return make_stringf("(%d%s)", damage,
                        terse? "" : " damage");
}

string scorefile_entry::strip_article_a(const string &s) const
{
    if (starts_with(s, "a "))
        return s.substr(2);
    else if (starts_with(s, "an "))
        return s.substr(3);
    return s;
}

string scorefile_entry::terse_missile_name() const
{
    const string pre_post[][2] =
    {
        { "Shot with ", " by " },
        { "Hit by ",     " thrown by " }
    };
    const string &aux = auxkilldata;
    string missile;

    for (const string (&affixes)[2] : pre_post)
    {
        if (!starts_with(aux, affixes[0]))
            continue;

        string::size_type end = aux.rfind(affixes[1]);
        if (end == string::npos)
            continue;

        int istart = affixes[0].length();
        int nchars = end - istart;
        missile = aux.substr(istart, nchars);

        // Was this prefixed by "a" or "an"?
        // (This should only ever not be the case with Robin and Ijyb.)
        missile = strip_article_a(missile);
    }
    return missile;
}

string scorefile_entry::terse_missile_cause() const
{
    const string &aux = auxkilldata;

    string monster_prefix = " by ";
    // We're looking for Shot with a%s %s by %s/ Hit by a%s %s thrown by %s
    string::size_type by = aux.rfind(monster_prefix);
    if (by == string::npos)
        return "???";

    string mcause = aux.substr(by + monster_prefix.length());
    mcause = strip_article_a(mcause);

    string missile = terse_missile_name();

    if (!missile.empty())
        mcause += "/" + missile;

    return mcause;
}

string scorefile_entry::terse_beam_cause() const
{
    string cause = auxkilldata;
    if (starts_with(cause, "by ") || starts_with(cause, "By "))
        cause = cause.substr(3);
    return cause;
}

string scorefile_entry::terse_wild_magic() const
{
    return terse_beam_cause();
}

void scorefile_entry::fixup_char_name()
{
    if (race_class_name.empty())
    {
        race_class_name = make_stringf("%s%s",
                                       _species_abbrev(race),
                                       _job_abbrev(job));
    }
}

string scorefile_entry::single_cdesc() const
{
    string scname;
    scname = chop_string(name, 10);

    return make_stringf("%8d %s %s-%02d%s", points, scname.c_str(),
                        race_class_name.c_str(), lvl,
                        (wiz_mode == 1) ? "W" : (explore_mode == 1) ? "E" : "");
}

static string _append_sentence_delimiter(const string &sentence,
                                         const string &delimiter)
{
    if (sentence.empty())
        return sentence;

    const char lastch = sentence[sentence.length() - 1];
    if (lastch == '!' || lastch == '.')
        return sentence;

    return sentence + delimiter;
}

string scorefile_entry::runes_gems_desc(bool semiverbose) const
{
    if (num_runes < 1 && gems_found < 1)
        return "";

    string desc = "";

    bool extra = (death_type == KILLED_BY_WINNING);
    if (num_runes >= 1)
    {
        desc += _hiscore_newline_string();
        desc += make_stringf("... %s %d rune%s",
                             extra ? "and" : "with",
                             num_runes,
                             (num_runes > 1) ? "s" : "");
        extra = true;
    }
    if (gems_found >= 1)
    {
        desc += _hiscore_newline_string();
        desc += make_stringf("... %s %d gem%s",
                             extra ? "and" : "with",
                             gems_found,
                             (gems_found > 1) ? "s" : "");
        // semiverbose is true here only when making the vmsg logfile field,
        // so we always display all gem info when it is true
        if (Options.more_gem_info || semiverbose)
        {
            if (gems_intact == 1 && gems_found == 1)
                desc += " (intact)";
            else if (gems_intact == 2 && gems_found == 2)
                desc += " (both intact)";
            else if (gems_intact == gems_found)
                desc += " (all intact)";
            else
                desc += make_stringf(" (%d intact)", gems_intact);
        }
    }
    if (!semiverbose
        && death_time > 0
        && !_hiscore_same_day(birth_time, death_time))
    {
        desc += " on ";
        desc += _hiscore_date_string(death_time);
    }

    desc = _append_sentence_delimiter(desc, "!");
    return desc + _hiscore_newline_string();
}

string
scorefile_entry::character_description(death_desc_verbosity verbosity) const
{
    bool single  = verbosity == DDV_TERSE || verbosity == DDV_ONELINE;

    if (single)
        return single_cdesc();

    bool verbose = verbosity == DDV_VERBOSE;

    string desc;
    // Please excuse the following bit of mess in the name of flavour ;)
    if (verbose)
    {
        desc = make_stringf("%8d %s the %s (level %d",
                  points, name.c_str(), title.c_str(), lvl);
    }
    else
    {
        desc = make_stringf("%8d %s the %s %s (level %d",
                  points, name.c_str(),
                  _species_name(race).c_str(),
                  _job_name(job), lvl);
    }

    if (final_max_max_hp > 0)  // as the other two may be negative
    {
        desc += make_stringf(", %d/%d", final_hp, final_max_hp);

        if (final_max_hp < final_max_max_hp)
            desc += make_stringf(" (%d)", final_max_max_hp);

        desc += " HPs";
    }

    desc += wiz_mode ? ") *WIZ*" : explore_mode ? ") *EXPLORE*" : ")";
    desc += _hiscore_newline_string();

    if (verbose)
    {
        string srace = _species_name(race);
        desc += make_stringf("Began as a%s %s %s",
                 is_vowel(srace[0]) ? "n" : "",
                 srace.c_str(),
                 _job_name(job));

        ASSERT(birth_time);
        desc += " on ";
        desc += _hiscore_date_string(birth_time);
        // TODO: show seed here?

        desc = _append_sentence_delimiter(desc, ".");
        desc += _hiscore_newline_string();

        if (god != GOD_NO_GOD
            // XX is this check really needed?
            && !species::mutation_level(static_cast<species_type>(race), MUT_FORLORN))
        {
            if (god == GOD_XOM)
            {
                desc += make_stringf("Was a %sPlaything of Xom.",
                                    (lvl >= 20) ? "Favourite " : "");

                desc += _hiscore_newline_string();
            }
            else
            {
                // Not exactly the same as the religion screen, but
                // good enough to fill this slot for now.
                desc += make_stringf("Was %s of %s%s",
                             (piety >= piety_breakpoint(5)) ? "the Champion" :
                             (piety >= piety_breakpoint(4)) ? "a High Priest" :
                             (piety >= piety_breakpoint(3)) ? "an Elder" :
                             (piety >= piety_breakpoint(2)) ? "a Priest" :
                             (piety >= piety_breakpoint(1)) ? "a Believer" :
                             (piety >= piety_breakpoint(0)) ? "a Follower"
                                                            : "an Initiate",
                          god_name(god).c_str(),
                             (penance > 0) ? " (penitent)." : ".");

                desc += _hiscore_newline_string();
            }
        }
    }

    return desc;
}

static bool _very_boring_death_type(int death_type)
{
    switch (death_type)
    {
    case KILLED_BY_QUITTING:
    case KILLED_BY_WIZMODE:
    case KILLED_BY_EXPLORING:
        return true;
    default:
        return false;
    }
}

string scorefile_entry::death_place(death_desc_verbosity verbosity) const
{
    bool verbose = (verbosity == DDV_VERBOSE);
    string place;

    if (death_type == KILLED_BY_LEAVING || death_type == KILLED_BY_WINNING)
        return "";

    if (verbosity == DDV_ONELINE || verbosity == DDV_TERSE)
        return " (" + level_id(branch, dlvl).describe() + ")";

    if (verbose && !_very_boring_death_type(death_type))
        place += "...";

    // where did we die?
    place += " " + prep_branch_level_name(level_id(branch, dlvl));

    if (!mapdesc.empty())
        place += make_stringf(" (%s)", mapdesc.c_str());

    if (verbose && death_time
        && !_hiscore_same_day(birth_time, death_time))
    {
        place += " on ";
        place += _hiscore_date_string(death_time);
    }

    place = _append_sentence_delimiter(place, ".");
    place += _hiscore_newline_string();

    return place;
}

/**
 * Describes the cause of the player's death.
 *
 * @param verbosity     The verbosity of the description.
 * @return              A description of the cause of death.
 */
string scorefile_entry::death_description(death_desc_verbosity verbosity) const
{
    bool needs_beam_cause_line = false;
    bool needs_called_by_monster_line = false;
    bool needs_damage = false;

    const bool terse   = (verbosity == DDV_TERSE);
    const bool semiverbose = (verbosity == DDV_LOGVERBOSE);
    const bool verbose = (verbosity == DDV_VERBOSE || semiverbose);
    const bool oneline = (verbosity == DDV_ONELINE);

    string desc;

    if (oneline)
        desc = " ";

    switch (death_type)
    {
    case KILLED_BY_MONSTER:
        if (terse)
            desc += death_source_desc();
        else if (oneline)
            desc += "slain by " + death_source_desc();
        else
        {
            desc += damage_verb();
            desc += " by ";
            desc += death_source_desc();
        }

        // put the damage on the weapon line if there is one
        if (auxkilldata.empty())
            needs_damage = true;
        break;

    case KILLED_BY_HEADBUTT:
        if (terse)
            desc += apostrophise(death_source_desc()) + " headbutt";
        else
            desc += "Headbutted by " + death_source_desc();
        needs_damage = true;
        break;

    case KILLED_BY_ROLLING:
        if (terse)
            desc += "squashed by " + death_source_desc();
        else
            desc += "Rolled over by " + death_source_desc();
        needs_damage = true;
        break;

    case KILLED_BY_SPINES:
        if (terse)
            desc += apostrophise(death_source_desc()) + " spines";
        else
            desc += "Impaled on " + apostrophise(death_source_desc()) + " spines" ;
        needs_damage = true;
        break;

    case KILLED_BY_POISON:
        if (death_source_name.empty() || terse)
        {
            if (!terse)
                desc += "Succumbed to poison";
            else if (!death_source_name.empty())
                desc += "poisoned by " + death_source_name;
            else
                desc += "poison";
            if (!auxkilldata.empty())
                desc += " (" + auxkilldata + ")";
        }
        else if (auxkilldata.empty()
                 && death_source_name.find("poison") != string::npos)
        {
            desc += "Succumbed to " + death_source_name;
        }
        else
        {
            desc += "Succumbed to " + ((death_source_name == "you")
                      ? "their own" : apostrophise(death_source_name)) + " "
                    + (auxkilldata.empty()? "poison" : auxkilldata);
        }
        break;

    case KILLED_BY_CLOUD:
        ASSERT(!auxkilldata.empty()); // there are no nameless clouds
        if (terse)
            if (death_source_name.empty())
                desc += "cloud of " + auxkilldata;
            else
                desc += "cloud of " +auxkilldata + " [" +
                        death_source_name == "you" ? "self" : death_source_name
                        + "]";
        else
        {
            desc += make_stringf("Engulfed by %s%s %s",
                death_source_name.empty() ? "a" :
                  death_source_name == "you" ? "their own" :
                  apostrophise(death_source_name).c_str(),
                death_source_name.empty() ? " cloud of" : "",
                auxkilldata.c_str());
        }
        needs_damage = true;
        break;

    case KILLED_BY_BEAM:
        if (oneline || semiverbose)
        {
            // keeping this short to leave room for the deep elf spellcasters:
            desc += make_stringf("%s by ",
                      _range_type_verb(auxkilldata.c_str()));
            desc += (death_source_name == "you") ? "themself"
                                                 : death_source_desc();

            if (semiverbose)
            {
                string beam = terse_missile_name();
                if (beam.empty())
                    beam = terse_beam_cause();
                trim_string(beam);
                if (!beam.empty())
                    desc += make_stringf(" (%s)", beam.c_str());
            }
        }
        else if (isupper(auxkilldata[0]))  // already made (ie shot arrows)
        {
            // If terse we have to parse the information from the string.
            // Darn it to heck.
            desc += terse? terse_missile_cause() : auxkilldata;
            needs_damage = true;
        }
        else if (verbose && starts_with(auxkilldata, "by "))
        {
            // "by" is used for priest attacks where the effect is indirect
            // in verbose format we have another line for the monster
            if (death_source_name == "you")
            {
                needs_damage = true;
                desc += make_stringf("Killed by their own %s",
                         auxkilldata.substr(3).c_str());
            }
            else
            {
                needs_called_by_monster_line = true;
                desc += make_stringf("Killed %s",
                          auxkilldata.c_str());
            }
        }
        else
        {
            // Note: This is also used for the "by" cases in non-verbose
            //       mode since listing the monster is more imporatant.
            if (semiverbose)
                desc += "Killed by ";
            else if (!terse)
                desc += "Killed from afar by ";

            if (death_source_name == "you")
                desc += "themself";
            else
                desc += death_source_desc();

            if (!auxkilldata.empty())
                needs_beam_cause_line = true;

            needs_damage = true;
        }
        break;

    case KILLED_BY_LAVA:
        if (terse)
            desc += "lava";
        else
        {
            if (starts_with(species::skin_name(
                        static_cast<species_type>(race)), "bandage"))
            {
                desc += "Turned to ash by lava";
            }
            else
                desc += "Took a swim in molten lava";
        }
        break;

    case KILLED_BY_WATER:
        if (species::is_undead(static_cast<species_type>(race)))
        {
            if (terse)
                desc = "fell apart";
            else if (starts_with(species::skin_name(
                        static_cast<species_type>(race)), "bandage"))
            {
                desc = "Soaked and fell apart";
            }
            else
                desc = "Sank and fell apart";
        }
        else
        {
            if (!death_source_name.empty())
            {
                desc += terse? "drowned by " : "Drowned by ";
                desc += death_source_name;
                needs_damage = true;
            }
            else
                desc += terse? "drowned" : "Drowned";
        }
        break;

    case KILLED_BY_STUPIDITY:
        if (terse)
            desc += "stupidity";
        else if (race >= 0 && // not a removed race
                 (species::is_undead(static_cast<species_type>(race))
                  || species::is_nonliving(static_cast<species_type>(race))))
        {
            desc += "Forgot to exist";
        }
        else
            desc += "Forgot to breathe";
        break;

    case KILLED_BY_WEAKNESS:
        desc += terse? "collapsed" : "Collapsed under their own weight";
        break;

    case KILLED_BY_CLUMSINESS:
        desc += terse? "clumsiness" : "Slipped on a banana peel";
        break;

    case KILLED_BY_TRAP:
        if (terse)
            desc += auxkilldata.c_str();
        else
        {
            desc += make_stringf("Killed by triggering %s",
                                 auxkilldata.c_str());
        }
        needs_damage = true;
        break;

    case KILLED_BY_LEAVING:
        if (terse)
            desc += "left";
        else
        {
            if (num_runes > 0 || gems_found > 0)
                desc += "Got out of the dungeon";
            else if (species::is_undead(static_cast<species_type>(race)))
                desc += "Safely got out of the dungeon";
            else
                desc += "Got out of the dungeon alive";
        }
        break;

    case KILLED_BY_WINNING:
        desc += terse? "escaped" : "Escaped with the Orb";
        if (num_runes < 1 && gems_found < 1)
            desc += "!";
        break;

    case KILLED_BY_QUITTING:
        desc += terse? "quit" : "Quit the game";
        break;

    case KILLED_BY_WIZMODE:
        desc += terse? "wizmode" : "Entered wizard mode";
        break;

    case KILLED_BY_EXPLORING:
        desc += terse? "exploremode" : "Entered explore mode";
        break;

    case KILLED_BY_DRAINING:
        if (terse)
            desc += "drained";
        else
        {
            desc += "Drained of all life";
            if (!death_source_desc().empty())
            {
                desc += " by " + death_source_desc();

                if (!auxkilldata.empty())
                    needs_beam_cause_line = true;
            }
            else if (!auxkilldata.empty())
                desc += " by " + auxkilldata;
        }
        break;

    case KILLED_BY_STARVATION:
        desc += terse? "starvation" : "Starved to death";
        break;

    case KILLED_BY_FREEZING:    // Freeze, Fridge spells
        desc += terse? "frozen" : "Frozen to death";
        if (!terse && !death_source_desc().empty())
            desc += " by " + death_source_desc();
        needs_damage = true;
        break;

    case KILLED_BY_BURNING:     // sticky flame
        if (terse)
            desc += "burnt";
        else if (!death_source_desc().empty())
        {
            desc += "Incinerated by " + death_source_desc();

            if (!auxkilldata.empty())
                needs_beam_cause_line = true;
        }
        else
            desc += "Burnt to a crisp";

        needs_damage = true;
        break;

    case KILLED_BY_BLINKING:     // disjunction darts
        if (terse)
            desc += "disjoined";
        else if (!death_source_desc().empty())
        {
            desc += "Disjoined by " + death_source_desc();

            if (!auxkilldata.empty())
                needs_beam_cause_line = true;
        }

        needs_damage = true;
        break;

    case KILLED_BY_WILD_MAGIC:
        if (auxkilldata.empty())
            desc += terse? "wild magic" : "Killed by wild magic";
        else
        {
            if (terse)
                desc += terse_wild_magic();
            else
            {
                // A lot of sources for this case... some have "by" already.
                desc += make_stringf("Killed %s%s",
                          (auxkilldata.find("by ") != 0) ? "by " : "",
                          auxkilldata.c_str());
            }
        }

        needs_damage = true;
        break;

    case KILLED_BY_XOM:
        if (terse)
            desc += "xom";
        else
            desc += auxkilldata.empty() ? "Killed for Xom's enjoyment"
                                        : "Killed by " + auxkilldata;
        needs_damage = true;
        break;

    case KILLED_BY_ROTTING:
        desc += terse? "rotting" : "Rotted away";
        if (!auxkilldata.empty())
            desc += " (" + auxkilldata + ")";
        if (!death_source_desc().empty())
            desc += " (" + death_source_desc() + ")";
        break;

    case KILLED_BY_TARGETING:
        if (terse)
            desc += "shot self";
        else
        {
            desc += "Killed themself with ";
            if (auxkilldata.empty())
                desc += "bad targeting";
            else
                desc += "a badly aimed " + auxkilldata;
        }
        needs_damage = true;
        break;

    case KILLED_BY_REFLECTION:
        needs_damage = true;
        if (terse)
            desc += "reflected bolt";
        else
        {
            desc += "Killed by a reflected ";
            if (auxkilldata.empty())
                desc += "bolt";
            else
                desc += auxkilldata;

            if (!death_source_name.empty() && !oneline && !semiverbose)
            {
                desc += "\n";
                desc += "             ";
                desc += "... reflected by ";
                desc += death_source_name;
                needs_damage = false;
            }
        }
        break;

    case KILLED_BY_BOUNCE:
        if (terse)
            desc += "bounced beam";
        else
        {
            desc += "Killed themself with a bounced ";
            if (auxkilldata.empty())
                desc += "beam";
            else
                desc += auxkilldata;
        }
        needs_damage = true;
        break;

    case KILLED_BY_SELF_AIMED:
        if (terse)
            desc += "suicidal targeting";
        else
        {
            desc += "Shot themself with ";
            if (auxkilldata.empty())
                desc += "a beam";
            else
                desc += article_a(auxkilldata, true);
        }
        needs_damage = true;
        break;

    case KILLED_BY_DEATH_EXPLOSION:
        if (terse)
        {
            if (death_source_name.empty())
                desc += "death explosion";
            else
                desc += death_source_name;
        }
        else
        {
            desc += "Killed by an exploding ";
            if (death_source_name.empty())
                desc += "monster";
            else
                desc += death_source_name;
        }
        needs_damage = true;
        break;

    case KILLED_BY_TSO_SMITING:
        desc += terse? "smitten by Shining One" : "Smitten by the Shining One";
        needs_damage = true;
        break;

    case KILLED_BY_BEOGH_SMITING:
        desc += terse? "smitten by Beogh" : "Smitten by Beogh";
        needs_damage = true;
        break;

    case KILLED_BY_PETRIFICATION:
        desc += terse? "petrified" : "Turned to stone";
        break;

    case KILLED_BY_DEATH_CURSE:
    {
        if (!auxkilldata.empty())
        {
            desc += (terse ? "" : "Slain by ") + auxkilldata;
            if (!terse && !death_source_name.empty())
                desc += "\n             ... wielded by " + death_source_name;
        }
        else if (terse)
        {

            desc += death_source_name.empty() ? "a death curse"
                    : death_source_name + " death curse";
        }
        else
        {
            desc += "Slain by " + apostrophise(death_source_desc())
                                + "'s death curse";
        }
        needs_damage = true;
        break;
    }

    case KILLED_BY_SOMETHING:
        if (!auxkilldata.empty())
            desc += (terse ? "" : "Killed by ") + auxkilldata;
        else
            desc += terse? "died" : "Died";
        needs_damage = true;
        break;

    case KILLED_BY_FALLING_DOWN_STAIRS:
        desc += terse? "fell downstairs" : "Fell down a flight of stairs";
        needs_damage = true;
        break;

    case KILLED_BY_FALLING_THROUGH_GATE:
        desc += terse? "fell through a gate" : "Fell down through a gate";
        needs_damage = true;
        break;

    case KILLED_BY_ACID:
        if (terse)
            desc += "acid";
        else if (!death_source_desc().empty())
        {
            desc += "Splashed by "
                    + apostrophise(death_source_desc())
                    + " acid";
        }
        else
            desc += "Splashed with acid";
        needs_damage = true;
        break;

    case KILLED_BY_CURARE:
        desc += terse? "asphyx" : "Asphyxiated";
        break;

    case KILLED_BY_DIVINE_WRATH:
        if (terse)
            desc += "divine wrath";
        else
        {
            desc += "Killed by ";
            if (auxkilldata.empty())
                desc += "divine wrath";
            else
            {
                // Lugonu's touch or "the <retribution> of <deity>";
                // otherwise it's a beam
                if (!isupper(auxkilldata[0])
                    && !starts_with(auxkilldata, "the "))
                {
                    desc += is_vowel(auxkilldata[0]) ? "an " : "a ";
                }

                desc += auxkilldata;
            }
        }
        needs_damage = true;
        if (!death_source_name.empty())
            needs_called_by_monster_line = true;
        break;

    case KILLED_BY_DISINT:
        if (terse)
            desc += "disintegration";
        else
        {
            if (death_source_name == "you")
                desc += "Blew themself up";
            else
                desc += "Blown up by " + death_source_desc();
            needs_beam_cause_line = true;
        }

        needs_damage = true;
        break;

    case KILLED_BY_MIRROR_DAMAGE:
        desc += terse ? "mirror damage" : "Killed by mirror damage";
        needs_damage = true;
        break;

    case KILLED_BY_FRAILTY:
        desc += terse ? "frailty" : "Became unviable by " + auxkilldata;
        break;

    case KILLED_BY_BARBS:
        desc += terse ? "barbs" : "Succumbed to barbed spike wounds";
        break;

    case KILLED_BY_BEING_THROWN:
        if (terse)
            desc += apostrophise(death_source_desc()) + " throw";
        else
            desc += "Thrown by " + death_source_desc();
        needs_damage = true;
        break;

    case KILLED_BY_COLLISION:
        if (terse)
            desc += auxkilldata + " collision";
        else
        {
            desc += "Collided with " + auxkilldata;
            needs_called_by_monster_line = true;
        }
        needs_damage = true;
        break;

    case KILLED_BY_ZOT:
        desc += terse ? "Zot" : "Tarried too long and was consumed by Zot";
        break;

    case KILLED_BY_CONSTRICTION:
        if (terse)
            desc += "constriction";
        else
            desc += "Constricted to death by " + death_source_desc();
        needs_damage = true;
        break;

    default:
        desc += terse? "program bug" : "Nibbled to death by software bugs";
        break;
    }                           // end switch

    switch (death_type)
    {
    case KILLED_BY_STUPIDITY:
    case KILLED_BY_WEAKNESS:
    case KILLED_BY_CLUMSINESS:
        if (terse || oneline)
        {
            desc += " (";
            desc += auxkilldata;
            desc += ")";
        }
        else
        {
            desc += "\n";
            desc += "             ";
            desc += "... caused by ";
            desc += auxkilldata;
        }
        break;

    default:
        break;
    }

    if (oneline && desc.length() > 2)
        desc[1] = tolower_safe(desc[1]);

    // TODO: Eventually, get rid of "..." for cases where the text fits.
    if (terse)
    {
        if (death_type == KILLED_BY_MONSTER && !auxkilldata.empty())
        {
            desc += "/";
            desc += strip_article_a(auxkilldata);
            needs_damage = true;
        }
        else if (needs_beam_cause_line)
            desc += "/" + terse_beam_cause();
        else if (needs_called_by_monster_line)
            desc += death_source_name;

        if (!killerpath.empty())
            desc += "[" + indirectkiller + "]";

        if (needs_damage && damage > 0)
            desc += " " + damage_string(true);
    }
    else if (verbose)
    {
        bool done_damage = false;  // paranoia

        if (!semiverbose && needs_damage && damage > 0)
        {
            desc += " " + damage_string();
            needs_damage = false;
            done_damage = true;
        }

        if (death_type == KILLED_BY_LEAVING
            || death_type == KILLED_BY_WINNING)
        {
            if (num_runes < 1 && gems_found < 1)
                desc = _append_sentence_delimiter(desc, ".");
            else
                desc += runes_gems_desc(semiverbose);
        }
        else if (!_very_boring_death_type(death_type))
        {
            desc += _hiscore_newline_string();

            if (death_type == KILLED_BY_MONSTER && !auxkilldata.empty())
            {
                if (!semiverbose)
                {
                    desc += make_stringf("... wielding %s",
                             auxkilldata.c_str());
                    needs_damage = true;
                    desc += _hiscore_newline_string();
                }
                else
                    desc += make_stringf(" (%s)", auxkilldata.c_str());
            }
            else if (needs_beam_cause_line)
            {
                if (!semiverbose)
                {
                    desc += auxkilldata == "damnation" ? "... with " :
                         auxkilldata == "creeping frost" ? "... by " :
                            (is_vowel(auxkilldata[0])) ? "... with an "
                                                       : "... with a ";
                    desc += auxkilldata;
                    desc += _hiscore_newline_string();
                    needs_damage = true;
                }
                else if (death_type == KILLED_BY_DRAINING
                         || death_type == KILLED_BY_BURNING)
                {
                    desc += make_stringf(" (%s)", auxkilldata.c_str());
                }
            }
            else if (needs_called_by_monster_line)
            {
                desc += make_stringf("... %s by %s",
                         death_type == KILLED_BY_COLLISION ? "after being knocked back" :
                         auxkilldata == "by angry trees"   ? "awakened" :
                         auxkilldata == "by Freeze"        ? "generated"
                                                           : "invoked",
                         death_source_name.c_str());
                desc += _hiscore_newline_string();
                needs_damage = true;
            }

            if (!killerpath.empty())
            {
                vector<string> summoners = _xlog_split_fields(killerpath);

                for (const auto &sumname : summoners)
                {
                    if (!semiverbose)
                    {
                        desc += "... " + sumname;
                        desc += _hiscore_newline_string();
                    }
                    else
                        desc += " (" + sumname;
                }

                if (semiverbose)
                    desc += string(summoners.size(), ')');
            }

            if (!semiverbose)
            {
                if (needs_damage && !done_damage && damage > 0)
                    desc += " " + damage_string();

                if (needs_damage && !done_damage)
                    desc += _hiscore_newline_string();

                if (you.duration[DUR_PARALYSIS])
                {
                    desc += "... while paralysed";
                    if (you.props.exists(DISABLED_BY_KEY))
                        desc += " by " + you.props[DISABLED_BY_KEY].get_string();

                    desc += _hiscore_newline_string();
                }
                else if (you.duration[DUR_PETRIFIED])
                {
                    desc += "... while petrified";
                    if (you.props.exists(DISABLED_BY_KEY))
                        desc += " by " + you.props[DISABLED_BY_KEY].get_string();

                    desc += _hiscore_newline_string();
                }
                else if (you.duration[DUR_SLEEP])
                {
                    desc += "... while put to sleep";
                    if (you.props.exists(DISABLED_BY_KEY))
                        desc += " by " + you.props[DISABLED_BY_KEY].get_string();
                }
                else if (you.duration[DUR_VEXED])
                {
                    desc += "... while vexed";
                    if (you.props.exists(DISABLED_BY_KEY))
                        desc += " by " + you.props[DISABLED_BY_KEY].get_string();
                }
            }
        }
    }

    if (!oneline)
    {
        if (death_type == KILLED_BY_LEAVING
            || death_type == KILLED_BY_WINNING)
        {
            // TODO: strcat "after reaching level %d"; for LEAVING
            if (verbosity == DDV_NORMAL)
            {
                const bool cool = num_runes > 0 || gems_found > 0;
                desc = _append_sentence_delimiter(desc,
                                                  cool ? "!" : ".");
            }
            desc += _hiscore_newline_string();
        }
    }

    if (death_type == KILLED_BY_DEATH_EXPLOSION && !terse && !auxkilldata.empty())
    {
        desc += "... ";
        desc += auxkilldata;
        desc += "\n";
        desc += "             ";
    }

    if (terse)
    {
        trim_string(desc);
        desc = strip_article_a(desc);
    }

    return desc;
}

//////////////////////////////////////////////////////////////////////////////
// xlog_fields

xlog_fields::xlog_fields() : fields(), fieldmap()
{
}

xlog_fields::xlog_fields(const string &line) : fields(), fieldmap()
{
    init(line);
}

// xlogfile escape: s/:/::/g
static string _xlog_escape(const string &s)
{
    return replace_all(s, ":", "::");
}

// xlogfile unescape: s/::/:/g
static string _xlog_unescape(const string &s)
{
    return replace_all(s, "::", ":");
}

static string::size_type _xlog_next_separator(const string &s,
                                              string::size_type start)
{
    string::size_type p = s.find(':', start);
    if (p != string::npos && p < s.length() - 1 && s[p + 1] == ':')
        return _xlog_next_separator(s, p + 2);

    return p;
}

static vector<string> _xlog_split_fields(const string &s)
{
    string::size_type start = 0, end = 0;
    vector<string> fs;

    for (; (end = _xlog_next_separator(s, start)) != string::npos;
          start = end + 1)
    {
        fs.push_back(s.substr(start, end - start));
    }

    if (start < s.length())
        fs.push_back(s.substr(start));

    return fs;
}

void xlog_fields::init(const string &line)
{
    for (const string &field : _xlog_split_fields(line))
    {
        string::size_type st = field.find('=');
        if (st == string::npos)
            continue;

        fields.emplace_back(field.substr(0, st),
                            _xlog_unescape(field.substr(st + 1)));
    }

    map_fields();
}

void xlog_fields::add_field(const string &key, const char *format, ...)
{
    va_list args;
    va_start(args, format);
    string buf = vmake_stringf(format, args);
    va_end(args);

    fields.emplace_back(key, buf);
    fieldmap[key] = buf;
}

string xlog_fields::str_field(const string &s) const
{
    return lookup(fieldmap, s, "");
}

int xlog_fields::int_field(const string &s) const
{
    string field = str_field(s);
    return atoi(field.c_str());
}

void xlog_fields::map_fields() const
{
    fieldmap.clear();
    for (const pair<string, string> &f : fields)
        fieldmap[f.first] = f.second;
}

string xlog_fields::xlog_line() const
{
    string line;
    for (const pair<string, string> &f : fields)
    {
        // Don't write empty fields.
        if (f.second.empty())
            continue;

        if (!line.empty())
            line += ":";

        line += f.first;
        line += "=";
        line += _xlog_escape(f.second);
    }

    return line;
}

#ifdef USE_TILE_WEB
JsonWrapper xlog_fields::xlog_json() const
{
    JsonWrapper j(json_mkobject());

    for (const pair<string, string> &f : fields)
    {
        // Don't write empty fields.
        if (f.second.empty())
            continue;

        json_append_member(j.node, f.first.c_str(),
                                    json_mkstring(f.second.c_str()));
    }
    return j;
}
#endif

///////////////////////////////////////////////////////////////////////////////
// Milestones

#define LAST_MILESTONE_PROP "last_milestone"
#define LAST_MILESTONE_TYPE_PROP "last_milestone_type"
#define LAST_MILESTONE_TURN_PROP "last_milestone_turn"

#if defined(USE_TILE_WEB)
void sync_last_milestone()
{
    if (!you.props.exists(LAST_MILESTONE_TURN_PROP))
        return; // no milestones yet
    // send webtiles an extremely minimal milestone bundle -- just enough for
    // the sake of the lobby on game start.
    xlog_fields xl;
    xl.add_field("type", "%s",
                    you.props[LAST_MILESTONE_TYPE_PROP].get_string().c_str());
    xl.add_field("milestone", "%s",
                    you.props[LAST_MILESTONE_PROP].get_string().c_str());
    xl.add_field("turn", "%d", you.props[LAST_MILESTONE_TURN_PROP].get_int());
    xl.add_field("status", "milestone_only");

    tiles.send_milestone(xl);
}
#endif

/**
 * @brief Record the player reaching a milestone, if ::DGL_MILESTONES is defined.
 * @callergraph
 */
void mark_milestone(const string &type, const string &milestone,
                    const string &origin_level, time_t milestone_time)
{
#if defined(USE_TILE_WEB) || defined(DGL_MILESTONES)
    // save previous milestone info in you.props, so that we know whether
    // this is new info
    string &lastmilestone = you.props[LAST_MILESTONE_PROP].get_string();
    string &lasttype = you.props[LAST_MILESTONE_TYPE_PROP].get_string();
    if (!you.props.exists(LAST_MILESTONE_TURN_PROP))
        you.props[LAST_MILESTONE_TURN_PROP] = -1;
    int &lastturn = you.props[LAST_MILESTONE_TURN_PROP].get_int();

    if (crawl_state.game_is_arena()
        || !crawl_state.need_save
        // Suppress duplicate milestones on the same turn.
        || (lastturn == you.num_turns
            && lasttype == type
            && lastmilestone == milestone))
    {
        return;
    }

    lasttype      = type;
    lastmilestone = milestone;
    lastturn      = you.num_turns;

    const scorefile_entry se(0, MID_NOBODY, KILL_NON_ACTOR, nullptr);
    se.set_base_xlog_fields();
    xlog_fields xl = se.get_fields();
    if (!origin_level.empty())
    {
        xl.add_field("oplace", "%s",
                     ((origin_level == "parent") ?
                      current_level_parent().describe() :
                      origin_level).c_str());
    }
    xl.add_field("time", "%s",
                 make_date_string(
                     milestone_time ? milestone_time
                                    : se.get_death_time()).c_str());
    xl.add_field("type", "%s", type.c_str());
    xl.add_field("milestone", "%s", milestone.c_str());
#ifdef USE_TILE_WEB
    if (!crawl_state.game_crashed)
        tiles.send_milestone(xl);
#endif
#ifdef DGL_MILESTONES
#ifndef SCORE_WIZARD_CHARACTERS
    // Don't mark normal milestones in wizmode or explore mode, when writing
    // a dgl milestone file. (We do still send these milestones to the webtiles
    // process.)
    if (type != "crash" && (you.wizard || you.suppress_wizard || you.explore))
        return;
#endif

    const string xlog_line = xl.xlog_line();
    const string milestone_file = _log_file_name(true);

    if (FILE *fp = lk_open("a", milestone_file))
    {
        fprintf(fp, "%s\n", xlog_line.c_str());
        lk_close(fp);
    }
#endif
#else
    UNUSED(type, milestone, origin_level, milestone_time);
#endif // USE_TILE_WEB
}

#if defined(USE_TILE_WEB) || defined(DGL_WHEREIS)
static xlog_fields _xlog_status(const char *status)
{
    const scorefile_entry se(0, MID_NOBODY, KILL_NON_ACTOR, nullptr);
    se.set_base_xlog_fields();
    xlog_fields xl = se.get_fields();
    xl.add_field("time", "%s", make_date_string(time(nullptr)).c_str());
    xl.add_field("status", "%s", status ? status : "");
    return xl.xlog_line();
}
#endif

#ifdef USE_TILE_WEB
void update_whereis_chargen(string name)
{
    // whereis info to indicate that character generation is happening
    // scorefile_entry is very crashy pre-game, so just do some basic
    // init here. We set the name manually because it is not yet set in `you`.
    // TODO: make scorefile_entry more robust
    if (!name.size())
        return;
    xlog_fields xl;
    xl.add_field("name", "%s", name.c_str());
    xl.add_field("lvl", "%d", 0);
    xl.add_field("time", "%s", make_date_string(time(nullptr)).c_str());
    xl.add_field("status", "chargen");
    xl.add_field("milestone", "started generating a character.");
    tiles.send_milestone(xl);
}
#endif

void update_whereis(const char *status)
{
#if defined(USE_TILE_WEB) || defined(DGL_WHEREIS)
    xlog_fields xl = _xlog_status(status);
#ifdef USE_TILE_WEB
    tiles.send_milestone(xl);
#endif
#ifdef DGL_WHEREIS
    whereis_record(xl);
#endif
#else
    UNUSED(status);
#endif
}
