Repository: CTFd/CTFd

Issue:
Develop "Dynamic Templates"
During SwampCTF we had 28 challenges, all dynamically scored. This means we had to input 4 distinct numbers in the right order for all challenges and keep them in sync. Any changes to the decay limit, minimum, or maximum scores had to be changed on all changes.

This was time consuming and error prone. I propose that this plugin offer a choice for admins to create _dynamic templates_. These templates would be created once in a separate UI and then be able to be selected during challenge creation via a drop down menu. This drop down menu could contain things like

- Standard Challenge
- Easy Challenge
- Medium Challenge
- Hard Challenge
- Custom

Having a centralized template creation menu would greatly simplify changes to the scoring after testing or during production.


Discussion:
Perhaps having a function allowing for that much control was a bad idea. 

I will confess I don't actively use this plugin so I'm not the best person to give input on it. From your experience with it would you say you would prefer a linear decay rate `500 - (1 * solve_count)` for all challenges or do you think the flexibility is useful? 

Shouldn't be too hard to make pre-made templates inside of the challenge creation modal though. 

Resolving patch:
diff --git a/CTFd/plugins/dynamic_challenges/__init__.py b/CTFd/plugins/dynamic_challenges/__init__.py
--- a/CTFd/plugins/dynamic_challenges/__init__.py
+++ b/CTFd/plugins/dynamic_challenges/__init__.py
@@ -1,14 +1,10 @@
-from __future__ import division  # Use floating point for math calculations
-
-import math
-
 from flask import Blueprint
 
-from CTFd.models import Challenges, Solves, db
+from CTFd.models import Challenges, db
 from CTFd.plugins import register_plugin_assets_directory
 from CTFd.plugins.challenges import CHALLENGE_CLASSES, BaseChallenge
+from CTFd.plugins.dynamic_challenges.decay import DECAY_FUNCTIONS, logarithmic
 from CTFd.plugins.migrations import upgrade
-from CTFd.utils.modes import get_model
 
 
 class DynamicChallenge(Challenges):
@@ -19,6 +15,7 @@ class DynamicChallenge(Challenges):
     initial = db.Column(db.Integer, default=0)
     minimum = db.Column(db.Integer, default=0)
     decay = db.Column(db.Integer, default=0)
+    function = db.Column(db.String(32), default="logarithmic")
 
     def __init__(self, *args, **kwargs):
         super(DynamicChallenge, self).__init__(**kwargs)
@@ -51,40 +48,8 @@ class DynamicValueChallenge(BaseChallenge):
 
     @classmethod
     def calculate_value(cls, challenge):
-        Model = get_model()
-
-        solve_count = (
-            Solves.query.join(Model, Solves.account_id == Model.id)
-            .filter(
-                Solves.challenge_id == challenge.id,
-                Model.hidden == False,
-                Model.banned == False,
-            )
-            .count()
-        )
-
-        # If the solve count is 0 we shouldn't manipulate the solve count to
-        # let the math update back to normal
-        if solve_count != 0:
-            # We subtract -1 to allow the first solver to get max point value
-            solve_count -= 1
-
-        # Handle situations where admins have entered a 0 decay
-        # This is invalid as it can cause a division by zero
-        if challenge.decay == 0:
-            challenge.decay = 1
-
-        # It is important that this calculation takes into account floats.
-        # Hence this file uses from __future__ import division
-        value = (
-            ((challenge.minimum - challenge.initial) / (challenge.decay ** 2))
-            * (solve_count ** 2)
-        ) + challenge.initial
-
-        value = math.ceil(value)
-
-        if value < challenge.minimum:
-            value = challenge.minimum
+        f = DECAY_FUNCTIONS.get(challenge.function, logarithmic)
+        value = f(challenge)
 
         challenge.value = value
         db.session.commit()
diff --git a/CTFd/plugins/dynamic_challenges/decay.py b/CTFd/plugins/dynamic_challenges/decay.py
new file mode 100644
--- /dev/null
+++ b/CTFd/plugins/dynamic_challenges/decay.py
@@ -0,0 +1,75 @@
+from __future__ import division  # Use floating point for math calculations
+
+import math
+
+from CTFd.models import Solves
+from CTFd.utils.modes import get_model
+
+
+def get_solve_count(challenge):
+    Model = get_model()
+
+    solve_count = (
+        Solves.query.join(Model, Solves.account_id == Model.id)
+        .filter(
+            Solves.challenge_id == challenge.id,
+            Model.hidden == False,
+            Model.banned == False,
+        )
+        .count()
+    )
+    return solve_count
+
+
+def linear(challenge):
+    solve_count = get_solve_count(challenge)
+
+    # If the solve count is 0 we shouldn't manipulate the solve count to
+    # let the math update back to normal
+    if solve_count != 0:
+        # We subtract -1 to allow the first solver to get max point value
+        solve_count -= 1
+
+    value = challenge.initial - (challenge.decay * solve_count)
+
+    value = math.ceil(value)
+
+    if value < challenge.minimum:
+        value = challenge.minimum
+
+    return value
+
+
+def logarithmic(challenge):
+    solve_count = get_solve_count(challenge)
+
+    # If the solve count is 0 we shouldn't manipulate the solve count to
+    # let the math update back to normal
+    if solve_count != 0:
+        # We subtract -1 to allow the first solver to get max point value
+        solve_count -= 1
+
+    # Handle situations where admins have entered a 0 decay
+    # This is invalid as it can cause a division by zero
+    if challenge.decay == 0:
+        challenge.decay = 1
+
+    # It is important that this calculation takes into account floats.
+    # Hence this file uses from __future__ import division
+    value = (
+        ((challenge.minimum - challenge.initial) / (challenge.decay ** 2))
+        * (solve_count ** 2)
+    ) + challenge.initial
+
+    value = math.ceil(value)
+
+    if value < challenge.minimum:
+        value = challenge.minimum
+
+    return value
+
+
+DECAY_FUNCTIONS = {
+    "linear": linear,
+    "logarithmic": logarithmic,
+}
diff --git a/CTFd/plugins/dynamic_challenges/migrations/eb68f277ab61_add_func_column_to_dynamic_challenges.py b/CTFd/plugins/dynamic_challenges/migrations/eb68f277ab61_add_func_column_to_dynamic_challenges.py
new file mode 100644
--- /dev/null
+++ b/CTFd/plugins/dynamic_challenges/migrations/eb68f277ab61_add_func_column_to_dynamic_challenges.py
@@ -0,0 +1,28 @@
+"""Add func column to dynamic_challenges
+
+Revision ID: eb68f277ab61
+Revises: 9e6f6578ca84
+Create Date: 2023-06-28 17:37:48.244827
+
+"""
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = "eb68f277ab61"
+down_revision = "b37fb68807ea"
+branch_labels = None
+depends_on = None
+
+
+def upgrade(op=None):
+    op.add_column(
+        "dynamic_challenge", sa.Column("function", sa.String(length=32), nullable=True)
+    )
+    conn = op.get_bind()
+    conn.execute(
+        "UPDATE dynamic_challenge SET function = 'logarithmic' WHERE function IS NULL"
+    )
+
+
+def downgrade(op=None):
+    op.drop_column("dynamic_challenge", "function")


Test patch:
diff --git a/tests/challenges/test_dynamic.py b/tests/challenges/test_dynamic.py
--- a/tests/challenges/test_dynamic.py
+++ b/tests/challenges/test_dynamic.py
@@ -349,3 +349,54 @@ def test_dynamic_challenges_reset():
         assert DynamicChallenge.query.count() == 0
 
     destroy_ctfd(app)
+
+
+def test_dynamic_challenge_linear_loses_value_properly():
+    app = create_ctfd(enable_plugins=True)
+    with app.app_context():
+        register_user(app)
+        client = login_as_user(app, name="admin", password="password")
+
+        challenge_data = {
+            "name": "name",
+            "category": "category",
+            "description": "description",
+            "function": "linear",
+            "initial": 100,
+            "decay": 5,
+            "minimum": 1,
+            "state": "visible",
+            "type": "dynamic",
+        }
+
+        r = client.post("/api/v1/challenges", json=challenge_data)
+        assert r.get_json().get("data")["id"] == 1
+
+        gen_flag(app.db, challenge_id=1, content="flag")
+
+        for i, team_id in enumerate(range(2, 26)):
+            name = "user{}".format(team_id)
+            email = "user{}@examplectf.com".format(team_id)
+            # We need to bypass rate-limiting so gen_user instead of register_user
+            user = gen_user(app.db, name=name, email=email)
+            user_id = user.id
+
+            with app.test_client() as client:
+                # We need to bypass rate-limiting so creating a fake user instead of logging in
+                with client.session_transaction() as sess:
+                    sess["id"] = user_id
+                    sess["nonce"] = "fake-nonce"
+                    sess["hash"] = hmac(user.password)
+
+                data = {"submission": "flag", "challenge_id": 1}
+                r = client.post("/api/v1/challenges/attempt", json=data)
+                resp = r.get_json()["data"]
+                assert resp["status"] == "correct"
+
+                chal = DynamicChallenge.query.filter_by(id=1).first()
+
+                if i >= 20:
+                    assert chal.value == chal.minimum
+                else:
+                    assert chal.value == (chal.initial - (i * 5))
+    destroy_ctfd(app)