diff --git a/src/model/match.py b/src/model/match.py index e884725..9843196 100644 --- a/src/model/match.py +++ b/src/model/match.py @@ -37,6 +37,7 @@ from threading import RLock from time import time +from nussschale.nussschale import nconfig from model.multideck import MultiDeck from nussschale.util.locks import mutex, named_mutex @@ -51,6 +52,7 @@ class Match: Class Attributes: frozen (bool): Whether matches are currently frozen, i.e. whether their state transitions are disabled. + skip_role (str): Which users are allowed to skip the current phase. """ # The minimum amount of players for a match @@ -94,6 +96,13 @@ class Match: # Whether matches are currently frozen frozen = False + # Which users are allowed to skip the phase + skip_role = "owner" + + # Wild card data + wild_card_count = 0 + total_cards = 0 + @classmethod @named_mutex("_pool_lock") def get_by_id(cls, id): @@ -228,6 +237,9 @@ def __init__(self): # The chat of this match, tuples with type/message self._chat = [("SYSTEM", "Match was created.")] + # Which users are allowed to skip the phase + self.skip_role = nconfig().get("skip-role", "owner") + def put_in_pool(self): """Puts this match into the match pool.""" Match.add_match(self.id, self) @@ -305,15 +317,17 @@ def get_seconds_to_next_phase(self): return int(self._timer - time()) @mutex - def user_can_skip_phase(self, part): - """Determine whether a user can skip to the next phase. + def user_can_skip_phase(self, participant): + """Determine whether a user can skip to the next phase Args: - obj: The participant in question. + participant: The participant that made the request + to skip the phase Returns: bool: Whether the given participant can skip to the next phase """ + # The match must not be ending if self._state == "ENDING": return False @@ -322,23 +336,47 @@ def user_can_skip_phase(self, part): if len(self._participants) < Match._MINIMUM_PLAYERS: return False - # Currently, only the owner can skip to the next phase - return self.get_owner_nick() == part.nickname + if self.skip_role == "picker": + if self._state == "CHOOSING": + return participant.picking + else: + return True + elif self.skip_role == "anyone": + return True + elif self.skip_role == "majority": + participant.wants_skip = True + skip_count = 0 + for part in self.get_participants(False): + if part.wants_skip: + skip_count += 1 + majority = int(len(list(self.get_participants(False))) / 2) + if skip_count > majority: + for part in self.get_participants(False): + part.wants_skip = False + return True + else: + self._chat.append(("SYSTEM", + "" + participant.nickname + + " wants to skip the phase. " + + str(majority - skip_count + 1) + + " request(s) left to reach majority.")) + return False + else: + return self.get_owner_nick() == participant.nickname @mutex - def skip_to_next_phase(self): - """Skips directly to the next phase. + def skip_to_next_phase(self, nick): + """Skips directly to the next phase - Contract: - This method locks the match's instance lock. + Args: + nick (str): The nickname of the user who is skipping the phase. """ # One second difference to prevent edge cases of timer change close to # game state transitions. if self._timer - time() > 1: self._timer = time() self._chat.append(("SYSTEM", - "" + self.get_owner_nick() - + " skipped to the next phase.")) + "" + nick + " skipped to next phase")) def _set_state(self, state): """Updates the state for this match. @@ -685,6 +723,12 @@ def create_deck(self, data): # Create multidecks for type in self._deck: self._multidecks[type] = MultiDeck[Card, int](self._deck[type]) + self.total_cards += len(self._deck[type]) + + wilds = [Card(card_id_counter + 1 + i, "WILD", "Wild card") + for i in range(self.wild_card_count)] + self._multidecks["WILD"] = MultiDeck[Card, int](wilds) + self.total_cards += self.wild_card_count return True, "OK" @@ -924,8 +968,12 @@ def _replenish_hands(self): The caller ensures that the match's lock is held when calling this method. """ + card_count = self.total_cards + for part in self.get_participants(False): + card_count -= part.get_card_count() for part in self.get_participants(False): - part.replenish_hand(self._multidecks) + drawn = part.replenish_hand(self._multidecks, card_count) + card_count -= drawn def _select_match_card(self): """Selects a random statement card for this match. diff --git a/src/model/multideck.py b/src/model/multideck.py index c3ee747..b1ae432 100644 --- a/src/model/multideck.py +++ b/src/model/multideck.py @@ -2,6 +2,7 @@ MIT License Copyright (c) 2017-2018 LordKorea +Copyright (c) 2018 Arc676/Alessandro Vinciguerra Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in @@ -25,7 +26,7 @@ requested. Thus the multideck lock can not be part of any deadlock. """ -from random import shuffle +from random import shuffle, randint from threading import RLock from typing import Generic, List, Optional, Set, TypeVar, cast @@ -39,6 +40,39 @@ class MultiDeck(Generic[T, U]): """A (refilling) deck used to make selection seem more 'random'.""" + def pick_from_queue(self, ptr, banned_ids: Set[U]) -> Optional[T]: + """Pick a card from the queue + + Args: + ptr: The index from which to start looking for viable cards. + banned_ids: A set of IDs that may not be chosen. + + Returns: + A tuple containing the selected card and the new value of ptr. The + card will be None if the request can't be fulfilled. + """ + while ptr < len(self._queue): + obj = self._queue[ptr] + if MultiDeck._id_of(obj) not in banned_ids: + self._contained.remove(MultiDeck._id_of(obj)) + del self._queue[ptr] + return obj, ptr + ptr += 1 + return None, ptr + + def refill_queue(self) -> None: + """Refill the card queue + """ + pool = [] + for obj in self._backing: + if MultiDeck._id_of(obj) not in self._contained: + pool.append(obj) + shuffle(pool) + for obj in pool: + self._contained.add(MultiDeck._id_of(obj)) + self._queue.append(obj) + + def __init__(self, deck: List[T]) -> None: """Constructor. @@ -50,6 +84,7 @@ def __init__(self, deck: List[T]) -> None: self._backing = deck # type: List[T] self._queue = [] # type: List[T] self._contained = set() # type: Set[U] + self.refill_queue() @staticmethod def _id_of(o: T) -> U: @@ -68,11 +103,17 @@ def _id_of(o: T) -> U: return cast(U, id) @mutex - def request(self, banned_ids: Set[U]) -> Optional[T]: - """Requests a card from the multideck. + def request(self, banned_ids: Set[U], wilds = None, cards_left = 0, + banned_wilds: Set[U] = None) -> Optional[T]: + """Requests a card from the multideck. The last three arguments are + optional and should be omitted when requesting a card from the wild + deck. Args: banned_ids: A set of IDs that may not be chosen. + wilds: The multideck with the wild cards. Defaults to None. + cards_left: The number of cards remaining to be drawn. + banned_wilds: A set of IDs of wild cards that are aleady drawn. Returns: The object that was selected. This might be None if the @@ -81,39 +122,26 @@ def request(self, banned_ids: Set[U]) -> Optional[T]: Contract: This method locks the deck's lock. """ + if wilds is not None: + if randint(1, cards_left) <= len(wilds._queue): + return wilds.request(banned_wilds) ptr = 0 # Try to find a viable object - while ptr < len(self._queue): - obj = self._queue[ptr] - if MultiDeck._id_of(obj) not in banned_ids: - self._contained.remove(MultiDeck._id_of(obj)) - del self._queue[ptr] - return obj - ptr += 1 + card, ptr = self.pick_from_queue(ptr, banned_ids) + if card is not None: + return card # No object in the queue works... Need to refill queue! # Note: For backing decks that are only slightly bigger than the # set of banned ids this might result in less than optimal randomness. # However in practice, deck size does exceed the number of banned ids # by at least factor 2. - pool = [] - for obj in self._backing: - if MultiDeck._id_of(obj) not in self._contained: - pool.append(obj) - shuffle(pool) - for obj in pool: - self._contained.add(MultiDeck._id_of(obj)) - self._queue.append(obj) + self.refill_queue() # Try to find a viable object again - while ptr < len(self._queue): - obj = self._queue[ptr] - if MultiDeck._id_of(obj) not in banned_ids: - self._contained.remove(MultiDeck._id_of(obj)) - del self._queue[ptr] - return obj - ptr += 1 + card, ptr = self.pick_from_queue(ptr, banned_ids) - # Still no object found: Failure, as the queue is already maximal. - return None + # If no object found: Failure, as the queue is already maximal. + # Otherwise, return the given card + return card diff --git a/src/model/participant.py b/src/model/participant.py index 0346a34..5cbddc4 100644 --- a/src/model/participant.py +++ b/src/model/participant.py @@ -2,6 +2,7 @@ MIT License Copyright (c) 2017-2018 LordKorea +Copyright (c) 2018 Arc676/Alessandro Vinciguerra Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in @@ -30,6 +31,7 @@ from threading import RLock from time import time from typing import Dict, List, Mapping, Optional, Set, TYPE_CHECKING, Union +from math import floor, ceil from nussschale.util.locks import mutex @@ -52,6 +54,7 @@ class Participant: occurring. order: The order key of the particpant, used for shuffling. spectator: Whether the participant is a spectator. + wants_skip: Whether the participant wants to skip the phase. """ # The number of hand cards per type @@ -94,6 +97,9 @@ def __init__(self, id: str, nickname: str) -> None: # participant is part of a match. self.spectator = False + # Whether the particpant wants to skip the phase. + self.wants_skip = False + # The hand of this participant self._hand = OrderedDict() # type: Dict[int, HandCard] self._hand_counter = 1 @@ -161,6 +167,19 @@ def get_hand(self) -> Dict[int, "HandCard"]: assert not self.spectator, "Trying to get hand for spectator" return deepcopy(self._hand) + @mutex + def get_card_count(self) -> int: + """Determines the number of cards in the participant's hand. + + Returns: + The number of cards in the participant's hand. + + Contract: + This method locks the participant's lock. + """ + assert not self.spectator, "Trying to get hand for spectator" + return len(self._hand.items()) + @mutex def choose_count(self) -> int: """Retrieves the number of chosen cards in the hand of this player. @@ -179,20 +198,33 @@ def choose_count(self) -> int: return n @mutex - def replenish_hand(self, mdecks: Mapping[str, "MultiDeck[Card, int]"] - ) -> None: + def replenish_hand(self, mdecks: Mapping[str, "MultiDeck[Card, int]"], + cards_left) -> int: """Replenishes the hand of this participant from the given decks. Args: mdecks: Maps card type to a multideck of the card type. + cards_left: Number of cards remaining to be drawn + + Returns: + The number of cards drawn by the participant. Contract: This method locks the participant's lock. """ assert not self.spectator, "Trying to replenish spectator" + cards_drawn = 0 + + # Find any wild cards already held + banned_wilds = set() + wilds_in_hand = 0 + for hcard in self._hand.values(): + if hcard.card.type == "WILD": + banned_wilds.add(hcard.card.id) + wilds_in_hand += 1 # Replenish for every type - for type in filter(lambda x: x != "STATEMENT", mdecks): + for type in filter(lambda x: x != "STATEMENT" and x != "WILD", mdecks): # Count cards of that type and fetch IDs k_in_hand = 0 ids_banned = set() # type: Set[int] @@ -201,15 +233,25 @@ def replenish_hand(self, mdecks: Mapping[str, "MultiDeck[Card, int]"] ids_banned.add(hcard.card.id) k_in_hand += 1 + # Reduce number of cards to draw if wild cards are in hand + if type == "OBJECT": + k_in_hand += floor(wilds_in_hand / 2) + else: + k_in_hand += ceil(wilds_in_hand / 2) + # Fill hand to limit for i in range(Participant._HAND_CARDS_PER_TYPE - k_in_hand): - pick = mdecks[type].request(ids_banned) + pick = mdecks[type].request(ids_banned, mdecks["WILD"], + cards_left, banned_wilds) if pick is None: break # Can't fulfill the requirement... ids_banned.add(pick.id) self._hand[self._hand_counter] = HandCard(pick) + cards_drawn += 1 self._hand_counter += 1 + return cards_drawn + @mutex def toggle_chosen(self, handid: int, allowance: int) -> None: """Toggles whether the hand card with the given ID is chosen. @@ -247,6 +289,19 @@ def toggle_chosen(self, handid: int, allowance: int) -> None: and other_hcard.chosen >= k): other_hcard.chosen = None + @mutex + def set_card_text(self, handid, text): + """Changes the text on a given card. To be used for wild cards. + + Args: + handid: The ID of the hand card. + text: The new text for the card. + + Contract: + This method locks the participant's lock. + """ + self._hand[handid].card.text = text + @mutex def get_choose_data(self, redacted: bool ) -> List[Optional[Dict[str, Union[bool, str]]]]: diff --git a/src/nussschale/handler.py b/src/nussschale/handler.py index aafbb74..f8661a3 100644 --- a/src/nussschale/handler.py +++ b/src/nussschale/handler.py @@ -2,6 +2,7 @@ MIT License Copyright (c) 2017-2018 LordKorea +Copyright (c) 2018 Arc676/Alessandro Vinciguerra Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in @@ -325,13 +326,16 @@ def _unwrap_param(self, allow_list: bool, param: _RawPOSTParam assert isinstance(param.value, str) return param.value else: - fp = param.file - fp.seek(0, 2) - size = fp.tell() - fp.seek(0) - nlog().log("File upload: '%s', %i bytes" % (param.filename, - size)) - return IOWrapper(param.file, param) + if param.filename == None: + return param.value + else : + fp = param.file + fp.seek(0, 2) + size = fp.tell() + fp.seek(0) + nlog().log("File upload: '%s', %i bytes" % (param.filename, + size)) + return IOWrapper(param.file, param) else: raise ValueError("Unsupported parameter type") diff --git a/src/pages/api.py b/src/pages/api.py index c4678a4..a4764bc 100644 --- a/src/pages/api.py +++ b/src/pages/api.py @@ -170,6 +170,10 @@ def api_choose(ctx: EndpointContext) -> None: except ValueError: raise HTTPException.forbidden(True, "invalid id") + text = ctx.get_param_as("text", str) + if text != "": + part.set_card_text(handid, text) + part.toggle_chosen(handid, match.count_gaps()) # todo: check for wrong id match.check_choosing_done() ctx.json_ok() @@ -200,7 +204,8 @@ def api_cards(ctx: EndpointContext) -> None: if not part.spectator: hand_cards = { "OBJECT": {}, - "VERB": {} + "VERB": {}, + "WILD": {} } # type: Dict[str, Dict] hand = part.get_hand() for id, hcard in hand.items(): @@ -316,7 +321,7 @@ def api_skip(ctx: EndpointContext) -> None: raise HTTPException.forbidden(True, "not authorized to skip phase") # Skip remaining time - match.skip_to_next_phase() + match.skip_to_next_phase(part.nickname) ctx.json_ok() diff --git a/src/pages/match.py b/src/pages/match.py index c9aab54..f0b7831 100644 --- a/src/pages/match.py +++ b/src/pages/match.py @@ -2,6 +2,7 @@ MIT License Copyright (c) 2017-2018 LordKorea +Copyright (c) 2018 Arc676/Alessandro Vinciguerra Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in @@ -124,6 +125,7 @@ def create_match(ctx: EndpointContext) -> None: # Create a new match match = Match() + match.wild_card_count = ctx.get_param_as("wildcards", int) # Create the deck from the upload try: diff --git a/src/res/css/dark/deck.css b/src/res/css/dark/deck.css index 71d77a4..63338f8 100644 --- a/src/res/css/dark/deck.css +++ b/src/res/css/dark/deck.css @@ -36,6 +36,16 @@ color: #441; } +.wild-card { + color: #001; + background-color: #BBB; +} + +.wild-card a, .wild-card a:link, .wild-card a:visited, .wild-card a:hover, + .wild-card a:active { + color: #441; +} + .knob-statement { background-color: #444; } @@ -51,4 +61,4 @@ .grey-card { filter: grayscale(50%); opacity: 0.5; -} \ No newline at end of file +} diff --git a/src/res/css/light/deck.css b/src/res/css/light/deck.css index 4df3bef..012ccd0 100644 --- a/src/res/css/light/deck.css +++ b/src/res/css/light/deck.css @@ -31,6 +31,11 @@ background-color: #DD9; } +.wild-card { + color: #111; + background-color: #DDD; +} + .knob-statement { background-color: #444; } diff --git a/src/res/js/match/match.js b/src/res/js/match/match.js index a0046d8..f795777 100644 --- a/src/res/js/match/match.js +++ b/src/res/js/match/match.js @@ -35,7 +35,8 @@ let externalUpdateAllowed = true let handResolver = new Map([ ["OBJECT", new Map()], - ["VERB", new Map()] + ["VERB", new Map()], + ["WILD", new Map()] ]) let numSelected = 0 let selectedCards = new Map() @@ -131,7 +132,8 @@ function updateCards(data) { let sentHand = new Map([ ["OBJECT", new Map()], - ["VERB", new Map()] + ["VERB", new Map()], + ["WILD", new Map()] ]) if (data.hasOwnProperty("hand")) { for (let type of sentHand.keys()) { @@ -350,10 +352,20 @@ if (!allowChoose) { return } + var data = { + "handId": id, + "text": "" + } + // Ask user for custom card text if the card isn't already selected and + // it's a wild card. + if (handResolver.get("WILD").has(id) && + !handResolver.get("WILD").get(id).hasClass("card-selected")) { + data["text"] = prompt("Enter card text:") + } $.ajax({ method: "POST", url: "/api/choose", - data: {"handId": id}, + data: data, success: () => updateSelection(id), error: (x, e, f) => console.log(`/api/choose error: ${e} ${f}`) }) @@ -371,6 +383,7 @@ let remove = false let card = handResolver.get("OBJECT").get(id) card = card || handResolver.get("VERB").get(id) + card = card || handResolver.get("WILD").get(id) // Remove card choices for (let i = 0; i < 4; i++) { @@ -417,6 +430,13 @@ pickTab("tab-objects") } + /** + * Chooses the wild cards tab. + */ + function chooseWildsTab() { + pickTab("tab-wilds") + } + /** * Sends POST request to skip the remaining time */ @@ -457,6 +477,7 @@ pickTab("tab-actions") $("#tab-actions").click(chooseActionsTab) $("#tab-objects").click(chooseObjectsTab) + $("#tab-wilds").click(chooseWildsTab) $("#skip-button").click(skipTime) $("#toggle-hand-button").click(toggleHand) $("#toggle-chat-button").click(toggleChat) diff --git a/src/res/tpl/dashboard.html b/src/res/tpl/dashboard.html index d242cb7..8695bb2 100644 --- a/src/res/tpl/dashboard.html +++ b/src/res/tpl/dashboard.html @@ -66,6 +66,8 @@
+ Number of blank cards: + Choose a deck... (Maximum file size for decks is 800kB) diff --git a/src/res/tpl/match.html b/src/res/tpl/match.html index d2dcd75..b69008f 100644 --- a/src/res/tpl/match.html +++ b/src/res/tpl/match.html @@ -69,6 +69,9 @@
Objects
+
+ Wild cards +
@@ -77,6 +80,9 @@
No cards on your hand.
+
+
No cards on your hand.
+