Analysis
사전 외 단어 입력 시 Invalied word 메시지 출력
출력에서 ANSI 색 코드:
- 33(G): 글자와 위치 모두 일치
- 33(Y): 글자는 존재하지만 위치 다름
- ETC/W: 글자 없음
Solver
추론 로직 설계
- 대량 후보: 문자의 빈도 기반 점수화 (엔트로피 유사 휴리스틱)
- 중간 후보: minimax 방식으로 최적 단어 추측
- 소규모 후보: 재귀적 forced_guess searching
import socketimport reimport timefrom collections import Counterfrom typing import List, Set, Optional, Tuplefrom pathlib import Path
WORD_LIST_PATH = Path(__file__).with_name('wordle_words.txt')if not WORD_LIST_PATH.exists(): raise FileNotFoundError('wordle_words.txt not found; download the Wordle list first')with open(WORD_LIST_PATH) as f: WORDS = [line.strip() for line in f if line.strip()]ALLOWED_WORDS = WORDS[:]
COLOR_RE = re.compile(r'\x1b\[(\d+(?:;\d+)*)m([a-z])\x1b\[0m')
def evaluate(secret: str, guess: str) -> str: """Return Wordle pattern string for guess against secret using 'g', 'y', 'b'.""" result = ['b'] * 5 counter = Counter() for i in range(5): s = secret[i] g = guess[i] if g == s: result[i] = 'g' else: counter[s] += 1 for i in range(5): if result[i] == 'g': continue g = guess[i] if counter[g] > 0: result[i] = 'y' counter[g] -= 1 return ''.join(result)
def filter_candidates(candidates: List[str], guess: str, pattern: str) -> List[str]: return [word for word in candidates if evaluate(word, guess) == pattern]
def score_candidates(candidates: List[str]) -> dict: freq_total = Counter() freq_pos = [Counter() for _ in range(5)] for word in candidates: unique_letters = set(word) freq_total.update(unique_letters) for idx, ch in enumerate(word): freq_pos[idx][ch] += 1 return { 'total': freq_total, 'pos': freq_pos, }
def forced_guess(candidates: List[str], allowed: List[str], remaining: int) -> Optional[str]: if len(candidates) <= 1: return candidates[0] if candidates else None if remaining <= 1: return None cache: dict[tuple[Tuple[str, ...], int], Optional[str]] = {} allowed_pool = list(dict.fromkeys(candidates + allowed[:max(20, len(candidates))]))
def helper(cands: Tuple[str, ...], rem: int) -> Optional[str]: if len(cands) <= 1: return cands[0] if cands else None if rem <= 1: return None key = (cands, rem) if key in cache: return cache[key] for word in allowed_pool: partitions: dict[str, List[str]] = {} for secret in cands: pattern = evaluate(secret, word) partitions.setdefault(pattern, []).append(secret) ok = True for pattern, subset in partitions.items(): if pattern == 'ggggg': continue res = helper(tuple(sorted(subset)), rem - 1) if res is None: ok = False break if ok: cache[key] = word return word cache[key] = None return None
return helper(tuple(sorted(candidates)), remaining)
def minimax_guess(candidates: List[str], allowed: List[str], used: Set[str]) -> str: best_word = None best_score = float('inf') best_is_candidate = False for word in allowed: if word in used: continue partitions = {} for secret in candidates: pattern = evaluate(secret, word) partitions[pattern] = partitions.get(pattern, 0) + 1 score = max(partitions.values()) is_candidate = word in candidates if score < best_score or (score == best_score and is_candidate and not best_is_candidate): best_score = score best_word = word best_is_candidate = is_candidate if best_score == 1 and best_is_candidate: break return best_word if best_word else candidates[0]
def choose_guess(candidates: List[str], allowed: List[str], used: Set[str], remaining_attempts: int) -> str: if not candidates: return 'arise' if len(candidates) == 1: return candidates[0] if len(candidates) <= 8: forced = forced_guess(candidates, allowed, remaining_attempts) if forced: return forced if len(candidates) <= 20: return minimax_guess(candidates, allowed, used) letter_freq = Counter() pos_freq = [Counter() for _ in range(5)] for word in candidates: unique_letters = set(word) letter_freq.update(unique_letters) for idx, ch in enumerate(word): pos_freq[idx][ch] += 1 letter_set = set(letter_freq) best_word = None best_score = -1.0 for word in allowed: if word in used: continue unique_letters = set(word) if not unique_letters & letter_set: continue score = sum(letter_freq[ch] for ch in unique_letters) positional = sum(pos_freq[idx][ch] for idx, ch in enumerate(word)) if word in candidates: score += positional else: score += 0.2 * positional duplicate_penalty = len(word) - len(unique_letters) if duplicate_penalty: score -= duplicate_penalty * 0.5 if score > best_score: best_score = score best_word = word if best_word is None: for word in candidates: if word not in used: return word return candidates[0] return best_word
def parse_feedback(text: str): matches = COLOR_RE.findall(text) if len(matches) < 5: raise ValueError(f"Unable to parse feedback: {text!r}") matches = matches[-5:] letters = [] pattern_chars = [] for code, letter in matches: color = code.split(';')[-1] if color == '32': pattern_chars.append('g') elif color == '33': pattern_chars.append('y') else: pattern_chars.append('b') letters.append(letter) return ''.join(letters), ''.join(pattern_chars)
def read_until(sock: socket.socket, targets: List[bytes], timeout: float = 5.0) -> bytes: sock.settimeout(timeout) data = b'' deadline = time.time() + timeout while time.time() < deadline: for target in targets: if target in data: return data try: chunk = sock.recv(4096) except socket.timeout: continue if not chunk: return data data += chunk return data
def drain_socket(sock: socket.socket, timeout: float = 2.0) -> str: sock.settimeout(timeout) chunks = [] while True: try: chunk = sock.recv(4096) except socket.timeout: break if not chunk: break chunks.append(chunk) if b'FLAG{' in chunk.upper(): break if not chunks: return '' return b''.join(chunks).decode('utf-8', errors='replace')
def main(): global ALLOWED_WORDS sock = socket.create_connection(('3.39.86.84', 31337)) try: initial = read_until(sock, [b'Enter your guess:']) if b'FLAG{' in initial.upper(): print(initial.decode('utf-8', errors='replace')) return print('Connected to server, starting solver...') round_num = 1 while True: candidates = WORDS[:] used = set() solved = False attempt = 1 max_attempts = 6 attempts_used = None last_followup = b'' while attempt <= max_attempts: remaining = max_attempts - attempt + 1 if attempt == 1: first_choices = [w for w in ('arise', 'stare', 'alone', 'irate', 'raise') if w in candidates] guess = first_choices[0] if first_choices else choose_guess(candidates, ALLOWED_WORDS, used, remaining) else: guess = choose_guess(candidates, ALLOWED_WORDS, used, remaining) print(f'[Round {round_num} Attempt {attempt}] {guess}') sock.sendall((guess + '\n').encode()) response = read_until(sock, [b'Enter your guess:', b'FLAG', b'Flag', b'Correct', b'Round '], timeout=2.0) decoded = response.decode('utf-8', errors='replace') if 'Invalid word' in decoded: print(f' -> rejected by server') if guess in ALLOWED_WORDS: ALLOWED_WORDS = [w for w in ALLOWED_WORDS if w != guess] candidates = [word for word in candidates if word != guess] continue if 'You lost!' in decoded: raise RuntimeError(f'Lost round {round_num}') used.add(guess) if 'Correct!' in decoded: solved = True attempts_used = attempt print(f' -> solved (server confirmed)') followup = read_until(sock, [b'Enter your guess:', b'Round', b'FLAG', b'Flag'], timeout=2.0) if followup: last_followup = followup if b'FLAG{' in followup.upper(): print(followup.decode('utf-8', errors='replace')) return break before = len(candidates) try: _, pattern = parse_feedback(decoded) except ValueError: if 'FLAG{' in decoded.upper(): print(decoded) return raise if pattern == 'ggggg': solved = True attempts_used = attempt print(' -> solved exactly') followup = read_until(sock, [b'Enter your guess:', b'Round', b'FLAG', b'Flag'], timeout=2.0) if followup: last_followup = followup if b'FLAG{' in followup.upper(): print(followup.decode('utf-8', errors='replace')) return break candidates = filter_candidates(candidates, guess, pattern) print(f' -> pattern {pattern}, {before} -> {len(candidates)} candidates') attempt += 1 if not solved: raise RuntimeError(f'Failed to solve round {round_num}') if attempts_used is not None: print(f'Round {round_num} solved in {attempts_used} attempts') if round_num >= 100: if last_followup: text_out = last_followup.decode('utf-8', errors='replace') print(text_out) if 'FLAG{' in text_out.upper(): return remainder = drain_socket(sock) if remainder: print(remainder) return round_num += 1 finally: sock.close()
if __name__ == '__main__': main()- Wordle 단어 목록을 만들어서 솔버를 구성. World_word.txt
- ANSI를 정규식으로 파싱하여 색상 코드별 패턴으로 변환한다.
- 후보를 유지하며 피드백 패턴에 맞는 단어만 남김
[Round 100 Attempt 4] snack -> solved (server confirmed)Round 100 solved in 4 attempts--------------------------------Congratulations! You cleared 100 rounds!Here is your flag: hspace{830fc3076a183c74dfa2e053b689747f}