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