package com.github.donkirkby.vograbulary.ultraghost; import java.io.Serializable; import java.util.ArrayList; public class Puzzle implements Serializable { private static final long serialVersionUID = -3638349068583271443L; public interface Listener { /** * This is called whenever one of the fields is changed. */ void changed(); /** * This is called when the puzzle is finished. Solution and response * are set. */ void completed(); } public static String NOT_SET = null; public static String NO_SOLUTION = ""; public static float MAX_DELAY = 50; // seconds public static float PENALTY_SECONDS = 5; private String letters; private String solution; private String response; private String hint; private Student owner; private transient WordList wordList; private int minimumWordLength = 4; private String previousWord; private transient ArrayList<Listener> listeners = new ArrayList<Listener>(); private boolean isComplete; private boolean isTimedOut; private boolean isPaused; private float solutionDelay; private float responseDelay; private WordResult cachedResult = WordResult.UNKNOWN; public Puzzle(String letters, Student owner, WordList wordList) { if (letters == null) { throw new IllegalArgumentException("Puzzle letters were null."); } if (owner == null) { throw new IllegalArgumentException("Puzzle owner was null."); } this.letters = letters; this.owner = owner; this.wordList = wordList; } public Puzzle(String letters, Student owner) { this(letters, owner, new DummyWordList()); } /** * A dummy word list that contains all words. * */ private static class DummyWordList extends WordList { @Override public boolean contains(String word) { return true; } } /** * The three letters that must be matched in a valid solution. */ public String getLetters() { return letters; } /** Get the word list used by this puzzle. */ public WordList getWordList() { return wordList; } /** Set the word list used by this puzzle. */ public void setWordList(WordList wordList) { this.wordList = wordList; } /** * Get a display version of the three letters, plus any previous word. */ public String getLettersDisplay() { return previousWord == null ? letters : letters + " after " + previousWord; } /** * A solution to the puzzle. If it matches the three letters, then it's * valid. Null means it hasn't been set yet, and empty string means it * has been skipped. */ public String getSolution() { return solution; } public void setSolution(String solution) { this.solution = solution; onChanged(); if ( ! getResult().isValidSolution()) { adjustScore(PENALTY_SECONDS); } } /** * Raise the changed event to any listeners. */ private void onChanged() { cachedResult = WordResult.UNKNOWN; getResult(); boolean isJustCompleted = isComplete ? false : (isComplete = isTimedOut || cachedResult.isCompleted()); for (Listener listener : listeners) { listener.changed(); if (isJustCompleted) { listener.completed(); } } } /** * Another solution that tries to improve on the original solution by being * shorter or the same length and earlier in the dictionary. Null means * it hasn't been set yet, and empty string means that no response is * wanted. */ public String getResponse() { return response; } public void setResponse(String response) { this.response = response; onChanged(); if ( ! isComplete) { adjustScore(PENALTY_SECONDS); } } /** * Another solution that could have been used, if any exists. */ public String getHint() { return hint; } public void setHint(String hint) { this.hint = hint; onChanged(); } /** * The result of this puzzle, including the change in score. It will be * UKNOWN until a solution and response have been entered. */ public WordResult getResult() { //stopJesting if (cachedResult != WordResult.UNKNOWN) { return cachedResult; } //resumeJesting if (solution == NOT_SET) { return WordResult.UNKNOWN; } if (response == NOT_SET) { return cachedResult = checkSolution(); } return cachedResult = checkResponse(); } public String getResultDisplay() { WordResult result = getResult(); String resultText = result == WordResult.UNKNOWN ? "" : result.toString() + " "; int potentialScore = getPotentialScore(); int score; if (isCompleted()) { score = getScore(); } else if ( ! result.isValidSolution()) { score = potentialScore; } else if (solution == NO_SOLUTION) { score = getScore(WordResult.WORD_FOUND); } else { score = getScore(WordResult.SHORTER); } String scoreText = Integer.toString(score); if (potentialScore != score) { scoreText += " of " + potentialScore; } if (isCompleted()) { if (isTimedOut) { if (resultText.length() != 0) { resultText += "and "; } resultText += "out of time "; } resultText += "(" + scoreText + ")"; } else { resultText += scoreText; } return resultText; } /** * Compare the solution and response. * @return the result of comparing the two solutions */ private WordResult checkResponse() { boolean isSkipped = solution == null || solution.length() == 0; if (response.length() == 0) { return isSkipped ? WordResult.SKIP_NOT_IMPROVED : WordResult.NOT_IMPROVED; } String challengeUpper = response.toUpperCase(); if ( ! wordList.contains(challengeUpper)) { return isSkipped ? WordResult.IMPROVED_SKIP_NOT_A_WORD : WordResult.IMPROVEMENT_NOT_A_WORD; } if ( ! isMatch(challengeUpper)) { return isSkipped ? WordResult.IMPROVED_SKIP_NOT_A_MATCH : WordResult.IMPROVEMENT_NOT_A_MATCH; } if (challengeUpper.length() < getMinimumWordLength()) { return isSkipped ? WordResult.IMPROVED_SKIP_TOO_SHORT : WordResult.IMPROVEMENT_TOO_SHORT; } if (isTooSoon(challengeUpper)) { return isSkipped ? WordResult.IMPROVED_SKIP_TOO_SOON : WordResult.IMPROVEMENT_TOO_SOON; } if (isSkipped) { return WordResult.WORD_FOUND; } return challengeWord(solution.toUpperCase(), challengeUpper); } /** * Check to see if the solution is in the word list and a match for the puzzle * letters. * @return VALID if the solution is valid, otherwise the reason the solution * was rejected. */ private WordResult checkSolution() { if (solution.length() == 0) { return WordResult.SKIPPING; } String solutionUpper = solution.toUpperCase(); if ( ! wordList.contains(solutionUpper)) { return WordResult.NOT_A_WORD; } if (isTooSoon(solutionUpper)) { return WordResult.TOO_SOON; } return ! isMatch(solution) ? WordResult.NOT_A_MATCH : solution.length() < getMinimumWordLength() ? WordResult.TOO_SHORT : WordResult.VALID; } /** * Check if a solution or response is too soon (not later or longer than * the previous word). * @param wordUpper the solution or response, all in upper case * @return false if the word is later or longer than the previous word, * otherwise true. */ private boolean isTooSoon(String wordUpper) { if (previousWord != null) { WordResult solutionOverPrevious = challengeWord(previousWord.toUpperCase(), wordUpper); if (solutionOverPrevious != WordResult.LATER && solutionOverPrevious != WordResult.LONGER) { return true; } } return false; } /** * The student who was assigned this puzzle. Any score will be given to * that student. */ public Student getOwner() { return owner; } @Override public String toString() { return "Puzzle(" + letters + ", " + owner.getName() + ")"; } /** * Check if a word is a match to the puzzle letters, but don't check * if it is in the word list. * @param word the word to check, case insensitive */ public boolean isMatch(String word) { String upper = word.toUpperCase(); if (upper.charAt(upper.length()-1) != letters.charAt(2)) { return false; } if (upper.charAt(0) != letters.charAt(0)) { return false; } int foundAt = upper.indexOf(letters.charAt(1), 1); return 0 < foundAt && foundAt < upper.length() - 1; } /** * Compare a new word with the current best solution. Both words must * be valid solutions all in upper case. */ private WordResult challengeWord(String solution, String challenge) { return challenge.length() > solution.length() ? WordResult.LONGER : challenge.length() == solution.length() && challenge.compareTo(solution) > 0 ? WordResult.LATER : challenge.length() < solution.length() ? WordResult.SHORTER : challenge.equals(solution) ? WordResult.NOT_IMPROVED : WordResult.EARLIER; } /** * Find the most common word that beats both the solution and the * response. * @return a valid solution that beats both, or null if none found */ public String findNextBetter() { String bestSoFar = getResult().isImproved() ? response.toUpperCase() : getResult().isValidSolution() ? solution.toUpperCase() : ""; Puzzle searchPuzzle = new Puzzle(letters, owner); searchPuzzle.setPreviousWord(previousWord); searchPuzzle.setSolution(bestSoFar); for (String word : wordList) { if (word.length() < getMinimumWordLength()) { continue; } searchPuzzle.setResponse(word); if (searchPuzzle.getResult().isImproved()) { return word; } } return null; // no improvement found. } /** * Set a limit for how long a word must be to solve the puzzle. Default 4. * @param minimumWordLength */ public void setMinimumWordLength(int minimumWordLength) { this.minimumWordLength = minimumWordLength; } public int getMinimumWordLength() { return minimumWordLength; } /** * Set the word from the previous puzzle. All solutions to this puzzle must * be worse than the previous word. Either longer or the same length but * later in the dictionary. * @param previousWord must be a word from the dictionary that matches the * three letters of this puzzle. */ public void setPreviousWord(String previousWord) { this.previousWord = previousWord; } public String getPreviousWord() { return previousWord; } public void addListener(Listener listener) { listeners.add(listener); } public int getScore() { WordResult result = getResult(); return getScore(result); } public int getScore(WordResult result) { float potentialScore = getPotentialScore(); float responseDelayLeft = Math.max(0, MAX_DELAY/2 - responseDelay)/MAX_DELAY*2; WordResult bestResult = WordResult.NOT_IMPROVED; if (NO_SOLUTION.equals(solution)) { bestResult = WordResult.SKIP_NOT_IMPROVED; } float resultRatio = ((float)result.getScore()) / bestResult.getScore(); float adjustedScore = potentialScore * (1 - (1 - resultRatio) * responseDelayLeft); return Math.round(adjustedScore); } private int getPotentialScore() { float progress = Math.min(solutionDelay/MAX_DELAY, 1); float potentialScore = 101 - (float)Math.exp(progress*Math.log(101)); if (NO_SOLUTION.equals(solution)) { potentialScore /= 3; } return Math.round(potentialScore); } public void adjustScore(float seconds) { if (isPaused) { return; } if (getResult().isValidSolution()) { responseDelay += seconds; if (responseDelay >= MAX_DELAY/2) { isTimedOut = true; onChanged(); } } else { solutionDelay += seconds; if (solutionDelay >= MAX_DELAY) { isTimedOut = true; onChanged(); } } } public boolean isCompleted() { return isTimedOut || getResult().isCompleted(); } /** True if the score timer has been paused. */ public boolean isPaused() { return isPaused; } public void setPaused(boolean isPaused) { this.isPaused = isPaused; onChanged(); } public void togglePause() { setPaused( ! this.isPaused); } }