diff --git a/migrations/20250722002114_unrated_levels.down.sql b/migrations/20250722002114_unrated_levels.down.sql index 861c1f47..b8410117 100644 --- a/migrations/20250722002114_unrated_levels.down.sql +++ b/migrations/20250722002114_unrated_levels.down.sql @@ -1,3 +1,38 @@ +CREATE OR REPLACE FUNCTION record_score(progress FLOAT, demon FLOAT, list_size FLOAT, requirement FLOAT) RETURNS FLOAT AS +$record_score$ +SELECT CASE + WHEN progress = 100 THEN + CASE + WHEN demon BETWEEN 56 AND 150 THEN + 1.039035131 * ((185.7 * EXP((-0.02715 * demon))) + 14.84) + WHEN demon BETWEEN 36 AND 55 THEN + 1.0371139743 * ((212.61 * POWER(1.036, 1 - demon)) + 25.071) + WHEN demon BETWEEN 21 AND 35 THEN + (((250 - 83.389) * POWER(1.0099685, 2 - demon) - 31.152)) * 1.0371139743 + WHEN demon BETWEEN 4 AND 20 THEN + ((326.1 * EXP((-0.0871 * demon))) + 51.09) * 1.037117142 + WHEN demon BETWEEN 1 AND 3 THEN + (-18.2899079915 * demon) + 368.2899079915 + END + WHEN progress < requirement THEN + 0.0 + ELSE + CASE + WHEN demon BETWEEN 56 AND 150 THEN + 1.039035131 * ((185.7 * EXP((-0.02715 * demon))) + 14.84) * (EXP(LN(5) * (progress - requirement) / (100 - requirement))) / 10 + WHEN demon BETWEEN 36 AND 55 THEN + (1.0371139743 * ((212.61 * POWER(1.036, 1 - demon)) + 25.071)) * (EXP(LN(5) * (progress - requirement) / (100 - requirement))) / 10 + WHEN demon BETWEEN 21 AND 35 THEN + (((250 - 83.389) * POWER(1.0099685, 2 - demon) - 31.152)) * 1.0371139743 * (EXP(LN(5) * (progress - requirement) / (100 - requirement))) / 10 + WHEN demon BETWEEN 4 AND 20 THEN + (((326.1 * EXP((-0.0871 * demon))) + 51.09) * 1.037117142) * (EXP(LN(5) * (progress - requirement) / (100 - requirement))) / 10 + WHEN demon BETWEEN 1 AND 3 THEN + ((-18.2899079915 * demon) + 368.2899079915) * (EXP(LN(5) * (progress - requirement) / (100 - requirement))) / 10 + END + END; +$record_score$ + LANGUAGE SQL IMMUTABLE; + DROP VIEW score_giving; CREATE VIEW score_giving AS @@ -186,6 +221,9 @@ SELECT recompute_player_scores(); SELECT recompute_nation_scores(); SELECT recompute_subdivision_scores(); +ALTER TABLE players ALTER COLUMN score SET NOT NULL; +ALTER TABLE players ALTER COLUMN score SET DEFAULT 0.0; + ALTER TABLE demons DROP COLUMN rated_position; DROP FUNCTION recompute_rated_positions(); diff --git a/migrations/20250722002114_unrated_levels.up.sql b/migrations/20250722002114_unrated_levels.up.sql index d4a3a75a..8e5a50e6 100644 --- a/migrations/20250722002114_unrated_levels.up.sql +++ b/migrations/20250722002114_unrated_levels.up.sql @@ -1,5 +1,7 @@ ALTER TABLE demons ADD COLUMN rated BOOLEAN NOT NULL DEFAULT TRUE; -ALTER TABLE players ADD COLUMN ratedplus_score DOUBLE PRECISION NOT NULL DEFAULT 0.0; +ALTER TABLE players ALTER COLUMN score DROP NOT NULL; +ALTER TABLE players ALTER COLUMN score DROP DEFAULT; +ALTER TABLE players ADD COLUMN ratedplus_score DOUBLE PRECISION DEFAULT NULL; ALTER TABLE nationalities ADD COLUMN ratedplus_score DOUBLE PRECISION NOT NULL DEFAULT 0.0; ALTER TABLE subdivisions ADD COLUMN ratedplus_score DOUBLE PRECISION NOT NULL DEFAULT 0.0; @@ -27,6 +29,47 @@ $$ LANGUAGE plpgsql; SELECT recompute_rated_positions(); -- stats viewer stuff +CREATE OR REPLACE FUNCTION record_score(progress FLOAT, demon FLOAT, list_size FLOAT, requirement FLOAT) RETURNS FLOAT AS +$record_score$ +SELECT CASE + WHEN demon > 150 THEN 0.0 + WHEN demon > 75 AND progress < 100 THEN 0.0 + WHEN progress = 100 THEN + CASE + WHEN demon BETWEEN 56 AND 150 THEN + 1.039035131 * ((185.7 * EXP((-0.02715 * demon))) + 14.84) + WHEN demon BETWEEN 36 AND 55 THEN + 1.0371139743 * ((212.61 * POWER(1.036, 1 - demon)) + 25.071) + WHEN demon BETWEEN 21 AND 35 THEN + (((250 - 83.389) * POWER(1.0099685, 2 - demon) - 31.152)) * 1.0371139743 + WHEN demon BETWEEN 4 AND 20 THEN + ((326.1 * EXP((-0.0871 * demon))) + 51.09) * 1.037117142 + WHEN demon BETWEEN 1 AND 3 THEN + (-18.2899079915 * demon) + 368.2899079915 + ELSE + 0.0 + END + WHEN progress < requirement THEN + 0.0 + ELSE + CASE + WHEN demon BETWEEN 56 AND 150 THEN + 1.039035131 * ((185.7 * EXP((-0.02715 * demon))) + 14.84) * (EXP(LN(5) * (progress - requirement) / (100 - requirement))) / 10 + WHEN demon BETWEEN 36 AND 55 THEN + (1.0371139743 * ((212.61 * POWER(1.036, 1 - demon)) + 25.071)) * (EXP(LN(5) * (progress - requirement) / (100 - requirement))) / 10 + WHEN demon BETWEEN 21 AND 35 THEN + (((250 - 83.389) * POWER(1.0099685, 2 - demon) - 31.152)) * 1.0371139743 * (EXP(LN(5) * (progress - requirement) / (100 - requirement))) / 10 + WHEN demon BETWEEN 4 AND 20 THEN + (((326.1 * EXP((-0.0871 * demon))) + 51.09) * 1.037117142) * (EXP(LN(5) * (progress - requirement) / (100 - requirement))) / 10 + WHEN demon BETWEEN 1 AND 3 THEN + ((-18.2899079915 * demon) + 368.2899079915) * (EXP(LN(5) * (progress - requirement) / (100 - requirement))) / 10 + ELSE + 0.0 + END + END; +$record_score$ + LANGUAGE SQL IMMUTABLE; + DROP VIEW score_giving; CREATE VIEW score_giving AS @@ -34,7 +77,7 @@ CREATE VIEW score_giving AS FROM records INNER JOIN demons ON demons.id = records.demon - WHERE records.status_ = 'APPROVED' AND (demons.position <= 75 OR records.progress = 100) + WHERE records.status_ = 'APPROVED' UNION @@ -42,7 +85,7 @@ CREATE VIEW score_giving AS FROM records INNER JOIN demons ON demons.id = records.demon - WHERE demons.rated = TRUE AND records.status_ = 'APPROVED' AND (demons.rated_position <= 75 OR records.progress = 100) + WHERE demons.rated = TRUE AND records.status_ = 'APPROVED' UNION @@ -65,7 +108,7 @@ $$ LANGUAGE SQL; CREATE OR REPLACE FUNCTION recompute_player_scores() RETURNS void AS $$ UPDATE players - SET score = COALESCE(q.score, 0), ratedplus_score = COALESCE(q.ratedplus_score, 0) + SET score = q.score, ratedplus_score = q.ratedplus_score FROM players p LEFT JOIN ( SELECT player, @@ -159,18 +202,18 @@ DROP MATERIALIZED VIEW player_ranks; CREATE MATERIALIZED VIEW player_ranks AS SELECT - CASE WHEN score != 0 THEN RANK() OVER (ORDER BY score DESC) END AS rank, - CASE WHEN ratedplus_score != 0 THEN RANK() OVER (ORDER BY ratedplus_score DESC) END AS ratedplus_rank, + CASE WHEN score > 0 THEN RANK() OVER (ORDER BY score DESC NULLS LAST) END AS rank, + CASE WHEN ratedplus_score > 0 THEN RANK() OVER (ORDER BY ratedplus_score DESC NULLS LAST) END AS ratedplus_rank, id FROM players -WHERE (ratedplus_score != 0 OR score != 0) AND NOT banned; +WHERE (ratedplus_score IS NOT NULL OR score IS NOT NULL) AND NOT banned; CREATE UNIQUE INDEX player_ranks_id_idx ON player_ranks(id); CREATE VIEW ranked_players AS SELECT - ROW_NUMBER() OVER(ORDER BY rank, id) AS index, - ROW_NUMBER() OVER (ORDER BY ratedplus_rank, id) AS ratedplus_index, + ROW_NUMBER() OVER(ORDER BY players.score DESC NULLS LAST, id) AS index, + ROW_NUMBER() OVER (ORDER BY players.ratedplus_score DESC NULLS LAST, id) AS ratedplus_index, rank, ratedplus_rank, id, name, players.score, players.ratedplus_score, diff --git a/migrations/20251114014715_record_dates.down.sql b/migrations/20251114014715_record_dates.down.sql new file mode 100644 index 00000000..80304090 --- /dev/null +++ b/migrations/20251114014715_record_dates.down.sql @@ -0,0 +1,39 @@ +ALTER TABLE record_modifications DROP COLUMN date; + +CREATE OR REPLACE FUNCTION audit_record_modification() RETURNS trigger AS $record_modification_trigger$ + DECLARE + progress_change SMALLINT; + video_change VARCHAR(200); + status_change RECORD_STATUS; + player_change INT; + demon_change INTEGER; + BEGIN + if (OLD.progress <> NEW.progress) THEN + progress_change = OLD.progress; + END IF; + + IF (OLD.video <> NEW.video) THEN + video_change = OLD.video; + END IF; + + IF (OLD.status_ <> NEW.status_) THEN + status_change = OLD.status_; + END IF; + + IF (OLD.player <> NEW.player) THEN + player_change = OLD.player; + END IF; + + IF (OLD.demon <> NEW.demon) THEN + demon_change = OLD.demon; + END IF; + + INSERT INTO record_modifications (userid, id, progress, video, status_, player, demon) + (SELECT id, NEW.id, progress_change, video_change, status_change, player_change, demon_change + FROM active_user LIMIT 1); + + RETURN NEW; + END; +$record_modification_trigger$ LANGUAGE plpgsql; + +ALTER TABLE records DROP COLUMN date; \ No newline at end of file diff --git a/migrations/20251114014715_record_dates.up.sql b/migrations/20251114014715_record_dates.up.sql new file mode 100644 index 00000000..0bafd48e --- /dev/null +++ b/migrations/20251114014715_record_dates.up.sql @@ -0,0 +1,50 @@ +ALTER TABLE records ADD COLUMN date TIMESTAMP WITHOUT TIME ZONE DEFAULT (NOW() AT TIME ZONE 'utc') NOT NULL; + +UPDATE records +SET date = record_additions.time +FROM record_additions +WHERE records.id = record_additions.id; + +-- audit logs +ALTER TABLE record_modifications ADD COLUMN date TIMESTAMP WITHOUT TIME ZONE; + +CREATE OR REPLACE FUNCTION audit_record_modification() RETURNS trigger AS $record_modification_trigger$ + DECLARE + progress_change SMALLINT; + video_change VARCHAR(200); + status_change RECORD_STATUS; + player_change INT; + demon_change INTEGER; + date_change TIMESTAMP WITHOUT TIME ZONE; + BEGIN + if (OLD.progress <> NEW.progress) THEN + progress_change = OLD.progress; + END IF; + + IF (OLD.video <> NEW.video) THEN + video_change = OLD.video; + END IF; + + IF (OLD.status_ <> NEW.status_) THEN + status_change = OLD.status_; + END IF; + + IF (OLD.player <> NEW.player) THEN + player_change = OLD.player; + END IF; + + IF (OLD.demon <> NEW.demon) THEN + demon_change = OLD.demon; + END IF; + + IF (OLD.date <> NEW.date) THEN + date_change = OLD.date; + END IF; + + INSERT INTO record_modifications (userid, id, progress, video, status_, player, demon, date) + (SELECT id, NEW.id, progress_change, video_change, status_change, player_change, demon_change, date_change + FROM active_user LIMIT 1); + + RETURN NEW; + END; +$record_modification_trigger$ LANGUAGE plpgsql; \ No newline at end of file diff --git a/migrations/20260208165810_edit_level_id.down.sql b/migrations/20260208165810_edit_level_id.down.sql new file mode 100644 index 00000000..3a2ceabc --- /dev/null +++ b/migrations/20260208165810_edit_level_id.down.sql @@ -0,0 +1,57 @@ +ALTER TABLE demon_modifications DROP COLUMN level_id; + +CREATE OR REPLACE FUNCTION audit_demon_modification() RETURNS trigger AS $demon_modification_trigger$ +DECLARE + name_change CITEXT; + position_change SMALLINT; + rated_position_change SMALLINT; + requirement_change SMALLINT; + video_change VARCHAR(200); + thumbnail_change TEXT; + verifier_change INT; + publisher_change INT; + rated_change BOOLEAN; +BEGIN + IF (OLD.name <> NEW.name) THEN + name_change = OLD.name; + END IF; + + IF (OLD.position <> NEW.position) THEN + position_change = OLD.position; + END IF; + + IF (OLD.rated_position IS DISTINCT FROM NEW.rated_position) THEN + rated_position_change = OLD.rated_position; + END IF; + + IF (OLD.requirement <> NEW.requirement) THEN + requirement_change = OLD.requirement; + END IF; + + IF (OLD.video <> NEW.video) THEN + video_change = OLD.video; + END IF; + + IF (OLD.thumbnail <> NEW.thumbnail) THEN + thumbnail_change = OLD.thumbnail; + END IF; + + IF (OLD.verifier <> NEW.verifier) THEN + verifier_change = OLD.verifier; + END IF; + + IF (OLD.publisher <> NEW.publisher) THEN + publisher_change = OLD.publisher; + END IF; + + IF (OLD.rated <> NEW.rated) THEN + rated_change = OLD.rated; + END IF; + + INSERT INTO demon_modifications (userid, name, position, rated_position, requirement, video, verifier, publisher, thumbnail, rated, id) + (SELECT id, name_change, position_change, rated_position_change, requirement_change, video_change, verifier_change, publisher_change, thumbnail_change, rated_change, NEW.id + FROM active_user LIMIT 1); + + RETURN NEW; +END; +$demon_modification_trigger$ LANGUAGE plpgsql; \ No newline at end of file diff --git a/migrations/20260208165810_edit_level_id.up.sql b/migrations/20260208165810_edit_level_id.up.sql new file mode 100644 index 00000000..e34aa9d6 --- /dev/null +++ b/migrations/20260208165810_edit_level_id.up.sql @@ -0,0 +1,62 @@ +ALTER TABLE demon_modifications ADD COLUMN level_id BIGINT NULL DEFAULT NULL; + +CREATE OR REPLACE FUNCTION audit_demon_modification() RETURNS trigger AS $demon_modification_trigger$ +DECLARE + name_change CITEXT; + position_change SMALLINT; + rated_position_change SMALLINT; + requirement_change SMALLINT; + video_change VARCHAR(200); + thumbnail_change TEXT; + verifier_change INT; + publisher_change INT; + rated_change BOOLEAN; + level_id_change BIGINT; +BEGIN + IF (OLD.name <> NEW.name) THEN + name_change = OLD.name; + END IF; + + IF (OLD.position <> NEW.position) THEN + position_change = OLD.position; + END IF; + + IF (OLD.rated_position IS DISTINCT FROM NEW.rated_position) THEN + rated_position_change = OLD.rated_position; + END IF; + + IF (OLD.requirement <> NEW.requirement) THEN + requirement_change = OLD.requirement; + END IF; + + IF (OLD.video <> NEW.video) THEN + video_change = OLD.video; + END IF; + + IF (OLD.thumbnail <> NEW.thumbnail) THEN + thumbnail_change = OLD.thumbnail; + END IF; + + IF (OLD.verifier <> NEW.verifier) THEN + verifier_change = OLD.verifier; + END IF; + + IF (OLD.publisher <> NEW.publisher) THEN + publisher_change = OLD.publisher; + END IF; + + IF (OLD.rated <> NEW.rated) THEN + rated_change = OLD.rated; + END IF; + + IF (OLD.level_id <> NEW.level_id) THEN + level_id_change = OLD.level_id; + END IF; + + INSERT INTO demon_modifications (userid, name, position, rated_position, requirement, video, verifier, publisher, thumbnail, rated, level_id, id) + (SELECT id, name_change, position_change, rated_position_change, requirement_change, video_change, verifier_change, publisher_change, thumbnail_change, rated_change, level_id_change, NEW.id + FROM active_user LIMIT 1); + + RETURN NEW; +END; +$demon_modification_trigger$ LANGUAGE plpgsql; \ No newline at end of file diff --git a/pointercrate-core-pages/static/css/main.css b/pointercrate-core-pages/static/css/main.css index 073a3708..0ac4366a 100644 --- a/pointercrate-core-pages/static/css/main.css +++ b/pointercrate-core-pages/static/css/main.css @@ -76,7 +76,7 @@ html[data-theme="dark"] { /* --color-consider-accent */ --color-text-accent: #dce6e3; - --color-text: #c9c6bf; + --color-text: #e2dfd8; --color-bg: #121720; --color-bg-surface: #252d3c; diff --git a/pointercrate-demonlist-api/src/pages.rs b/pointercrate-demonlist-api/src/pages.rs index ee1450c6..db14c057 100644 --- a/pointercrate-demonlist-api/src/pages.rs +++ b/pointercrate-demonlist-api/src/pages.rs @@ -179,7 +179,7 @@ pub async fn demon_page( demonlist: current_list(&list.0, &mut connection).await?, list: list.0, movements: modifications, - integration: gd.load_level_for_demon(&full_demon.demon).await, + integration: gd.load_level_for_demon(&full_demon.demon, &mut connection).await, data: full_demon, }, ))) diff --git a/pointercrate-demonlist-pages/src/account/demons.rs b/pointercrate-demonlist-pages/src/account/demons.rs index e1b5b75d..844cbd28 100644 --- a/pointercrate-demonlist-pages/src/account/demons.rs +++ b/pointercrate-demonlist-pages/src/account/demons.rs @@ -129,6 +129,15 @@ impl AccountPageTab for DemonsTab { } } } + div.stats-container.flex.space { + span{ + b { + i.fa.fa-pencil-alt.clickable #demon-levelid-pen aria-hidden = "true" {} " " (tr("demon-viewer.levelid-field")) + } + br; + span #demon-levelid {} + } + } div.stats-container.flex.space { span{ i.fa.fa-plus.clickable #demon-add-creator-pen aria-hidden = "true" {} b { @@ -155,6 +164,7 @@ impl AccountPageTab for DemonsTab { (change_thumbnail_dialog()) (change_verifier_dialog()) (change_publisher_dialog()) + (change_levelid_dialog()) (add_creator_dialog()) } } @@ -334,6 +344,31 @@ fn change_publisher_dialog() -> Markup { ) } +fn change_levelid_dialog() -> Markup { + html! { + div.overlay.closable { + div.dialog #demon-levelid-dialog { + span.plus.cross.hover {} + h2.underlined.pad { + (tr("demon-levelid-dialog")) + } + p style = "max-width: 400px"{ + (tr("demon-levelid-dialog.info")) + } + form.flex.col novalidate = "" { + p.info-red.output {} + p.info-green.output {} + span.form-input #demon-levelid-edit { + input name = "level_id" type = "number" min = "1"; + p.error {} + } + input.button.blue.hover type = "submit" style = "margin: 15px auto 0px;" value = (tr("demon-levelid-dialog.submit")); + } + } + } + } +} + fn add_creator_dialog() -> Markup { player_selection_dialog( "demon-add-creator-dialog", diff --git a/pointercrate-demonlist-pages/src/account/records.rs b/pointercrate-demonlist-pages/src/account/records.rs index 3d999af4..9229f813 100644 --- a/pointercrate-demonlist-pages/src/account/records.rs +++ b/pointercrate-demonlist-pages/src/account/records.rs @@ -82,6 +82,7 @@ impl AccountPageTab for RecordsPage { (change_video_dialog()) (change_holder_dialog()) (change_demon_dialog(&demons[..])) + (change_date_dialog()) } } } @@ -174,6 +175,15 @@ fn record_manager(demons: &[Demon]) -> Markup { span #record-submitter {} } } + div.stats-container.flex.space { + span { + b { + i.fa.fa-pencil-alt.clickable #record-date-pen aria-hidden = "true" {} " " (tr("record-date")) + } + br; + span #record-date {} + } + } span.button.red.hover #record-delete style = "margin: 15px auto 0px" {(tr("record-viewer.delete"))}; } } @@ -409,3 +419,29 @@ fn change_demon_dialog(demons: &[Demon]) -> Markup { } } } + +fn change_date_dialog() -> Markup { + html! { + div.overlay.closable { + div.dialog #record-date-dialog { + span.plus.cross.hover {} + h2.underlined.pad { + (tr("record-date-dialog")) + } + p style = "max-width: 400px"{ + (tr("record-date-dialog.info")) + } + form.flex.col novalidate = "" { + p.info-red.output {} + p.info-green.output {} + span.form-input #record-date-edit { + label for = "date" {(tr("record-date-dialog.date-field")) } + input name = "date" type = "datetime-local"; + p.error {} + } + input.button.blue.hover type = "submit" style = "margin: 15px auto 0px;" value = (tr("record-date-dialog.submit")); + } + } + } + } +} diff --git a/pointercrate-demonlist-pages/static/ftl/en-us/demon.ftl b/pointercrate-demonlist-pages/static/ftl/en-us/demon.ftl index a76b98f2..87d636c3 100644 --- a/pointercrate-demonlist-pages/static/ftl/en-us/demon.ftl +++ b/pointercrate-demonlist-pages/static/ftl/en-us/demon.ftl @@ -131,6 +131,7 @@ demon-viewer = Demon # .verifier-field = { demon-verifier }: .rated-field = { demon-rated }: .creators-field = { demon-creators }: + .levelid-field = { demon-id }: demon-add-panel = Add Demon .button = Add a demon! @@ -191,6 +192,10 @@ demon-verifier-dialog = Change demon verifier .info = Type the new verifier of the demon into the text field below. If the player already exists, it will appear as a suggestion below the text field. Then click the button below. .submit = Edit +demon-levelid-dialog = Change level ID + .info = Leave empty to remove the level ID. + .submit = Edit + demon-creator-dialog = Add creator .info = Type the creator to add to this demon into the text field below. If the player already exists, it will appear as a suggestion below the text field. Then click the button below. .submit = Add Creator diff --git a/pointercrate-demonlist-pages/static/ftl/en-us/record.ftl b/pointercrate-demonlist-pages/static/ftl/en-us/record.ftl index 54ba474c..b7b5b21f 100644 --- a/pointercrate-demonlist-pages/static/ftl/en-us/record.ftl +++ b/pointercrate-demonlist-pages/static/ftl/en-us/record.ftl @@ -11,6 +11,7 @@ record-demon = Demon record-holder = Record Holder record-progress = Progress record-submitter = Submitter ID +record-date = Submission Date ## Records tab (user area) records = Records @@ -94,6 +95,12 @@ record-progress-dialog = Change record progress .progress-validator-stepmismatch = Record progress mustn't be a decimal .progress-validator-valuemissing = Please enter a progress value +record-date-dialog = Change record submission date + .info = Change the submission date of this record. This can be modified to reorder records in demon pages. + .date-field = Date: + + .submit = Edit + # The giant information box below the record manager, split # into different sections here # diff --git a/pointercrate-demonlist-pages/static/ftl/ru-ru/demon.ftl b/pointercrate-demonlist-pages/static/ftl/ru-ru/demon.ftl index 1d0e93a1..ae0f93c8 100644 --- a/pointercrate-demonlist-pages/static/ftl/ru-ru/demon.ftl +++ b/pointercrate-demonlist-pages/static/ftl/ru-ru/demon.ftl @@ -132,6 +132,7 @@ demon-viewer = Демон # .publisher-field = { demon-publisher }: .verifier-field = { demon-verifier }: .creators-field = { demon-creators }: + .levelid-field = { demon-id }: demon-add-panel = Добавление демона .button = Добавить демон! @@ -191,6 +192,10 @@ demon-verifier-dialog = Изменение верифера демона .info = Здесь проходит введение нового верифера демона через поле ниже. Если такой игрок уже существует, его имя появится в качестве предложения ниже поля ввода. После этого нажмите на кнопку ниже. .submit = Изменить +demon-levelid-dialog = Изменение ID уровня + .info = Оставьте пустой для удаления ID уровня. + .submit = Изменить + demon-creator-dialog = Добавление креатора .info = Здесь проходит добавление нового креатора для этого демона через поле ниже. Если такой игрок уже существует, его имя появится в качестве предложения ниже поля ввода. После этого нажмите на кнопку ниже. .submit = Добавить креатора diff --git a/pointercrate-demonlist-pages/static/js/account/demon.js b/pointercrate-demonlist-pages/static/js/account/demon.js index 24875c5e..a0996779 100644 --- a/pointercrate-demonlist-pages/static/js/account/demon.js +++ b/pointercrate-demonlist-pages/static/js/account/demon.js @@ -22,7 +22,7 @@ import { FormDialog, setupDropdownEditor, } from "/static/core/js/modules/form.js"; -import { loadResource, tr } from "/static/core/js/modules/localization.js"; +import { tr } from "/static/core/js/modules/localization.js"; export let demonManager; @@ -51,6 +51,8 @@ export class DemonManager extends FilteredPaginator { this._verifier = document.getElementById("demon-verifier"); this._publisher = document.getElementById("demon-publisher"); + this._level_id = document.getElementById("demon-levelid"); + this._creators = document.getElementById("demon-creators"); this._rated = setupDropdownEditor( @@ -159,6 +161,12 @@ export class DemonManager extends FilteredPaginator { new PaginatorEditorBackend(this, true), this.output ); + setupFormDialogEditor( + new PaginatorEditorBackend(this, true), + "demon-levelid-dialog", + "demon-levelid-pen", + this.output + ); } onReceive(response) { @@ -203,6 +211,13 @@ export class DemonManager extends FilteredPaginator { " (" + this.currentObject.verifier.id + ")"; + + if (this.currentObject.level_id) { + this._level_id.innerText = this.currentObject.level_id; + this._level_id.style.display = "initial"; + } else { + this._level_id.style.display = "none"; + } this._rated.selectSilently(this.currentObject.rated.toString()); diff --git a/pointercrate-demonlist-pages/static/js/account/records.js b/pointercrate-demonlist-pages/static/js/account/records.js index 4bb8aca8..72cd33f9 100644 --- a/pointercrate-demonlist-pages/static/js/account/records.js +++ b/pointercrate-demonlist-pages/static/js/account/records.js @@ -50,6 +50,7 @@ class RecordManager extends Paginator { this._progress = document.getElementById("record-progress"); this._submitter = document.getElementById("record-submitter"); this._notes = document.getElementById("record-notes"); + this._date = document.getElementById("record-date"); this.dropdown = new Dropdown( document @@ -84,6 +85,7 @@ class RecordManager extends Paginator { this.output ); this.initDemonDialog(); + this.initDateDialog(); document .getElementById("record-copy-info") @@ -183,6 +185,16 @@ class RecordManager extends Paginator { ); } + initDateDialog() { + setupEditorDialog( + new FormDialog("record-date-dialog"), + "record-date-pen", + new PaginatorEditorBackend(this, true), + this.output, + (date) => ({ date: new Date(date.date).toISOString() }) + ); + } + onReceive(response) { super.onReceive(response); @@ -227,6 +239,9 @@ class RecordManager extends Paginator { this._progress.innerText = this.currentObject.progress + "%"; this._submitter.innerText = this.currentObject.submitter.id; + let date = new Date(this.currentObject.date); + this._date.innerText = date.toLocaleString(); + // this is introducing race conditions. Oh well. return get("/api/v1/records/" + this.currentObject.id + "/notes/").then( (response) => { diff --git a/pointercrate-demonlist-pages/static/js/statsviewer/individual.js b/pointercrate-demonlist-pages/static/js/statsviewer/individual.js index 9cf85e09..102b0f75 100644 --- a/pointercrate-demonlist-pages/static/js/statsviewer/individual.js +++ b/pointercrate-demonlist-pages/static/js/statsviewer/individual.js @@ -30,7 +30,7 @@ class IndividualStatsViewer extends StatsViewer { const demonPositionKey = window.active_list == "demonlist" ? "rated_position" : "position"; this._rank.innerText = playerData[rankKey] || "-"; - this._score.innerText = playerData[scoreKey].toFixed(2); + this._score.innerText = playerData[scoreKey]?.toFixed(2) ?? "-"; this.setName(playerData.name, playerData.nationality); @@ -278,7 +278,9 @@ function generateStatsViewerPlayer(player) { li.className = "white hover"; li.dataset.id = player.id; - b.appendChild(document.createTextNode("#" + player.rank + " ")); + if (player.rank) { + b.appendChild(document.createTextNode("#" + player.rank + " ")); + } i.appendChild(document.createTextNode(player.score.toFixed(2))); if (player.nationality) { diff --git a/pointercrate-demonlist/sql/paginate_player_ranking.sql b/pointercrate-demonlist/sql/paginate_player_ranking.sql index c85908dc..a146a6fb 100644 --- a/pointercrate-demonlist/sql/paginate_player_ranking.sql +++ b/pointercrate-demonlist/sql/paginate_player_ranking.sql @@ -1,11 +1,11 @@ SELECT ratedplus_index as index, ratedplus_rank as rank, id, name, ratedplus_score as score, subdivision, iso_country_code, nation FROM ranked_players -WHERE ratedplus_rank IS NOT NULL +WHERE ratedplus_score IS NOT NULL AND (ratedplus_index < $1 OR $1 IS NULL) AND (ratedplus_index > $2 OR $2 IS NULL) AND (STRPOS(name, $3::CITEXT) > 0 OR $3 is NULL) AND (nation = $4 OR iso_country_code = $4 OR (nation IS NULL AND $5) OR ($4 IS NULL AND NOT $5)) AND (continent = CAST($6::TEXT AS continent) OR $6 IS NULL) AND (subdivision = $7 OR $7 IS NULL) -ORDER BY ratedplus_rank {}, id +ORDER BY ratedplus_index {} LIMIT $8 \ No newline at end of file diff --git a/pointercrate-demonlist/sql/paginate_rated_player_ranking.sql b/pointercrate-demonlist/sql/paginate_rated_player_ranking.sql index fc10876a..989572ef 100644 --- a/pointercrate-demonlist/sql/paginate_rated_player_ranking.sql +++ b/pointercrate-demonlist/sql/paginate_rated_player_ranking.sql @@ -1,11 +1,11 @@ SELECT index, rank, id, name, score, subdivision, iso_country_code, nation FROM ranked_players -WHERE rank IS NOT NULL +WHERE score IS NOT NULL AND (index < $1 OR $1 IS NULL) AND (index > $2 OR $2 IS NULL) AND (STRPOS(name, $3::CITEXT) > 0 OR $3 is NULL) AND (nation = $4 OR iso_country_code = $4 OR (nation IS NULL AND $5) OR ($4 IS NULL AND NOT $5)) AND (continent = CAST($6::TEXT AS continent) OR $6 IS NULL) AND (subdivision = $7 OR $7 IS NULL) -ORDER BY rank {}, id +ORDER BY index {} LIMIT $8 \ No newline at end of file diff --git a/pointercrate-demonlist/sql/record_by_id.sql b/pointercrate-demonlist/sql/record_by_id.sql index 87b47fb8..de43f21b 100644 --- a/pointercrate-demonlist/sql/record_by_id.sql +++ b/pointercrate-demonlist/sql/record_by_id.sql @@ -2,6 +2,7 @@ SELECT progress, CASE WHEN players.link_banned THEN NULL ELSE records.video::text END, CASE WHEN players.link_banned THEN NULL ELSE records.raw_footage::text END, status_::text AS "status!: String" , + date AS "date!", players.id AS player_id, players.name AS "player_name: String", players.banned AS player_banned, demons.id AS demon_id, demons.name AS "demon_name: String", demons.position, demons.rated_position, submitters.submitter_id AS submitter_id, submitters.banned AS submitter_banned diff --git a/pointercrate-demonlist/src/demon/patch.rs b/pointercrate-demonlist/src/demon/patch.rs index 1d22d4b8..e4c6328d 100644 --- a/pointercrate-demonlist/src/demon/patch.rs +++ b/pointercrate-demonlist/src/demon/patch.rs @@ -33,6 +33,9 @@ pub struct PatchDemon { #[serde(default, deserialize_with = "non_nullable")] pub rated: Option, + + #[serde(default, deserialize_with = "nullable")] + pub level_id: Option>, } impl FullDemon { @@ -96,6 +99,10 @@ impl Demon { self.set_rated(rated, connection).await?; } + if let Some(level_id) = patch.level_id { + self.set_level_id(level_id, connection).await?; + } + Ok(self) } @@ -191,6 +198,21 @@ impl Demon { Ok(()) } + + pub async fn set_level_id(&mut self, level_id: Option, connection: &mut PgConnection) -> Result<()> { + let validated_level_id = match level_id { + Some(id) => Some(Demon::validate_level_id(id)?), + None => None, + }; + + sqlx::query!("UPDATE demons SET level_id = $1 WHERE id = $2", level_id, self.base.id) + .execute(connection) + .await?; + + self.level_id = validated_level_id; + + Ok(()) + } } impl MinimalDemon { diff --git a/pointercrate-demonlist/src/player/mod.rs b/pointercrate-demonlist/src/player/mod.rs index fa3dd557..8f9ea74e 100644 --- a/pointercrate-demonlist/src/player/mod.rs +++ b/pointercrate-demonlist/src/player/mod.rs @@ -59,8 +59,8 @@ pub struct Player { /// - Player updates /// * Player banned /// * Player objects merged - pub rated_score: f64, - pub score: f64, + pub rated_score: Option, + pub score: Option, pub rated_rank: Option, pub rank: Option, pub nationality: Option, @@ -71,7 +71,7 @@ pub struct Player { impl Hash for Player { fn hash(&self, state: &mut H) { self.base.hash(state); - ((self.score * 100f64) as u64).hash(state); + self.score.map(|score| (score * 100f64) as u64).hash(state); self.nationality.hash(state); } } @@ -86,10 +86,10 @@ impl Taggable for FullPlayer { impl DatabasePlayer { /// Recomputes this player's score and updates it in the database. - pub async fn update_score(&self, connection: &mut PgConnection) -> Result<(f64, f64), CoreError> { + pub async fn update_score(&self, connection: &mut PgConnection) -> Result<(Option, Option), CoreError> { // No need to specially handle banned players - they have no approved records, so `score_of_player` will return 0 let new_scores = sqlx::query!( - "UPDATE players SET score = coalesce(score_of_player(true, $1), 0), ratedplus_score = coalesce(score_of_player(false, $1), 0) WHERE id = $1 RETURNING score, ratedplus_score", + "UPDATE players SET score = score_of_player(true, $1), ratedplus_score = score_of_player(false, $1) WHERE id = $1 RETURNING score, ratedplus_score", self.id ) .fetch_one(&mut *connection) diff --git a/pointercrate-demonlist/src/player/paginate.rs b/pointercrate-demonlist/src/player/paginate.rs index 2b282734..c89330d8 100644 --- a/pointercrate-demonlist/src/player/paginate.rs +++ b/pointercrate-demonlist/src/player/paginate.rs @@ -163,7 +163,7 @@ pub struct RankedPlayer { impl Paginatable for RankedPlayer { async fn first_and_last(connection: &mut PgConnection) -> Result, sqlx::Error> { - Ok(sqlx::query!("SELECT COUNT(*) FROM players WHERE NOT banned AND (score > 0.0 OR ratedplus_score > 0.0)") + Ok(sqlx::query!("SELECT COUNT(*) FROM players WHERE NOT banned AND (score IS NOT NULL OR ratedplus_score IS NOT NULL)") .fetch_one(connection) .await? .count diff --git a/pointercrate-demonlist/src/record/get.rs b/pointercrate-demonlist/src/record/get.rs index d22b735c..9e51b858 100644 --- a/pointercrate-demonlist/src/record/get.rs +++ b/pointercrate-demonlist/src/record/get.rs @@ -6,6 +6,7 @@ use crate::{ record::{FullRecord, MinimalRecordD, MinimalRecordP, RecordStatus}, submitter::Submitter, }; +use chrono::{NaiveDateTime, TimeZone, Utc}; use futures::stream::StreamExt; use sqlx::{Error, PgConnection}; @@ -15,6 +16,7 @@ struct FetchedRecord { video: Option, raw_footage: Option, status: String, + date: NaiveDateTime, player_id: i32, player_name: String, player_banned: bool, @@ -54,6 +56,7 @@ impl FullRecord { id: row.submitter_id, banned: row.submitter_banned, }), + date: Utc.from_utc_datetime(&row.date), }), Err(Error::RowNotFound) => Err(DemonlistError::RecordNotFound { record_id: id }), @@ -109,7 +112,7 @@ pub async fn approved_records_on(demon: &MinimalDemon, connection: &mut PgConnec Fetched, r#"SELECT records.id, progress, CASE WHEN players.link_banned THEN NULL ELSE video::text END, players.id AS player_id, players.name, players.banned, nation::TEXT, iso_country_code::TEXT FROM records INNER JOIN players ON records.player = players.id LEFT OUTER JOIN nationalities ON nationality = iso_country_code WHERE status_ = 'APPROVED' AND - records.demon = $1 ORDER BY progress DESC, id ASC"#, + records.demon = $1 ORDER BY progress DESC, date ASC"#, demon.id ) .fetch(connection); diff --git a/pointercrate-demonlist/src/record/mod.rs b/pointercrate-demonlist/src/record/mod.rs index 441b0c61..4ffcf62e 100644 --- a/pointercrate-demonlist/src/record/mod.rs +++ b/pointercrate-demonlist/src/record/mod.rs @@ -28,6 +28,7 @@ pub use self::{ post::Submission, }; use crate::{demon::MinimalDemon, error::Result, nationality::Nationality, player::DatabasePlayer, submitter::Submitter}; +use chrono::{DateTime, Utc}; use derive_more::Display; use pointercrate_core::etag::Taggable; use serde::{Deserialize, Deserializer, Serialize, Serializer}; @@ -128,6 +129,7 @@ pub struct FullRecord { pub demon: MinimalDemon, pub submitter: Option, pub raw_footage: Option, + pub date: DateTime, } impl Taggable for FullRecord { diff --git a/pointercrate-demonlist/src/record/patch.rs b/pointercrate-demonlist/src/record/patch.rs index 8256a8c0..f4398d1f 100644 --- a/pointercrate-demonlist/src/record/patch.rs +++ b/pointercrate-demonlist/src/record/patch.rs @@ -4,6 +4,7 @@ use crate::{ player::DatabasePlayer, record::{FullRecord, RecordStatus}, }; +use chrono::{DateTime, Utc}; use log::{info, warn}; use pointercrate_core::{ error::CoreError, @@ -31,6 +32,9 @@ pub struct PatchRecord { #[serde(default, deserialize_with = "non_nullable")] demon_id: Option, + + #[serde(default, deserialize_with = "non_nullable")] + date: Option>, } impl FullRecord { @@ -69,6 +73,10 @@ impl FullRecord { _ => (), } + if let Some(date) = data.date { + self.set_date(date, connection).await?; + } + // Not all record update require recomputing scores (for example, changing status from "submitted" to "under consideration") // but the logic for correctly determining this is hard, and updating scores of individual players cheap, so we do not bother. self.player.update_score(connection).await?; @@ -395,4 +403,14 @@ impl FullRecord { Ok(()) } + + pub async fn set_date(&mut self, date: DateTime, connection: &mut PgConnection) -> Result<()> { + sqlx::query!("UPDATE records SET date = $1 WHERE id = $2", date.naive_utc(), self.id) + .execute(&mut *connection) + .await?; + + self.date = date; + + Ok(()) + } } diff --git a/pointercrate-demonlist/src/record/post.rs b/pointercrate-demonlist/src/record/post.rs index a1e4808d..3320a13a 100644 --- a/pointercrate-demonlist/src/record/post.rs +++ b/pointercrate-demonlist/src/record/post.rs @@ -5,6 +5,7 @@ use crate::{ record::{FullRecord, RecordStatus}, submitter::Submitter, }; +use chrono::{TimeZone, Utc}; use derive_more::Display; use log::debug; use serde::Deserialize; @@ -172,8 +173,8 @@ impl NormalizedSubmission { impl ValidatedSubmission { pub async fn create(self, submitter: Submitter, connection: &mut PgConnection) -> Result { - let id = sqlx::query!( - "INSERT INTO records (progress, video, status_, player, submitter, demon, raw_footage) VALUES ($1, $2::TEXT, 'SUBMITTED', $3, $4, $5, $6) RETURNING id", + let row = sqlx::query!( + "INSERT INTO records (progress, video, status_, player, submitter, demon, raw_footage) VALUES ($1, $2::TEXT, 'SUBMITTED', $3, $4, $5, $6) RETURNING id, date", self.progress, self.video, self.player.id, @@ -182,11 +183,10 @@ impl ValidatedSubmission { self.raw_footage ) .fetch_one(&mut *connection) - .await? - .id; + .await?; let mut record = FullRecord { - id, + id: row.id, progress: self.progress, video: self.video, raw_footage: self.raw_footage, @@ -194,6 +194,7 @@ impl ValidatedSubmission { player: self.player, demon: self.demon, submitter: Some(submitter), + date: Utc.from_utc_datetime(&row.date), }; // Dealing with different status and upholding their invariant is complicated, we should not diff --git a/pointercrate-integrate/src/gd.rs b/pointercrate-integrate/src/gd.rs index e930df49..fb28bed8 100644 --- a/pointercrate-integrate/src/gd.rs +++ b/pointercrate-integrate/src/gd.rs @@ -12,7 +12,7 @@ use log::{debug, error, trace, warn}; use pointercrate_core::ratelimits; use pointercrate_demonlist::demon::Demon; use reqwest::{header::CONTENT_TYPE, Client}; -use sqlx::{Pool, Postgres}; +use sqlx::{PgConnection, Pool, Postgres}; use std::{borrow::Cow, sync::Arc}; pub use dash_rs::{ @@ -37,7 +37,7 @@ impl GeometryDashConnector { /// /// If the last time the data for this demon was sought on the Geometry Dash servers was over 24h ago, /// re-query them for updated data. - pub async fn load_level_for_demon(&self, demon: &Demon) -> Option { + pub async fn load_level_for_demon(&self, demon: &Demon, connection: &mut PgConnection) -> Option { if self.ratelimits.throttle_throttle(demon.base.id).is_ok() && self.ratelimits.throttle().is_ok() && self.ratelimits.demon_refresh(demon.base.id).is_ok() @@ -50,12 +50,12 @@ impl GeometryDashConnector { if let Some(level_id) = demon.level_id { let level = self - .lookup_level(level_id) + .lookup_level(level_id, &mut *connection) .await? - .with_data(self.lookup_level_data(level_id).await?); + .with_data(self.lookup_level_data(level_id, &mut *connection).await?); let song = match level.custom_song { - Some(id) => self.lookup_newgrounds_song(id).await, + Some(id) => self.lookup_newgrounds_song(id, &mut *connection).await, None => None, }; @@ -164,9 +164,7 @@ impl GeometryDashConnector { } } - pub async fn lookup_creator(&self, user_id: u64) -> Option> { - let mut connection = self.pool.acquire().await.ok()?; - + pub async fn lookup_creator(&self, user_id: u64, connection: &mut PgConnection) -> Option> { let creator_row = sqlx::query!("SELECT * FROM gj_creator WHERE user_id = $1", user_id as i64) .fetch_one(&mut *connection) .await @@ -195,9 +193,7 @@ impl GeometryDashConnector { let _ = connection.commit().await; } - pub async fn lookup_newgrounds_song(&self, song_id: u64) -> Option> { - let mut connection = self.pool.acquire().await.ok()?; - + pub async fn lookup_newgrounds_song(&self, song_id: u64, connection: &mut PgConnection) -> Option> { let song_row = sqlx::query!("SELECT * from gj_newgrounds_song WHERE song_id = $1", song_id as i64) .fetch_one(&mut *connection) .await @@ -242,9 +238,7 @@ impl GeometryDashConnector { let _ = connection.commit().await; } - pub async fn lookup_level_data(&self, level_id: u64) -> Option> { - let mut connection = self.pool.acquire().await.ok()?; - + pub async fn lookup_level_data(&self, level_id: u64, connection: &mut PgConnection) -> Option> { let row = sqlx::query!("SELECT * FROM gj_level_data WHERE level_id = $1", level_id as i64) .fetch_one(&mut *connection) .await @@ -304,9 +298,7 @@ impl GeometryDashConnector { let _ = connection.commit().await; } - pub async fn lookup_level(&self, level_id: u64) -> Option> { - let mut connection = self.pool.acquire().await.ok()?; - + pub async fn lookup_level(&self, level_id: u64, connection: &mut PgConnection) -> Option> { let row = sqlx::query!("SELECT * FROM gj_level WHERE level_id = $1", level_id as i64) .fetch_one(&mut *connection) .await diff --git a/pointercrate-test/tests/demonlist/list.rs b/pointercrate-test/tests/demonlist/list.rs index b5302130..6eeb57db 100644 --- a/pointercrate-test/tests/demonlist/list.rs +++ b/pointercrate-test/tests/demonlist/list.rs @@ -1,7 +1,7 @@ use pointercrate_core::etag::Taggable; use pointercrate_demonlist::{ demon::{recompute_rated_positions, Demon, FullDemon}, - player::{recompute_scores, DatabasePlayer, FullPlayer}, + player::{recompute_scores, DatabasePlayer, FullPlayer, Player}, record::RecordStatus, LIST_MODERATOR, }; @@ -10,6 +10,10 @@ use pointercrate_test::TestClient; use pointercrate_user::auth::{AuthenticatedUser, PasswordOrBrowser}; use rocket::http::Status; use sqlx::{PgConnection, Pool, Postgres}; +use std::{ + collections::hash_map::DefaultHasher, + hash::{Hash, Hasher}, +}; fn ranking_contains(ranking: &[serde_json::Value], name: &str) -> bool { ranking.iter().any(|player| player["name"].as_str() == Some(name)) @@ -99,7 +103,7 @@ async fn test_rerating_demon_restores_demonlist_score_and_position(pool: Pool) { .get_success_result() .await; - assert_ne!(player.player.score, 0.0f64, "Unrated demon failed to give verifier a rated+ score"); - assert_eq!(player.player.rated_score, 0.0f64, "Unrated demon gave verifier a demonlist score"); + assert_ne!( + player.player.score.unwrap(), + 0.0f64, + "Unrated demon failed to give verifier a rated+ score" + ); + assert_eq!(player.player.rated_score, None, "Unrated demon gave verifier a demonlist score"); } #[sqlx::test(migrations = "../migrations")] @@ -258,9 +266,14 @@ async fn test_rated_demon_gives_both_scores(pool: Pool) { .get_success_result() .await; - assert_ne!(player.player.score, 0.0f64, "Rated demon failed to give verifier a rated+ score"); assert_ne!( - player.player.rated_score, 0.0f64, + player.player.score.unwrap(), + 0.0f64, + "Rated demon failed to give verifier a rated+ score" + ); + assert_ne!( + player.player.rated_score.unwrap(), + 0.0f64, "Rated demon failed to give verifier a demonlist score" ); } @@ -281,7 +294,7 @@ async fn test_patch_demon_to_unrated_removes_demonlist_score(pool: Pool) { .await .unwrap(); + let (full_score, incremental_score) = (full.score.unwrap(), incremental.score.unwrap()); + let (full_ratedplus_score, incremental_ratedplus_score) = (full.ratedplus_score.unwrap(), incremental.ratedplus_score.unwrap()); + assert!( - (full.score - incremental.score).abs() < 1e-9, + (full_score - incremental_score).abs() < 1e-9, "demonlist score drifts between recompute_scores ({}) and update_score ({})", - full.score, - incremental.score + full_score, + incremental_score ); assert!( - (full.ratedplus_score - incremental.ratedplus_score).abs() < 1e-9, + (full_ratedplus_score - incremental_ratedplus_score).abs() < 1e-9, "rated+ score drifts between recompute_scores ({}) and update_score ({})", - full.ratedplus_score, - incremental.ratedplus_score + full_ratedplus_score, + incremental_ratedplus_score ); - assert!(full.score > 0.0 && full.ratedplus_score > 0.0); + assert!(full_score > 0.0 && full_ratedplus_score > 0.0); } #[test] @@ -956,7 +975,11 @@ async fn test_raising_requirement_updates_player_scores(pool: Pool) { .expect_status(Status::Ok) .get_success_result() .await; - assert_ne!(before.player.rated_score, 0.0f64, "sanity: the 50% record should give the holder a demonlist score"); + assert_ne!( + before.player.rated_score.unwrap(), + 0.0f64, + "sanity: the 50% record should give the holder a demonlist score" + ); let full: FullDemon = clnt .get(format!("/api/v2/demons/{}/", demon_id)) @@ -976,12 +999,12 @@ async fn test_raising_requirement_updates_player_scores(pool: Pool) { .get_success_result() .await; assert_eq!( - after.player.rated_score, 0.0f64, - "raising the requirement deleted the holder's only record, so their demonlist score must drop to 0" + after.player.rated_score, None, + "raising the requirement deleted the holder's only record, so their demonlist score must be removed" ); assert_eq!( - after.player.score, 0.0f64, - "raising the requirement deleted the holder's only record, so their rated+ score must drop to 0" + after.player.score, None, + "raising the requirement deleted the holder's only record, so their rated+ score must be removed" ); } @@ -1606,3 +1629,292 @@ async fn test_heatmap_css_includes_subdivision_rules_per_list(pool: Pool) { + let (clnt, mut connection) = pointercrate_test::demonlist::setup_rocket(pool).await; + + let verifier = DatabasePlayer::by_name_or_create("stardust1971", &mut connection).await.unwrap(); + let scoreless = DatabasePlayer::by_name_or_create("scoreless", &mut connection).await.unwrap(); + + add_demon("Bloodbath", 1, 100, verifier.id, verifier.id, true, &mut connection).await; + recompute_rated_positions(&mut connection).await.unwrap(); + recompute_scores(&mut connection).await.unwrap(); + + let player: FullPlayer = clnt + .get(format!("/api/v1/players/{}/", scoreless.id)) + .expect_status(Status::Ok) + .get_success_result() + .await; + + assert_eq!(player.player.rated_score, None); + assert_eq!(player.player.score, None); + assert_eq!(player.player.rated_rank, None); + assert_eq!(player.player.rank, None); + + let (demonlist_ranking, _) = clnt + .get("/api/v1/players/ranking/?list=demonlist") + .get_pagination_result::() + .await; + let (ratedplus_ranking, _) = clnt + .get("/api/v1/players/ranking/?list=ratedplus") + .get_pagination_result::() + .await; + + assert!(!ranking_contains(&demonlist_ranking, "scoreless")); + assert!(!ranking_contains(&ratedplus_ranking, "scoreless")); +} + +#[sqlx::test(migrations = "../migrations")] +async fn test_newly_created_player_has_no_scores(pool: Pool) { + let (_clnt, mut connection) = pointercrate_test::demonlist::setup_rocket(pool).await; + + let player = DatabasePlayer::by_name_or_create("stardust1971", &mut connection).await.unwrap(); + + let row = sqlx::query!("SELECT score, ratedplus_score FROM players WHERE id = $1", player.id) + .fetch_one(&mut *connection) + .await + .unwrap(); + + assert_eq!(row.score, None); + assert_eq!(row.ratedplus_score, None); +} + +#[sqlx::test(migrations = "../migrations")] +async fn test_legacy_record_lists_player_with_zero_score_on_both_lists(pool: Pool) { + let (clnt, mut connection) = pointercrate_test::demonlist::setup_rocket(pool).await; + + let verifier = DatabasePlayer::by_name_or_create("stardust1971", &mut connection).await.unwrap(); + let holder = DatabasePlayer::by_name_or_create("legacy_holder", &mut connection).await.unwrap(); + + let mut legacy = 0; + for position in 1..=151 { + legacy = add_demon( + format!("Demon {}", position), + position, + 50, + verifier.id, + verifier.id, + true, + &mut connection, + ) + .await; + } + recompute_rated_positions(&mut connection).await.unwrap(); + + add_simple_record(100, holder.id, legacy, RecordStatus::Approved, &mut connection).await; + recompute_scores(&mut connection).await.unwrap(); + + let player: FullPlayer = clnt + .get(format!("/api/v1/players/{}/", holder.id)) + .expect_status(Status::Ok) + .get_success_result() + .await; + + assert_eq!(player.player.rated_score, Some(0.0f64)); + assert_eq!(player.player.score, Some(0.0f64)); + assert_eq!(player.player.rated_rank, None); + assert_eq!(player.player.rank, None); + + let (demonlist_ranking, _) = clnt + .get("/api/v1/players/ranking/?list=demonlist") + .get_pagination_result::() + .await; + let (ratedplus_ranking, _) = clnt + .get("/api/v1/players/ranking/?list=ratedplus") + .get_pagination_result::() + .await; + + assert!(ranking_contains(&demonlist_ranking, "legacy_holder")); + assert!(ranking_contains(&ratedplus_ranking, "legacy_holder")); +} + +#[sqlx::test(migrations = "../migrations")] +async fn test_zero_score_players_listed_unranked_below_scoring_players(pool: Pool) { + let (clnt, mut connection) = pointercrate_test::demonlist::setup_rocket(pool).await; + + let verifier = DatabasePlayer::by_name_or_create("stardust1971", &mut connection).await.unwrap(); + let scoring = DatabasePlayer::by_name_or_create("scoring_holder", &mut connection).await.unwrap(); + let zero = DatabasePlayer::by_name_or_create("zero_holder", &mut connection).await.unwrap(); + + let mut demons = Vec::new(); + for position in 1..=76 { + demons.push( + add_demon( + format!("Demon {}", position), + position, + 50, + verifier.id, + verifier.id, + true, + &mut connection, + ) + .await, + ); + } + recompute_rated_positions(&mut connection).await.unwrap(); + + add_simple_record(100, scoring.id, demons[0], RecordStatus::Approved, &mut connection).await; + add_simple_record(90, zero.id, demons[75], RecordStatus::Approved, &mut connection).await; + recompute_scores(&mut connection).await.unwrap(); + + for list in ["demonlist", "ratedplus"] { + let (ranking, _) = clnt + .get(format!("/api/v1/players/ranking/?list={}", list)) + .get_pagination_result::() + .await; + + let scoring_entry = ranking.iter().find(|entry| entry["name"] == "scoring_holder").unwrap(); + let zero_entry = ranking + .iter() + .find(|entry| entry["name"] == "zero_holder") + .unwrap_or_else(|| panic!("zero score player missing from {} ranking: {:?}", list, ranking)); + + assert_eq!(zero_entry["score"].as_f64(), Some(0.0f64), "{}", zero_entry); + assert!(zero_entry["rank"].is_null(), "zero score player was given a rank on {}: {}", list, zero_entry); + assert_eq!(scoring_entry["rank"].as_i64(), Some(2), "{}", scoring_entry); + assert_eq!( + ranking.last().unwrap()["name"], + "zero_holder", + "zero score player is not last on {}: {:?}", + list, + ranking + ); + } +} + +#[sqlx::test(migrations = "../migrations")] +async fn test_unrated_only_holder_has_no_demonlist_score(pool: Pool) { + let (clnt, mut connection) = pointercrate_test::demonlist::setup_rocket(pool).await; + + let verifier = DatabasePlayer::by_name_or_create("stardust1971", &mut connection).await.unwrap(); + let holder = DatabasePlayer::by_name_or_create("unrated_holder", &mut connection).await.unwrap(); + + let unrated = add_demon("Bloodlust", 1, 100, verifier.id, verifier.id, false, &mut connection).await; + recompute_rated_positions(&mut connection).await.unwrap(); + + add_simple_record(100, holder.id, unrated, RecordStatus::Approved, &mut connection).await; + recompute_scores(&mut connection).await.unwrap(); + + let player: FullPlayer = clnt + .get(format!("/api/v1/players/{}/", holder.id)) + .expect_status(Status::Ok) + .get_success_result() + .await; + + assert_eq!(player.player.rated_score, None); + assert_eq!(player.player.rated_rank, None); + assert_ne!(player.player.score.unwrap(), 0.0f64); + assert!(player.player.rank.is_some()); +} + +#[sqlx::test(migrations = "../migrations")] +async fn test_update_score_distinguishes_missing_and_zero_score(pool: Pool) { + let (_clnt, mut connection) = pointercrate_test::demonlist::setup_rocket(pool).await; + + let verifier = DatabasePlayer::by_name_or_create("stardust1971", &mut connection).await.unwrap(); + let holder = DatabasePlayer::by_name_or_create("holder", &mut connection).await.unwrap(); + let scoreless = DatabasePlayer::by_name_or_create("scoreless", &mut connection).await.unwrap(); + + let mut demons = Vec::new(); + for position in 1..=77 { + demons.push( + add_demon( + format!("Demon {}", position), + position, + 50, + verifier.id, + verifier.id, + position != 1, + &mut connection, + ) + .await, + ); + } + recompute_rated_positions(&mut connection).await.unwrap(); + + let record = add_simple_record(90, holder.id, demons[76], RecordStatus::Approved, &mut connection).await; + + assert_eq!( + holder.update_score(&mut connection).await.unwrap(), + (Some(0.0f64), Some(0.0f64)), + "progress record outside the main list of both lists must give a score of zero, not no score" + ); + assert_eq!(scoreless.update_score(&mut connection).await.unwrap(), (None, None)); + + sqlx::query!("DELETE FROM records WHERE id = $1", record) + .execute(&mut *connection) + .await + .unwrap(); + + assert_eq!(holder.update_score(&mut connection).await.unwrap(), (None, None)); + + add_simple_record(100, holder.id, demons[0], RecordStatus::Approved, &mut connection).await; + + let (rated_score, ratedplus_score) = holder.update_score(&mut connection).await.unwrap(); + assert_eq!(rated_score, None, "record on an unrated demon gave a demonlist score"); + assert_ne!(ratedplus_score.unwrap(), 0.0f64); +} + +#[sqlx::test(migrations = "../migrations")] +async fn test_ranking_pagination_continues_into_unranked_players(pool: Pool) { + let (clnt, mut connection) = pointercrate_test::demonlist::setup_rocket(pool).await; + + let verifier = DatabasePlayer::by_name_or_create("stardust1971", &mut connection).await.unwrap(); + let scoring = DatabasePlayer::by_name_or_create("scoring_holder", &mut connection).await.unwrap(); + let zero_a = DatabasePlayer::by_name_or_create("zero_holder_a", &mut connection).await.unwrap(); + let zero_b = DatabasePlayer::by_name_or_create("zero_holder_b", &mut connection).await.unwrap(); + DatabasePlayer::by_name_or_create("scoreless", &mut connection).await.unwrap(); + + let mut demons = Vec::new(); + for position in 1..=76 { + demons.push( + add_demon( + format!("Demon {}", position), + position, + 50, + verifier.id, + verifier.id, + true, + &mut connection, + ) + .await, + ); + } + recompute_rated_positions(&mut connection).await.unwrap(); + + add_simple_record(90, zero_b.id, demons[75], RecordStatus::Approved, &mut connection).await; + add_simple_record(100, scoring.id, demons[0], RecordStatus::Approved, &mut connection).await; + add_simple_record(90, zero_a.id, demons[75], RecordStatus::Approved, &mut connection).await; + recompute_scores(&mut connection).await.unwrap(); + + for list in ["demonlist", "ratedplus"] { + let mut entries = Vec::new(); + let mut after = 0; + + loop { + let (page, links) = clnt + .get(format!("/api/v1/players/ranking/?list={}&limit=1&after={}", list, after)) + .get_pagination_result::() + .await; + + entries.extend(page); + + match link_param(&links, "next", "after") { + Some(next) if entries.len() < 10 => after = next, + _ => break, + } + } + + let names: Vec<_> = entries.iter().map(|entry| entry["name"].as_str().unwrap()).collect(); + let ranks: Vec<_> = entries.iter().map(|entry| entry["rank"].as_i64()).collect(); + + assert_eq!( + names, + vec!["stardust1971", "scoring_holder", "zero_holder_a", "zero_holder_b"], + "unexpected {} ranking order", + list + ); + assert_eq!(ranks, vec![Some(1), Some(2), None, None], "unexpected {} ranks", list); + } +} diff --git a/pointercrate-test/tests/demonlist/player/score.rs b/pointercrate-test/tests/demonlist/player/score.rs index d3f5e6aa..6d9ece52 100644 --- a/pointercrate-test/tests/demonlist/player/score.rs +++ b/pointercrate-test/tests/demonlist/player/score.rs @@ -34,7 +34,11 @@ pub async fn test_score_update_on_record_update(pool: Pool) { .get_success_result() .await; - assert_ne!(player.player.score, 0.0f64, "Adding approved record failed to give player score"); + assert_ne!( + player.player.score.unwrap(), + 0.0f64, + "Adding approved record failed to give player score" + ); clnt.patch( format!("/api/v1/records/{}/", record.id), @@ -52,7 +56,7 @@ pub async fn test_score_update_on_record_update(pool: Pool) { .get_success_result() .await; - assert_eq!(player.player.score, 0.0f64, "Rejecting record failed to remove player score"); + assert_eq!(player.player.score, None, "Rejecting record failed to remove player score"); } #[sqlx::test(migrations = "../migrations")] @@ -70,7 +74,7 @@ pub async fn test_verifications_give_score(pool: Pool) { .get_success_result() .await; - assert_ne!(player.player.score, 0.0f64); + assert_ne!(player.player.score.unwrap(), 0.0f64); } async fn nationality_score(iso_country_code: &str, connection: &mut PgConnection) -> f64 { @@ -171,7 +175,11 @@ pub async fn test_extended_progress_records_give_no_score(pool: Pool) .get_success_result() .await; - assert_eq!(player.player.score, 0.0f64, "Progress record on extended list demon is given score"); + assert_eq!( + player.player.score, + Some(0.0f64), + "Progress record on extended list demon is given score (or incorrectly setting score to null)" + ); } #[sqlx::test(migrations = "../migrations")] @@ -215,7 +223,8 @@ pub async fn test_score_resets_if_last_record_removed(pool: Pool) { .await; assert_ne!( - player.player.score, 0.0f64, + player.player.score.unwrap(), + 0.0f64, "Progress record on final main list demon not giving score" ); @@ -239,7 +248,8 @@ pub async fn test_score_resets_if_last_record_removed(pool: Pool) { .await; assert_eq!( - player.player.score, 0.0f64, + player.player.score, + Some(0.0f64), "Removal of player's last record did not reset their score to 0" ); } diff --git a/pointercrate-test/tests/demonlist/record.rs b/pointercrate-test/tests/demonlist/record.rs index 4ee6b920..106891fe 100644 --- a/pointercrate-test/tests/demonlist/record.rs +++ b/pointercrate-test/tests/demonlist/record.rs @@ -254,7 +254,11 @@ async fn test_record_deletion_updates_player_score(pool: Pool) { .get_success_result() .await; - assert_ne!(player.player.score, 0.0f64, "Adding approved record failed to give player score"); + assert_ne!( + player.player.score.unwrap(), + 0.0f64, + "Adding approved record failed to give player score" + ); clnt.delete(format!("/api/v1/records/{}/", record.id)) .authorize_as(&helper) @@ -269,5 +273,5 @@ async fn test_record_deletion_updates_player_score(pool: Pool) { .get_success_result() .await; - assert_eq!(player.player.score, 0.0f64, "Deleting approved record failed to lower player score"); + assert_eq!(player.player.score, None, "Deleting approved record failed to lower player score"); }