From 1055eaa0eeaf47055a02099f9d7df3c018b46cc0 Mon Sep 17 00:00:00 2001
From: Benedikt Magnus <magnus@magnuscraft.de>
Date: Fri, 12 Nov 2021 23:43:24 +0100
Subject: [PATCH] Full assignment algorithm

---
 scripts/wichtelbot/classes/relationship.ts    |  36 ++
 scripts/wichtelbot/database.ts                |  46 ++-
 .../message/modules/assignmentModule.ts       | 334 ++++++++++++++----
 3 files changed, 345 insertions(+), 71 deletions(-)
 create mode 100644 scripts/wichtelbot/classes/relationship.ts

diff --git a/scripts/wichtelbot/classes/relationship.ts b/scripts/wichtelbot/classes/relationship.ts
new file mode 100644
index 0000000..35b346e
--- /dev/null
+++ b/scripts/wichtelbot/classes/relationship.ts
@@ -0,0 +1,36 @@
+import Config from "../../utility/config";
+
+export interface RelationshipData
+{
+    giverId: string;
+    takerId: string;
+}
+
+function instanceOfRelationship (object: any): object is Relationship
+{
+    const potentialRelationship = object as Relationship;
+
+    return (potentialRelationship.wichtelEvent !== undefined);
+}
+
+export class Relationship implements RelationshipData
+{
+    public wichtelEvent: string;
+    public giverId: string;
+    public takerId: string;
+
+    constructor (relationship: Relationship|RelationshipData)
+    {
+        if (instanceOfRelationship(relationship))
+        {
+            this.wichtelEvent = relationship.wichtelEvent;
+        }
+        else
+        {
+            this.wichtelEvent = Config.main.currentEvent.name;
+        }
+
+        this.giverId = relationship.giverId;
+        this.takerId = relationship.takerId;
+    }
+}
diff --git a/scripts/wichtelbot/database.ts b/scripts/wichtelbot/database.ts
index e12849b..0d9eca4 100644
--- a/scripts/wichtelbot/database.ts
+++ b/scripts/wichtelbot/database.ts
@@ -1,6 +1,7 @@
 import * as fs from 'fs';
-
 import Contact, { ContactCoreData, ContactData } from './classes/contact';
+import { Relationship, RelationshipData } from './classes/relationship';
+import Config from '../utility/config';
 import ContactType from './types/contactType';
 import { Exclusion } from './classes/exclusion';
 import { InformationData } from './classes/information';
@@ -482,6 +483,49 @@ export default class Database
 
         return exclusions;
     }
+
+    public getRelationships (): Relationship[]
+    {
+        // TODO: Could these kind of statements be abstracted as "get all and return as this class instances"?
+
+        const statement = this.mainDatabase.prepare(
+            'SELECT * FROM relationship'
+        );
+
+        const rawRelationships = statement.all() as Relationship[];
+
+        const relationships: Relationship[] = [];
+
+        for (const rawRelationship of rawRelationships)
+        {
+            const relationship = new Relationship(rawRelationship);
+
+            relationships.push(relationship);
+        }
+
+        return relationships;
+    }
+
+    public saveRelationships (relationships: RelationshipData[]): void
+    {
+        const statement = this.mainDatabase.prepare(
+            `INSERT INTO
+                relationship (wichtelEvent, giverId, takerId)
+            VALUES
+                (:wichtelEvent, :giverId, :takerId)`
+        );
+
+        for (const relationship of relationships)
+        {
+            const parameters = {
+                wichtelEvent: Config.main.currentEvent.name,
+                giverId: relationship.giverId,
+                takerId: relationship.takerId,
+            };
+
+            statement.run(parameters);
+        }
+    }
 }
 
 /*
diff --git a/scripts/wichtelbot/message/modules/assignmentModule.ts b/scripts/wichtelbot/message/modules/assignmentModule.ts
index 58db536..d016816 100644
--- a/scripts/wichtelbot/message/modules/assignmentModule.ts
+++ b/scripts/wichtelbot/message/modules/assignmentModule.ts
@@ -3,16 +3,29 @@ import { Exclusion } from '../../classes/exclusion';
 import { ExclusionReason } from '../../types/exclusionReason';
 import GiftType from '../../types/giftType';
 import Member from '../../classes/member';
+import { RelationshipData } from '../../classes/relationship';
 
-interface AssignmentCandidate
+interface Candidate
 {
-    /** The member that could possibly become the Wichtel child of this assignment. */
-    wichtelChild: Member;
-    /** The score for the Wichtel child. The higher the better. */
+    /** The member that could possibly become the taker Wichtel of the assignment. */
+    taker: Member;
+    /** The score for the taker Wichtel. The higher the better. */
     score: number;
 }
 
-type AssignmentCandidateMap = Map<Member, Set<AssignmentCandidate>>;
+interface Pairing
+{
+    /** The future giver Wichtel. */
+    giver: Member;
+    /** The list of possible candidates for this assignment. */
+    candidates: Candidate[];
+}
+
+interface Assignment
+{
+    giver: Member;
+    taker: Member;
+}
 
 export class AssignmentModule
 {
@@ -27,142 +40,190 @@ export class AssignmentModule
      * Run the assignment.
      * @returns True if successful, false otherwise.
      */
-    public assign (): boolean
+    public assign (): boolean // TODO: Return the reason for failure.
     {
         const members = this.database.getWaitingMember();
-        const exclusions = this.database.getUserExclusions();
-        const wishExclusions = this.extractWishExclusions(exclusions);
-        const wichtelFromThePastExclusions = this.extractWichtelFromThePastExclusions(exclusions);
 
-        const memberToCandidates = this.createAssignmentCandidateMap(members);
+        if (members.length === 0)
+        {
+            return false;
+        }
+
+        const pairings = this.assemblePairings(members);
 
-        for (const [member, candidates] of memberToCandidates)
+        const preparationResult = this.prepareCandidates(pairings);
+
+        if (!preparationResult)
         {
-            for (const candidate of candidates.values())
-            {
-                if (this.isGiftTypeIncompatible(member, candidate.wichtelChild)
-                    || this.isInternationalIncompatible(member, candidate.wichtelChild)
-                    || this.isExcluded(member, candidate.wichtelChild, wishExclusions))
-                {
-                    candidates.delete(candidate);
-                }
+            return false;
+        }
 
-                candidate.score = this.calculateScore(member, candidate.wichtelChild, wichtelFromThePastExclusions);
-            }
+        const assignments = this.assignCandidates(pairings);
+
+        if (assignments.length === 0)
+        {
+            return false;
+        }
 
-            if (candidates.size === 0)
+        const relationships: RelationshipData[] = assignments.map(
+            assignment =>
             {
-                // Empty candidates list means no valid solution for the assignment:
-                return false;
+                return {
+                    giverId: assignment.giver.id,
+                    takerId: assignment.taker.id,
+                };
             }
+        );
 
-            this.sortCandidatesByScore(candidates);
-        }
+        this.database.saveRelationships(relationships);
 
         return true;
     }
 
-    private extractWishExclusions (exclusions: Exclusion[]): Exclusion[]
-    {
-        return exclusions.filter(exclusion => exclusion.reason === ExclusionReason.Wish);
-    }
-
-    private extractWichtelFromThePastExclusions (exclusions: Exclusion[]): Exclusion[]
-    {
-        return exclusions.filter(exclusion => exclusion.reason === ExclusionReason.WichtelFromThePast);
-    }
-
-    private createAssignmentCandidateMap (members: Member[]): AssignmentCandidateMap
+    private assemblePairings (members: Member[]): Pairing[]
     {
-        const assignmentScores: AssignmentCandidateMap = new Map();
+        const pairings: Pairing[] = [];
 
         for (const member of members)
         {
-            const scores: Set<AssignmentCandidate> = new Set();
+            const candidates: Candidate[] = [];
 
-            for (const wichtelChild of members)
+            for (const takerWichtel of members)
             {
-                if (member === wichtelChild)
+                if (member === takerWichtel)
                 {
                     continue;
                 }
 
-                scores.add(
+                candidates.push(
                     {
-                        wichtelChild: wichtelChild,
+                        taker: takerWichtel,
                         score: 0,
                     }
                 );
             }
 
-            assignmentScores.set(member, scores);
+            pairings.push(
+                {
+                    giver: member,
+                    candidates: candidates,
+                }
+            );
         }
 
-        return assignmentScores;
+        return pairings;
     }
 
-    private isGiftTypeIncompatible (member: Member, wichtelChild: Member): boolean
+    /**
+     * Prepare the canidates assignment map by removing the incompatible members and calculating the scores/rank.
+     * @returns True if successful, false otherwise.
+     */
+    private prepareCandidates (pairings: Pairing[]): boolean
     {
-        if (member.information.giftTypeAsGiver === wichtelChild.information.giftTypeAsTaker)
+        const exclusions = this.database.getUserExclusions();
+        const wishExclusions = this.extractWishExclusions(exclusions);
+        const wichtelFromThePastExclusions = this.extractWichtelFromThePastExclusions(exclusions);
+
+        for (const pairing of pairings)
+        {
+            for (let i = pairing.candidates.length - 1; i >= 0; i--)
+            {
+                const candidate = pairing.candidates[i];
+
+                if (this.isGiftTypeIncompatible(pairing.giver, candidate.taker)
+                    || this.isInternationalIncompatible(pairing.giver, candidate.taker)
+                    || this.isExcluded(pairing.giver, candidate.taker, wishExclusions))
+                {
+                    pairing.candidates.splice(i, 1);
+                }
+
+                candidate.score = this.calculateScore(pairing.giver, candidate.taker, wichtelFromThePastExclusions);
+            }
+
+            if (pairing.candidates.length === 0)
+            {
+                // Empty candidates list means no valid solution for the assignment:
+                return false;
+            }
+
+            this.sortCandidatesByScore(pairing.candidates);
+        }
+
+        return true;
+    }
+
+    private extractWishExclusions (exclusions: Exclusion[]): Exclusion[]
+    {
+        return exclusions.filter(exclusion => exclusion.reason === ExclusionReason.Wish);
+    }
+
+    private extractWichtelFromThePastExclusions (exclusions: Exclusion[]): Exclusion[]
+    {
+        return exclusions.filter(exclusion => exclusion.reason === ExclusionReason.WichtelFromThePast);
+    }
+
+    private isGiftTypeIncompatible (giverWichtel: Member, takerWichtel: Member): boolean
+    {
+        if (giverWichtel.information.giftTypeAsGiver === takerWichtel.information.giftTypeAsTaker)
         {
             return false;
         }
 
-        const result = (member.information.giftTypeAsGiver != GiftType.All) && (wichtelChild.information.giftTypeAsTaker != GiftType.All);
+        const result = (giverWichtel.information.giftTypeAsGiver != GiftType.All)
+            && (takerWichtel.information.giftTypeAsTaker != GiftType.All);
 
         return result;
     }
 
-    private isInternationalIncompatible (member: Member, wichtelChild: Member): boolean
+    private isInternationalIncompatible (giverWichtel: Member, takerWichtel: Member): boolean
     {
-        if (member.information.internationalAllowed)
+        if (giverWichtel.information.internationalAllowed)
         {
             return false;
         }
 
-        if ((member.information.giftTypeAsGiver != GiftType.Analogue) && (wichtelChild.information.giftTypeAsTaker != GiftType.Analogue))
+        if ((giverWichtel.information.giftTypeAsGiver != GiftType.Analogue)
+            && (takerWichtel.information.giftTypeAsTaker != GiftType.Analogue))
         {
             return false;
         }
 
         // NOTE: If one or both have "all" as gift type, they are compatible but should have a lower score if from different countries.
 
-        const result = (member.information.country != wichtelChild.information.country);
+        const result = (giverWichtel.information.country != takerWichtel.information.country);
 
         return result;
     }
 
-    private isExcluded (member: Member, wichtelChild: Member, exclusions: Exclusion[]): boolean
+    private isExcluded (giverWichtel: Member, takerWichtel: Member, exclusions: Exclusion[]): boolean
     {
         for (const exclusion of exclusions)
         {
-            if ((exclusion.giverId === member.id) && (exclusion.takerId === wichtelChild.id))
+            if ((exclusion.giverId === giverWichtel.id) && (exclusion.takerId === takerWichtel.id))
             {
                 return true;
             }
-
-            // TODO: Should this be optimised?
         }
 
         return false;
     }
 
-    private calculateScore (member: Member, wichtelChild: Member, exclusions: Exclusion[]): number
+    private calculateScore (giverWichtel: Member, takerWichtel: Member, exclusions: Exclusion[]): number
     {
         let score = 0;
 
-        if (member.information.giftTypeAsGiver === wichtelChild.information.giftTypeAsTaker)
+        if (giverWichtel.information.giftTypeAsGiver === takerWichtel.information.giftTypeAsTaker)
         {
             score += 4;
         }
 
-        if (member.information.country === wichtelChild.information.country)
+        if (giverWichtel.information.country === takerWichtel.information.country)
         {
             score += 2;
         }
-        else if (!member.information.internationalAllowed)
+        else if (!giverWichtel.information.internationalAllowed)
         {
-            if ((member.information.giftTypeAsGiver == GiftType.All) && (wichtelChild.information.giftTypeAsTaker == GiftType.All))
+            if ((giverWichtel.information.giftTypeAsGiver == GiftType.All) && (takerWichtel.information.giftTypeAsTaker == GiftType.All))
             {
                 // Both have "all" but the analogue way is blocked, thus a decrease:
                 score -= 3;
@@ -171,7 +232,7 @@ export class AssignmentModule
 
         for (const exclusion of exclusions)
         {
-            if ((exclusion.giverId === member.id) && (exclusion.takerId === wichtelChild.id))
+            if ((exclusion.giverId === giverWichtel.id) && (exclusion.takerId === takerWichtel.id))
             {
                 score -= 8;
                 break;
@@ -181,23 +242,156 @@ export class AssignmentModule
         return score;
     }
 
-    private sortCandidatesByScore (candidates: Set<AssignmentCandidate>): void
+    private sortCandidatesByScore (candidates: Candidate[]): void
     {
-        const candidatesArray = Array.from(candidates);
-
-        candidatesArray.sort(
+        candidates.sort(
             (a, b) =>
             {
                 return a.score - b.score;
             }
         );
+    }
 
-        candidates.clear();
+    private assignCandidates (pairings: Pairing[]): Assignment[]
+    {
+        const priorities: Set<Member> = new Set();
 
-        // NOTE: Sets save the order of the elements as they are inserted:
-        for (const candidate of candidatesArray)
+        while (priorities.size < pairings.length)
         {
-            candidates.add(candidate);
+            const remaining = this.clonePairings(pairings);
+            this.sortPairings(remaining, priorities);
+
+            const completed: Assignment[] = [];
+
+            while (true)
+            {
+                const pairing = remaining.shift();
+
+                if (pairing === undefined)
+                {
+                    // All pairings have been assigned.
+                    break;
+                }
+
+                if (pairing.candidates.length == 0)
+                {
+                    // If there are no candidates left, we have an incomplete solution.
+                    // In this case, we add the member to the priority list and rerun the assignment.
+
+                    if (priorities.has(pairing.giver))
+                    {
+                        // If the member is already in the priority list, there is no possible solution with this algorithm:
+                        return [];
+                    }
+                    else
+                    {
+                        priorities.add(pairing.giver);
+                    }
+
+                    break;
+                }
+
+                const [firstCandidate] = pairing.candidates;
+
+                const assignment: Assignment = {
+                    giver: pairing.giver,
+                    taker: firstCandidate.taker,
+                };
+
+                completed.push(assignment);
+
+                for (const pairing of remaining)
+                {
+                    for (let i = pairing.candidates.length - 1; i >= 0; i--)
+                    {
+                        const candidate = pairing.candidates[i];
+
+                        // Remove the candidate from all other remaining pairings for it to not be assigned twice and
+                        // the giver from the taker's candidates to prevent a one-to-one circular assignment:
+                        if ((candidate.taker === assignment.taker)
+                            || ((pairing.giver === assignment.taker) && (candidate.taker === assignment.giver)))
+                        {
+                            pairing.candidates.splice(i, 1);
+                        }
+                    }
+                }
+
+                // Sort again because the assignment changed all pairing scores:
+                this.sortPairings(remaining, priorities);
+            }
+
+            if (completed.length === pairings.length)
+            {
+                // Found a solution!
+
+                return completed;
+            }
         }
+
+        // If all members are prioritised, there could no possible solution be found:
+        return [];
+    }
+
+    private clonePairings (pairings: Pairing[]): Pairing[]
+    {
+        const clones: Pairing[] = [];
+
+        for (const pairing of pairings)
+        {
+            const clone: Pairing = {
+                giver: pairing.giver,
+                candidates: [...pairing.candidates],
+            };
+
+            clones.push(clone);
+        }
+
+        return clones;
+    }
+
+    private sortPairings (pairing: Pairing[], priorities: Set<Member>): void
+    {
+        pairing.sort(
+            (pairingA: Pairing, pairingB: Pairing): number =>
+            {
+                const aIsPriority = priorities.has(pairingA.giver);
+                const bIsPriority = priorities.has(pairingB.giver);
+
+                if (aIsPriority || bIsPriority)
+                {
+                    if (aIsPriority && bIsPriority)
+                    {
+                        return 0;
+                    }
+                    else if (aIsPriority)
+                    {
+                        return -1;
+                    }
+                    else
+                    {
+                        return 1;
+                    }
+                }
+
+                // The value of the chain is determined by the accumulated weighting of all the wichtel plus their total number.
+                // The higher the value, the less important the user is for the selection.
+                // This sets a balance between "find the most valuable combinations", "make sure everyone is served" and "try to avoid
+                // making too poor assignments".
+                const accumulateScores = (totalScore: number, candidate: Candidate): number => totalScore + candidate.score;
+
+                const totalScoreA = pairingA.candidates.reduce(accumulateScores, 0) + pairingA.candidates.length;
+                const totalScoreB = pairingB.candidates.reduce(accumulateScores, 0) + pairingB.candidates.length;
+
+                let result = totalScoreA - totalScoreB;
+
+                // If both scores are the same, the number of candidates is used as a tie breaker:
+                if (result === 0)
+                {
+                    result = pairingA.candidates.length - pairingB.candidates.length;
+                }
+
+                return result;
+            }
+        );
     }
 }
-- 
GitLab