feat: implement wordle backend

This commit is contained in:
sBubshait 2025-08-05 10:29:41 +03:00
parent 04e2aed4af
commit 16b2396190
9 changed files with 513 additions and 0 deletions

View File

@ -0,0 +1,78 @@
package online.wesal.wesal.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import online.wesal.wesal.dto.ApiResponse;
import online.wesal.wesal.dto.DailyChallengeResponse;
import online.wesal.wesal.dto.LeaderboardEntry;
import online.wesal.wesal.dto.PuzzleAttemptRequest;
import online.wesal.wesal.entity.Puzzle;
import online.wesal.wesal.entity.PuzzleAttempt;
import online.wesal.wesal.service.PuzzleService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/puzzles")
@CrossOrigin(origins = "*")
@Tag(name = "Puzzles", description = "Wordle puzzle endpoints")
public class PuzzleController {
@Autowired
private PuzzleService puzzleService;
@GetMapping("/dailyChallenge")
@Operation(summary = "Get daily challenge", description = "Returns today's Wordle word and whether user has solved it")
public ResponseEntity<ApiResponse<DailyChallengeResponse>> getDailyChallenge() {
try {
Puzzle todaysPuzzle = puzzleService.getTodaysPuzzle();
boolean solved = puzzleService.hasUserSolvedToday();
DailyChallengeResponse response = new DailyChallengeResponse(todaysPuzzle.getWord(), solved);
return ResponseEntity.ok(ApiResponse.success(response));
} catch (RuntimeException e) {
return ResponseEntity.ok(ApiResponse.error(e.getMessage()));
}
}
@PostMapping("/attempt")
@Operation(summary = "Submit puzzle attempt", description = "Submit user's attempt for today's puzzle")
public ResponseEntity<ApiResponse<String>> submitAttempt(@Valid @RequestBody PuzzleAttemptRequest request) {
try {
puzzleService.submitAttempt(request.getAttempts(), request.getSolved());
String message = request.getSolved() ?
"Congratulations! You solved today's puzzle!" :
"Better luck next time!";
return ResponseEntity.ok(ApiResponse.success(message));
} catch (RuntimeException e) {
return ResponseEntity.ok(ApiResponse.error(e.getMessage()));
}
}
@GetMapping("/leaderboard")
@Operation(summary = "Get daily leaderboard", description = "Returns leaderboard for today's puzzle ordered by submission time")
public ResponseEntity<ApiResponse<List<LeaderboardEntry>>> getLeaderboard() {
try {
List<PuzzleAttempt> attempts = puzzleService.getTodaysLeaderboard();
List<LeaderboardEntry> leaderboard = attempts.stream()
.map(attempt -> new LeaderboardEntry(
attempt.getUser().getDisplayName(),
attempt.getUser().getUsername(),
attempt.getAttempts(),
attempt.isSolved(),
attempt.getSubmittedAt()
))
.collect(Collectors.toList());
return ResponseEntity.ok(ApiResponse.success(leaderboard));
} catch (RuntimeException e) {
return ResponseEntity.ok(ApiResponse.error(e.getMessage()));
}
}
}

View File

@ -0,0 +1,30 @@
package online.wesal.wesal.dto;
public class DailyChallengeResponse {
private String wordle;
private boolean solved;
public DailyChallengeResponse() {}
public DailyChallengeResponse(String wordle, boolean solved) {
this.wordle = wordle;
this.solved = solved;
}
public String getWordle() {
return wordle;
}
public void setWordle(String wordle) {
this.wordle = wordle;
}
public boolean isSolved() {
return solved;
}
public void setSolved(boolean solved) {
this.solved = solved;
}
}

View File

@ -0,0 +1,62 @@
package online.wesal.wesal.dto;
import java.time.LocalDateTime;
public class LeaderboardEntry {
private String displayName;
private String username;
private int attempts;
private boolean solved;
private LocalDateTime submittedAt;
public LeaderboardEntry() {}
public LeaderboardEntry(String displayName, String username, int attempts, boolean solved, LocalDateTime submittedAt) {
this.displayName = displayName;
this.username = username;
this.attempts = attempts;
this.solved = solved;
this.submittedAt = submittedAt;
}
public String getDisplayName() {
return displayName;
}
public void setDisplayName(String displayName) {
this.displayName = displayName;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public int getAttempts() {
return attempts;
}
public void setAttempts(int attempts) {
this.attempts = attempts;
}
public boolean isSolved() {
return solved;
}
public void setSolved(boolean solved) {
this.solved = solved;
}
public LocalDateTime getSubmittedAt() {
return submittedAt;
}
public void setSubmittedAt(LocalDateTime submittedAt) {
this.submittedAt = submittedAt;
}
}

View File

@ -0,0 +1,39 @@
package online.wesal.wesal.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
public class PuzzleAttemptRequest {
@NotNull
@Min(1)
@Max(6)
private Integer attempts;
@NotNull
private Boolean solved;
public PuzzleAttemptRequest() {}
public PuzzleAttemptRequest(Integer attempts, Boolean solved) {
this.attempts = attempts;
this.solved = solved;
}
public Integer getAttempts() {
return attempts;
}
public void setAttempts(Integer attempts) {
this.attempts = attempts;
}
public Boolean getSolved() {
return solved;
}
public void setSolved(Boolean solved) {
this.solved = solved;
}
}

View File

@ -0,0 +1,50 @@
package online.wesal.wesal.entity;
import jakarta.persistence.*;
import java.time.LocalDate;
@Entity
@Table(name = "puzzles")
public class Puzzle {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private LocalDate date;
@Column(nullable = false, length = 5)
private String word;
public Puzzle() {}
public Puzzle(LocalDate date, String word) {
this.date = date;
this.word = word;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public LocalDate getDate() {
return date;
}
public void setDate(LocalDate date) {
this.date = date;
}
public String getWord() {
return word;
}
public void setWord(String word) {
this.word = word;
}
}

View File

@ -0,0 +1,88 @@
package online.wesal.wesal.entity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "puzzle_attempts")
public class PuzzleAttempt {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "puzzle_id", nullable = false)
private Puzzle puzzle;
@Column(nullable = false)
private int attempts;
@Column(nullable = false)
private boolean solved;
@Column(nullable = false)
private LocalDateTime submittedAt;
public PuzzleAttempt() {}
public PuzzleAttempt(User user, Puzzle puzzle, int attempts, boolean solved) {
this.user = user;
this.puzzle = puzzle;
this.attempts = attempts;
this.solved = solved;
this.submittedAt = LocalDateTime.now();
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public User getUser() {
return user;
}
public void setUser(User user) {
this.user = user;
}
public Puzzle getPuzzle() {
return puzzle;
}
public void setPuzzle(Puzzle puzzle) {
this.puzzle = puzzle;
}
public int getAttempts() {
return attempts;
}
public void setAttempts(int attempts) {
this.attempts = attempts;
}
public boolean isSolved() {
return solved;
}
public void setSolved(boolean solved) {
this.solved = solved;
}
public LocalDateTime getSubmittedAt() {
return submittedAt;
}
public void setSubmittedAt(LocalDateTime submittedAt) {
this.submittedAt = submittedAt;
}
}

View File

@ -0,0 +1,20 @@
package online.wesal.wesal.repository;
import online.wesal.wesal.entity.Puzzle;
import online.wesal.wesal.entity.PuzzleAttempt;
import online.wesal.wesal.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface PuzzleAttemptRepository extends JpaRepository<PuzzleAttempt, Long> {
Optional<PuzzleAttempt> findByUserAndPuzzle(User user, Puzzle puzzle);
@Query("SELECT pa FROM PuzzleAttempt pa WHERE pa.puzzle = :puzzle ORDER BY pa.submittedAt ASC")
List<PuzzleAttempt> findByPuzzleOrderBySubmittedAtAsc(@Param("puzzle") Puzzle puzzle);
}

View File

@ -0,0 +1,13 @@
package online.wesal.wesal.repository;
import online.wesal.wesal.entity.Puzzle;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.time.LocalDate;
import java.util.Optional;
@Repository
public interface PuzzleRepository extends JpaRepository<Puzzle, Long> {
Optional<Puzzle> findByDate(LocalDate date);
}

View File

@ -0,0 +1,133 @@
package online.wesal.wesal.service;
import online.wesal.wesal.entity.Puzzle;
import online.wesal.wesal.entity.PuzzleAttempt;
import online.wesal.wesal.entity.User;
import online.wesal.wesal.repository.PuzzleAttemptRepository;
import online.wesal.wesal.repository.PuzzleRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.Random;
@Service
public class PuzzleService {
@Autowired
private PuzzleRepository puzzleRepository;
@Autowired
private PuzzleAttemptRepository puzzleAttemptRepository;
@Autowired
private UserService userService;
private static final List<String> WORD_LIST = Arrays.asList(
"APPLE", "BEACH", "CHAIN", "DANCE", "EAGLE", "FLAME", "GRAPE", "HOUSE", "IMAGE", "JUDGE",
"KNIFE", "LIGHT", "MUSIC", "NIGHT", "OCEAN", "PEACE", "QUEEN", "RIVER", "STAGE", "TIGER",
"UNITY", "VOICE", "WATER", "YOUTH", "ZEBRA", "ANGEL", "BREAD", "CHAIR", "DREAM", "EARTH",
"FIELD", "GLASS", "HEART", "INDEX", "JUICE", "KITE", "LEMON", "MAGIC", "NOVEL", "OPERA",
"PLANT", "QUEST", "RADIO", "SMILE", "TOWER", "ULTRA", "VILLA", "WORLD", "YOUTH", "ZEBRA",
"ABOUT", "ABOVE", "ABUSE", "ACTOR", "ACUTE", "ADMIT", "ADOPT", "ADULT", "AFTER", "AGAIN",
"AGENT", "AGREE", "AHEAD", "ALARM", "ALBUM", "ALERT", "ALIEN", "ALIGN", "ALIKE", "ALIVE",
"ALLOW", "ALONE", "ALONG", "ALTER", "AMONG", "ANGER", "ANGLE", "ANGRY", "APART", "APPLE",
"APPLY", "ARENA", "ARGUE", "ARISE", "ARRAY", "ASIDE", "ASSET", "AVOID", "AWAKE", "AWARD",
"AWARE", "BADLY", "BAKER", "BASES", "BASIC", "BEACH", "BEGAN", "BEGIN", "BEING", "BELOW",
"BENCH", "BILLY", "BIRTH", "BLACK", "BLAME", "BLANK", "BLIND", "BLOCK", "BLOOD", "BOARD",
"BOOST", "BOOTH", "BOUND", "BRAIN", "BRAND", "BRASS", "BRAVE", "BREAD", "BREAK", "BREED",
"BRIEF", "BRING", "BROAD", "BROKE", "BROWN", "BUILD", "BUILT", "BUYER", "CABLE", "CALIF",
"CARRY", "CATCH", "CAUSE", "CHAIN", "CHAIR", "CHAOS", "CHARM", "CHART", "CHASE", "CHEAP",
"CHECK", "CHEST", "CHIEF", "CHILD", "CHINA", "CHOSE", "CIVIL", "CLAIM", "CLASS", "CLEAN",
"CLEAR", "CLICK", "CLIMB", "CLOCK", "CLOSE", "CLOUD", "COACH", "COAST", "COULD", "COUNT",
"COURT", "COVER", "CRAFT", "CRASH", "CRAZY", "CREAM", "CRIME", "CROSS", "CROWD", "CROWN",
"CRUDE", "CURVE", "CYCLE", "DAILY", "DAMAGE", "DANCE", "DATED", "DEALT", "DEATH", "DEBUG",
"DELAY", "DEPTH", "DOING", "DOUBT", "DOZEN", "DRAFT", "DRAMA", "DRANK", "DRAWN", "DREAM",
"DRESS", "DRILL", "DRINK", "DRIVE", "DROVE", "DYING", "EAGER", "EARLY", "EARTH", "EIGHT",
"ELITE", "EMPTY", "ENEMY", "ENJOY", "ENTER", "ENTRY", "EQUAL", "ERROR", "EVENT", "EVERY",
"EXACT", "EXIST", "EXTRA", "FAITH", "FALSE", "FAULT", "FIBER", "FIELD", "FIFTH", "FIFTY",
"FIGHT", "FINAL", "FIRST", "FIXED", "FLASH", "FLEET", "FLOOR", "FLUID", "FOCUS", "FORCE",
"FORTH", "FORTY", "FORUM", "FOUND", "FRAME", "FRANK", "FRAUD", "FRESH", "FRONT", "FRUIT",
"FULLY", "FUNNY", "GIANT", "GIVEN", "GLASS", "GLOBE", "GOING", "GRACE", "GRADE", "GRAND",
"GRANT", "GRASS", "GRAVE", "GREAT", "GREEN", "GROSS", "GROUP", "GROWN", "GUARD", "GUESS",
"GUEST", "GUIDE", "HAPPY", "HARRY", "HEART", "HEAVY", "HENCE", "HENRY", "HORSE", "HOTEL",
"HOUSE", "HUMAN", "IDEAL", "IMAGE", "INDEX", "INNER", "INPUT", "ISSUE", "JAPAN", "JIMMY",
"JOINT", "JONES", "JUDGE", "KNOWN", "LABEL", "LARGE", "LASER", "LATER", "LAUGH", "LAYER",
"LEARN", "LEASE", "LEAST", "LEAVE", "LEGAL", "LEVEL", "LEWIS", "LIGHT", "LIMIT", "LINKS",
"LIVES", "LOCAL", "LOOSE", "LOWER", "LUCKY", "LUNCH", "LYING", "MAGIC", "MAJOR", "MAKER",
"MARCH", "MARIA", "MATCH", "MAYBE", "MAYOR", "MEANT", "MEDIA", "METAL", "MIGHT", "MINOR",
"MINUS", "MIXED", "MODEL", "MONEY", "MONTH", "MORAL", "MOTOR", "MOUNT", "MOUSE", "MOUTH",
"MOVED", "MOVIE", "MUSIC", "NEEDS", "NEVER", "NEWLY", "NIGHT", "NOISE", "NORTH", "NOTED",
"NOVEL", "NURSE", "OCCUR", "OCEAN", "OFFER", "OFTEN", "ORDER", "OTHER", "OUGHT", "PAINT",
"PANEL", "PAPER", "PARTY", "PEACE", "PETER", "PHASE", "PHONE", "PHOTO", "PIANO", "PICKED",
"PIECE", "PILOT", "PITCH", "PLACE", "PLAIN", "PLANE", "PLANT", "PLATE", "POINT", "POUND",
"POWER", "PRESS", "PRICE", "PRIDE", "PRIME", "PRINT", "PRIOR", "PRIZE", "PROOF", "PROUD",
"PROVE", "QUEEN", "QUICK", "QUIET", "QUITE", "RADIO", "RAISE", "RANGE", "RAPID", "RATIO",
"REACH", "READY", "REALM", "REBEL", "REFER", "RELAX", "RELAY", "RIDER", "RIDGE", "RIGHT",
"RIGID", "RIVAL", "RIVER", "ROBIN", "ROGER", "ROMAN", "ROUGH", "ROUND", "ROUTE", "ROYAL",
"RURAL", "SCALE", "SCENE", "SCOPE", "SCORE", "SENSE", "SERVE", "SEVEN", "SHALL", "SHAPE",
"SHARE", "SHARP", "SHEET", "SHELF", "SHELL", "SHIFT", "SHINE", "SHIRT", "SHOCK", "SHOOT",
"SHORT", "SHOWN", "SIGHT", "SILLY", "SINCE", "SIXTH", "SIXTY", "SIZED", "SKILL", "SLEEP",
"SLIDE", "SMALL", "SMART", "SMILE", "SMITH", "SMOKE", "SNAKE", "SNOW", "SOBER", "SORRY",
"SOUND", "SOUTH", "SPACE", "SPARE", "SPEAK", "SPEED", "SPEND", "SPENT", "SPLIT", "SPOKE",
"SPORT", "STAFF", "STAGE", "STAKE", "STAND", "START", "STATE", "STEAM", "STEEL", "STEEP",
"STEER", "STERN", "STICK", "STILL", "STOCK", "STONE", "STOOD", "STORE", "STORM", "STORY",
"STRIP", "STUCK", "STUDY", "STUFF", "STYLE", "SUGAR", "SUITE", "SUPER", "SWEET", "TABLE",
"TAKEN", "TASTE", "TAXES", "TEACH", "TEAM", "TEETH", "TERRY", "THANK", "THEFT", "THEIR",
"THEME", "THERE", "THESE", "THICK", "THING", "THINK", "THIRD", "THOSE", "THREE", "THREW",
"THROW", "THUMB", "TIGER", "TIGHT", "TIMES", "TIRED", "TITLE", "TODAY", "TOPIC", "TOTAL",
"TOUCH", "TOUGH", "TOWER", "TRACK", "TRADE", "TRAIN", "TREAT", "TREND", "TRIAL", "TRIBE",
"TRICK", "TRIED", "TRIES", "TRIP", "TRUCK", "TRULY", "TRUNK", "TRUST", "TRUTH", "TWICE",
"UNDER", "UNDUE", "UNION", "UNITY", "UNTIL", "UPPER", "UPSET", "URBAN", "USAGE", "USUAL",
"VALUE", "VIDEO", "VIRUS", "VISIT", "VITAL", "VOCAL", "VOICE", "WASTE", "WATCH", "WATER",
"WHEEL", "WHERE", "WHICH", "WHILE", "WHITE", "WHOLE", "WHOSE", "WOMAN", "WOMEN", "WORLD",
"WORRY", "WORSE", "WORST", "WORTH", "WOULD", "WRITE", "WRONG", "WROTE", "YOUNG", "YOUTH"
);
public Puzzle getTodaysPuzzle() {
LocalDate today = LocalDate.now();
Optional<Puzzle> existingPuzzle = puzzleRepository.findByDate(today);
if (existingPuzzle.isPresent()) {
return existingPuzzle.get();
}
String word = generateWordForDate(today);
Puzzle puzzle = new Puzzle(today, word);
return puzzleRepository.save(puzzle);
}
private String generateWordForDate(LocalDate date) {
Random random = new Random(date.toEpochDay());
return WORD_LIST.get(random.nextInt(WORD_LIST.size()));
}
public boolean hasUserSolvedToday() {
User currentUser = userService.getCurrentUser();
Puzzle todaysPuzzle = getTodaysPuzzle();
Optional<PuzzleAttempt> attempt = puzzleAttemptRepository.findByUserAndPuzzle(currentUser, todaysPuzzle);
return attempt.isPresent();
}
public PuzzleAttempt submitAttempt(int attempts, boolean solved) {
User currentUser = userService.getCurrentUser();
Puzzle todaysPuzzle = getTodaysPuzzle();
Optional<PuzzleAttempt> existingAttempt = puzzleAttemptRepository.findByUserAndPuzzle(currentUser, todaysPuzzle);
if (existingAttempt.isPresent()) {
throw new RuntimeException("You've already attempted the daily challenge");
}
PuzzleAttempt attempt = new PuzzleAttempt(currentUser, todaysPuzzle, attempts, solved);
return puzzleAttemptRepository.save(attempt);
}
public List<PuzzleAttempt> getTodaysLeaderboard() {
Puzzle todaysPuzzle = getTodaysPuzzle();
return puzzleAttemptRepository.findByPuzzleOrderBySubmittedAtAsc(todaysPuzzle);
}
}