1package spellpuzzle23import (4 "fmt"5 "math/rand/v2"6 "regexp"7 "strings"89 "charm.land/bubbles/v2/key"10 tea "charm.land/bubbletea/v2"11 "charm.land/lipgloss/v2"12 "github.com/FelineStateMachine/puzzletea/game"13)1415var ansiPattern = regexp.MustCompile(`\x1b\[[0-9;]*m`)1617type feedbackKind int1819const (20 feedbackNone feedbackKind = iota21 feedbackError22 feedbackSuccess23)2425type feedback struct {26 Kind feedbackKind `json:"kind"`27 Message string `json:"message"`28}2930type Model struct {31 modeTitle string32 board board33 bank []rune3435 placements []WordPlacement36 boardIndex map[string][]int3738 allowedWords map[string]struct{}39 bonusSet map[string]struct{}40 bonusWords []string4142 trace []int43 bankCursor int44 bankColorSlots []int45 keys KeyMap4647 solved bool48 showFullHelp bool49 feedback feedback5051 mouseDragging bool52 hoverTile int5354 termWidth int55 termHeight int56 bankOriginX int57 bankOriginY int58 originValid bool59}6061var _ game.Gamer = Model{}6263func New(mode SpellPuzzleMode, puzzle GeneratedPuzzle) (game.Gamer, error) {64 model := &Model{65 modeTitle: mode.Title(),66 bank: []rune(puzzle.Bank),67 placements: append([]WordPlacement(nil), puzzle.Placements...),68 bonusWords: nil,69 bankCursor: -1,70 bankColorSlots: buildBankColorSlots(len([]rune(puzzle.Bank))),71 keys: DefaultKeyMap,72 hoverTile: -1,73 }74 model.rebuildDerivedState()75 return model, nil76}7778func (m *Model) rebuildDerivedState() {79 m.placements = normalizePlacements(m.placements)80 m.board = buildBoard(m.placements)81 m.boardIndex = make(map[string][]int, len(m.placements))82 for i := range m.placements {83 text := m.placements[i].Text84 m.boardIndex[text] = append(m.boardIndex[text], i)85 }8687 m.allowedWords = make(map[string]struct{})88 for _, word := range spellableWords(string(m.bank), 3) {89 m.allowedWords[word] = struct{}{}90 }9192 m.bonusSet = make(map[string]struct{}, len(m.bonusWords))93 for _, word := range m.bonusWords {94 m.bonusSet[word] = struct{}{}95 }96 m.solved = m.allBoardWordsFound()97 m.normalizeBankState()98}99100func (m Model) Init() tea.Cmd {101 return nil102}103104func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {105 switch typed := msg.(type) {106 case game.HelpToggleMsg:107 m.showFullHelp = typed.Show108 m.originValid = false109 case tea.WindowSizeMsg:110 m.termWidth = typed.Width111 m.termHeight = typed.Height112 m.originValid = false113 case tea.KeyPressMsg:114 m.clearFeedback()115 m.handleKeyPress(typed)116 case tea.MouseClickMsg:117 m.clearFeedback()118 m.handleMouseClick(typed)119 case tea.MouseMotionMsg:120 m.handleMouseMotion(typed)121 case tea.MouseReleaseMsg:122 m.handleMouseRelease(typed)123 }124 return m, nil125}126127func (m *Model) handleKeyPress(msg tea.KeyPressMsg) {128 if !m.solved && m.handleTypedLetter(msg) {129 return130 }131132 switch {133 case key.Matches(msg, m.keys.Left):134 m.moveBankCursorLeft()135 case key.Matches(msg, m.keys.Right):136 m.moveBankCursorRight()137 default:138 if m.solved {139 return140 }141 switch {142 case key.Matches(msg, m.keys.Shuffle):143 m.shuffleBank()144 case key.Matches(msg, m.keys.Submit):145 m.submitTrace()146 case key.Matches(msg, m.keys.Back):147 m.backspaceTrace()148 }149 }150}151152func (m *Model) moveBankCursorLeft() {153 if len(m.bank) == 0 {154 return155 }156 if m.bankCursor < 0 {157 m.bankCursor = len(m.bank) - 1158 return159 }160 m.bankCursor = (m.bankCursor - 1 + len(m.bank)) % len(m.bank)161}162163func (m *Model) moveBankCursorRight() {164 if len(m.bank) == 0 {165 return166 }167 if m.bankCursor < 0 {168 m.bankCursor = 0169 return170 }171 m.bankCursor = (m.bankCursor + 1) % len(m.bank)172}173174func (m *Model) handleTypedLetter(msg tea.KeyPressMsg) bool {175 if len(msg.Text) != 1 {176 return false177 }178179 typed := msg.Text[0]180 if typed >= 'a' && typed <= 'z' {181 typed = typed - 'a' + 'A'182 }183 if typed < 'A' || typed > 'Z' {184 return false185 }186187 index, ok := m.findAvailableTileForLetter(rune(typed))188 if !ok {189 return true190 }191 m.appendTraceTile(index)192 m.bankCursor = index193 return true194}195196func (m *Model) handleMouseClick(msg tea.MouseClickMsg) {197 if m.solved {198 return199 }200 if msg.Button != tea.MouseLeft {201 return202 }203 index, ok := m.tileAt(msg.X, msg.Y)204 if !ok {205 return206 }207 m.trace = nil208 m.bankCursor = -1209 m.mouseDragging = true210 m.hoverTile = index211 m.appendTraceTile(index)212}213214func (m *Model) handleMouseMotion(msg tea.MouseMotionMsg) {215 if m.solved {216 m.hoverTile = -1217 return218 }219 if !m.mouseDragging {220 m.hoverTile = -1221 return222 }223 index, ok := m.tileAt(msg.X, msg.Y)224 if !ok {225 m.hoverTile = -1226 return227 }228 m.hoverTile = index229 m.appendTraceTile(index)230}231232func (m *Model) handleMouseRelease(msg tea.MouseReleaseMsg) {233 if m.solved {234 m.mouseDragging = false235 m.hoverTile = -1236 return237 }238 if msg.Button != tea.MouseLeft || !m.mouseDragging {239 return240 }241 m.mouseDragging = false242 m.hoverTile = -1243 m.submitTrace()244}245246func (m *Model) appendTraceTile(index int) {247 if index < 0 || index >= len(m.bank) {248 return249 }250 if m.tileUsage(index) >= m.tileCapacity(index) {251 return252 }253 m.trace = append(m.trace, index)254}255256func (m *Model) shuffleBank() {257 if len(m.bank) < 2 {258 return259 }260261 perm := make([]int, len(m.bank))262 for i := range perm {263 perm[i] = i264 }265266 rng := rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))267 changed := false268 for attempt := 0; attempt < 6; attempt++ {269 rng.Shuffle(len(perm), func(i, j int) {270 perm[i], perm[j] = perm[j], perm[i]271 })272 if !isIdentityPermutation(perm) {273 changed = true274 break275 }276 }277 if !changed {278 return279 }280281 oldBank := append([]rune(nil), m.bank...)282 oldColorSlots := append([]int(nil), m.bankColorSlots...)283 oldToNew := make([]int, len(m.bank))284 for newIndex, oldIndex := range perm {285 m.bank[newIndex] = oldBank[oldIndex]286 if len(oldColorSlots) == len(m.bank) {287 m.bankColorSlots[newIndex] = oldColorSlots[oldIndex]288 }289 oldToNew[oldIndex] = newIndex290 }291292 for i, oldIndex := range m.trace {293 m.trace[i] = oldToNew[oldIndex]294 }295 if m.bankCursor >= 0 && m.bankCursor < len(oldToNew) {296 m.bankCursor = oldToNew[m.bankCursor]297 }298 if m.hoverTile >= 0 && m.hoverTile < len(oldToNew) {299 m.hoverTile = oldToNew[m.hoverTile]300 }301 m.originValid = false302}303304func isIdentityPermutation(perm []int) bool {305 for i, value := range perm {306 if i != value {307 return false308 }309 }310 return true311}312313func (m Model) findAvailableTileForLetter(letter rune) (int, bool) {314 for index, current := range m.bank {315 if current != letter {316 continue317 }318 if m.tileUsage(index) < m.tileCapacity(index) {319 return index, true320 }321 }322 return 0, false323}324325func (m *Model) backspaceTrace() {326 if len(m.trace) == 0 {327 return328 }329 m.trace = m.trace[:len(m.trace)-1]330 if len(m.trace) == 0 {331 m.bankCursor = -1332 return333 }334 m.bankCursor = m.trace[len(m.trace)-1]335}336337func (m *Model) submitTrace() {338 word := m.traceWord()339 if word == "" {340 return341 }342 defer func() {343 m.trace = nil344 m.bankCursor = -1345 m.originValid = false346 }()347348 if !isExactWord(word) || !m.canTraceSpell(word) {349 m.feedback = feedback{Kind: feedbackError, Message: "not a word"}350 return351 }352353 if indexes, ok := m.boardIndex[word]; ok {354 alreadyFound := true355 for _, idx := range indexes {356 if !m.placements[idx].Found {357 alreadyFound = false358 break359 }360 }361 if alreadyFound {362 m.feedback = feedback{Kind: feedbackError, Message: "word already found"}363 return364 }365 for _, idx := range indexes {366 m.placements[idx].Found = true367 }368 m.board = buildBoard(m.placements)369 m.solved = m.allBoardWordsFound()370 m.feedback = feedback{Kind: feedbackSuccess, Message: "word added to board"}371 return372 }373374 if _, ok := m.allowedWords[word]; !ok {375 m.feedback = feedback{Kind: feedbackError, Message: "not a word"}376 return377 }378 if _, ok := m.bonusSet[word]; ok {379 m.feedback = feedback{Kind: feedbackError, Message: "word already found"}380 return381 }382383 m.bonusSet[word] = struct{}{}384 m.bonusWords = append(m.bonusWords, word)385 m.feedback = feedback{Kind: feedbackSuccess, Message: "bonus word counted"}386}387388func (m *Model) clearFeedback() {389 m.feedback = feedback{}390}391392func (m Model) canTraceSpell(word string) bool {393 counts := countLetters(word)394 for i, count := range counts {395 if count > countLetters(string(m.bank))[i] {396 return false397 }398 }399 return true400}401402func (m Model) traceWord() string {403 var builder strings.Builder404 for _, index := range m.trace {405 if index < 0 || index >= len(m.bank) {406 continue407 }408 builder.WriteRune(m.bank[index])409 }410 return builder.String()411}412413func (m Model) tileUsage(index int) int {414 count := 0415 for _, current := range m.trace {416 if current == index {417 count++418 }419 }420 return count421}422423func (m Model) tileCapacity(index int) int {424 if index < 0 || index >= len(m.bank) {425 return 0426 }427 return 1428}429430func (m Model) allBoardWordsFound() bool {431 if len(m.placements) == 0 {432 return false433 }434 for _, placement := range m.placements {435 if !placement.Found {436 return false437 }438 }439 return true440}441442func (m Model) countWordsLeft() int {443 left := 0444 for _, placement := range m.placements {445 if !placement.Found {446 left++447 }448 }449 return left450}451452func (m Model) countBonusWordsTotal() int {453 total := len(m.allowedWords) - len(m.boardIndex)454 if total < 0 {455 return 0456 }457 return total458}459460func (m Model) recentBonusWords(limit int) []string {461 if limit >= len(m.bonusWords) {462 return append([]string(nil), m.bonusWords...)463 }464 return append([]string(nil), m.bonusWords[len(m.bonusWords)-limit:]...)465}466467func (m Model) View() string {468 return renderView(m)469}470471func (m Model) GetDebugInfo() string {472 rows := [][2]string{473 {"Bank", string(m.bank)},474 {"Trace", m.traceWord()},475 {"Words Left", fmt.Sprintf("%d", m.countWordsLeft())},476 {"Bonus Count", fmt.Sprintf("%d", len(m.bonusWords))},477 {"Solved", fmt.Sprintf("%v", m.solved)},478 }479480 tableRows := make([][]string, 0, len(m.placements))481 for _, placement := range m.placements {482 found := "No"483 if placement.Found {484 found = "Yes"485 }486 tableRows = append(tableRows, []string{487 placement.Text,488 found,489 fmt.Sprintf("(%d,%d)", placement.Start.X, placement.Start.Y),490 })491 }492493 out := game.DebugHeader("Spell Puzzle", rows)494 out += game.DebugTable("Board Words", []string{"Word", "Found", "Start"}, tableRows)495 return out496}497498func (m Model) SetTitle(t string) game.Gamer {499 m.modeTitle = t500 return m501}502503func (m Model) IsSolved() bool {504 return m.solved505}506507func (m Model) Reset() game.Gamer {508 for i := range m.placements {509 m.placements[i].Found = false510 }511 m.bonusWords = nil512 m.trace = nil513 m.feedback = feedback{}514 m.hoverTile = -1515 m.bankCursor = -1516 m.rebuildDerivedState()517 return m518}519520func (m *Model) normalizeBankState() {521 if len(m.bank) == 0 {522 m.bankCursor = -1523 m.hoverTile = -1524 return525 }526 if len(m.trace) == 0 {527 m.bankCursor = -1528 } else if m.bankCursor < 0 || m.bankCursor >= len(m.bank) {529 m.bankCursor = m.trace[len(m.trace)-1]530 }531 if !m.mouseDragging || m.hoverTile < 0 || m.hoverTile >= len(m.bank) {532 m.hoverTile = -1533 }534 if len(m.bankColorSlots) != len(m.bank) {535 m.bankColorSlots = buildBankColorSlots(len(m.bank))536 }537}538539func buildBankColorSlots(length int) []int {540 slots := make([]int, length)541 if length == 0 {542 return slots543 }544 for i := range slots {545 slots[i] = i546 }547 return slots548}549550func (m Model) tileAt(screenX, screenY int) (int, bool) {551 startX, startY := m.cachedBankOrigin()552 if screenY != startY {553 return 0, false554 }555 for i := range len(m.bank) {556 tileX := startX + i*4557 if screenX >= tileX && screenX < tileX+3 {558 return i, true559 }560 }561 return 0, false562}563564func (m *Model) cachedBankOrigin() (int, int) {565 if m.originValid {566 return m.bankOriginX, m.bankOriginY567 }568 x, y := m.bankOrigin()569 m.bankOriginX, m.bankOriginY = x, y570 m.originValid = true571 return x, y572}573574func (m Model) bankOrigin() (int, int) {575 view := renderView(m)576 viewWidth := lipgloss.Width(view)577 viewHeight := lipgloss.Height(view)578 centerX := max((m.termWidth-viewWidth)/2, 0)579 centerY := max((m.termHeight-viewHeight)/2, 0)580 strippedView := stripANSI(view)581 bank := stripANSI(renderBankRow(m))582 for rowIndex, line := range strings.Split(strippedView, "\n") {583 if col := strings.Index(line, bank); col >= 0 {584 return centerX + col, centerY + rowIndex585 }586 }587 return centerX, centerY588}589590func stripANSI(text string) string {591 return ansiPattern.ReplaceAllString(text, "")592}