From 55881e2585834deeab39a35c5774e7ed6dfe4322 Mon Sep 17 00:00:00 2001 From: wpeterr Date: Mon, 13 Apr 2026 10:23:59 +0700 Subject: [PATCH 1/2] Add TFT coach mode with manual patch snapshot workflow --- AGENTS.md | 8 +- docs/experiment-tft-helper-manual-snapshot.md | 79 +++++++ leanring-buddy/ClickyAnalytics.swift | 7 + leanring-buddy/CompanionManager.swift | 73 ++++++- leanring-buddy/CompanionPanelView.swift | 72 ++++++- leanring-buddy/TFTMetaContext.swift | 59 ++++++ leanring-buddyTests/leanring_buddyTests.swift | 15 ++ scripts/update_tft_meta_snapshot.py | 196 ++++++++++++++++++ 8 files changed, 503 insertions(+), 6 deletions(-) create mode 100644 docs/experiment-tft-helper-manual-snapshot.md create mode 100644 leanring-buddy/TFTMetaContext.swift create mode 100644 scripts/update_tft_meta_snapshot.py diff --git a/AGENTS.md b/AGENTS.md index 6946d441..50c40673 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,14 +48,16 @@ Worker vars: `ELEVENLABS_VOICE_ID` **Transient Cursor Mode**: When "Show Clicky" is off, pressing the hotkey fades in the cursor overlay for the duration of the interaction (recording → response → TTS → optional pointing), then fades it out automatically after 1 second of inactivity. +**Manual TFT Snapshot Mode**: TFT coaching uses a local hardcoded patch/meta snapshot (`TFTMetaContext.swift`) appended to the Claude system prompt. The snapshot is intentionally manual and refreshed via `scripts/update_tft_meta_snapshot.py`, so the app stays self-contained and does not depend on live meta APIs at runtime. + ## Key Files | File | Lines | Purpose | |------|-------|---------| | `leanring_buddyApp.swift` | ~89 | Menu bar app entry point. Uses `@NSApplicationDelegateAdaptor` with `CompanionAppDelegate` which creates `MenuBarPanelManager` and starts `CompanionManager`. No main window — the app lives entirely in the status bar. | -| `CompanionManager.swift` | ~1026 | Central state machine. Owns dictation, shortcut monitoring, screen capture, Claude API, ElevenLabs TTS, and overlay management. Tracks voice state (idle/listening/processing/responding), conversation history, model selection, and cursor visibility. Coordinates the full push-to-talk → screenshot → Claude → TTS → pointing pipeline. | +| `CompanionManager.swift` | ~1080 | Central state machine. Owns dictation, shortcut monitoring, screen capture, Claude API, ElevenLabs TTS, and overlay management. Tracks voice state (idle/listening/processing/responding), conversation history, model/assistant mode selection, and cursor visibility. Coordinates the full push-to-talk → screenshot → Claude → TTS → pointing pipeline. | | `MenuBarPanelManager.swift` | ~243 | NSStatusItem + custom NSPanel lifecycle. Creates the menu bar icon, manages the floating companion panel (show/hide/position), installs click-outside-to-dismiss monitor. | -| `CompanionPanelView.swift` | ~761 | SwiftUI panel content for the menu bar dropdown. Shows companion status, push-to-talk instructions, model picker (Sonnet/Opus), permissions UI, DM feedback button, and quit button. Dark aesthetic using `DS` design system. | +| `CompanionPanelView.swift` | ~830 | SwiftUI panel content for the menu bar dropdown. Shows companion status, push-to-talk instructions, assistant mode picker (General/TFT Coach), model picker (Sonnet/Opus), permissions UI, DM feedback button, and quit button. Dark aesthetic using `DS` design system. | | `OverlayWindow.swift` | ~881 | Full-screen transparent overlay hosting the blue cursor, response text, waveform, and spinner. Handles cursor animation, element pointing with bezier arcs, multi-monitor coordinate mapping, and fade-out transitions. | | `CompanionResponseOverlay.swift` | ~217 | SwiftUI view for the response text bubble and waveform displayed next to the cursor in the overlay. | | `CompanionScreenCaptureUtility.swift` | ~132 | Multi-monitor screenshot capture using ScreenCaptureKit. Returns labeled image data for each connected display. | @@ -72,9 +74,11 @@ Worker vars: `ELEVENLABS_VOICE_ID` | `ElementLocationDetector.swift` | ~335 | Detects UI element locations in screenshots for cursor pointing. | | `DesignSystem.swift` | ~880 | Design system tokens — colors, corner radii, shared styles. All UI references `DS.Colors`, `DS.CornerRadius`, etc. | | `ClickyAnalytics.swift` | ~121 | PostHog analytics integration for usage tracking. | +| `TFTMetaContext.swift` | ~65 | Hardcoded TFT patch/meta snapshot and prompt context builder used by TFT Coach mode. | | `WindowPositionManager.swift` | ~262 | Window placement logic, Screen Recording permission flow, and accessibility permission helpers. | | `AppBundleConfiguration.swift` | ~28 | Runtime configuration reader for keys stored in the app bundle Info.plist. | | `worker/src/index.ts` | ~142 | Cloudflare Worker proxy. Three routes: `/chat` (Claude), `/tts` (ElevenLabs), `/transcribe-token` (AssemblyAI temp token). | +| `scripts/update_tft_meta_snapshot.py` | ~185 | Manual updater script that refreshes the hardcoded TFT snapshot block from official patch notes + Data Dragon versions. | ## Build & Run diff --git a/docs/experiment-tft-helper-manual-snapshot.md b/docs/experiment-tft-helper-manual-snapshot.md new file mode 100644 index 00000000..1c0159ce --- /dev/null +++ b/docs/experiment-tft-helper-manual-snapshot.md @@ -0,0 +1,79 @@ +# Experiment: TFT Helper Manual Snapshot + +## Summary +Added a new assistant mode called `TFT Coach` so Clicky can answer with TFT-focused guidance using a local patch/meta snapshot that is manually refreshed. + +## What Changed +- Added assistant mode selection in the menu bar panel (`General` vs `TFT Coach`). +- Added a local TFT snapshot source in `TFTMetaContext.swift`. +- Appended TFT-specific instructions + snapshot context into the Claude system prompt when `TFT Coach` mode is active. +- Added panel status text so users can see which snapshot is currently loaded. +- Added analytics event for mode switching (`assistant_mode_selected`). +- Added `scripts/update_tft_meta_snapshot.py` to refresh the hardcoded snapshot when new patch notes release. +- Added tests for TFT prompt context/status builders. + +## Why +The app previously had only a generic assistant prompt. Even with a TFT-looking screen, it had no stable, patch-aware TFT context and no dedicated behavior mode. + +## Root Cause +- No domain mode switch existed in `CompanionManager`/`CompanionPanelView`. +- No local data source existed for current TFT patch/meta context. +- No maintenance workflow existed for patch refreshes. + +## What Was Tried and Rejected +- Initial direction was a live worker endpoint for TFT meta data. +- Rejected per request to keep this hardcoded/local for now and manually updated per patch. + +## Key Files +- `leanring-buddy/CompanionManager.swift` +- `leanring-buddy/CompanionPanelView.swift` +- `leanring-buddy/TFTMetaContext.swift` +- `leanring-buddy/ClickyAnalytics.swift` +- `leanring-buddyTests/leanring_buddyTests.swift` +- `scripts/update_tft_meta_snapshot.py` + +## Gotchas +- Auto patch discovery from the en-us tag page may lag or differ by locale. Use `--patch-url` when needed: + - `python3 scripts/update_tft_meta_snapshot.py --patch-url ` +- Snapshot is intentionally manual. If this file is stale, coach quality will drift. +- This is not live ladder ingestion; recommendations still depend on screenshot state. + +## Verified Live Surface +- Exact route used: macOS menu bar panel (`NSStatusItem` -> `CompanionPanelView`), no web route. +- Files that render it: + - `leanring-buddy/CompanionPanelView.swift` +- Code path that reaches it: + - `leanring_buddyApp.swift` -> `CompanionAppDelegate` -> `MenuBarPanelManager` -> `CompanionPanelView` +- Stale routes intentionally not touched: + - No old/duplicate web routes exist (menu bar app only). + +## Verification +Within 5 minutes, confirm with these checks: + +1. UI mode visibility +- Launch app, open panel. +- Confirm `Mode` segmented control appears with `General` and `TFT Coach`. +- Switch to `TFT Coach`. +- Confirm the status line appears: `Manual TFT snapshot: ...`. + +2. Prompt context instrumentation +- Trigger push-to-talk once in `TFT Coach`. +- Check app logs for structured prompt context event: + - grep pattern: `"event":"clicky_prompt_context"` + - expected fields include: + - `"assistantMode":"tft_coach"` + - `"transcriptCharacterCount":` + +3. Analytics signal +- In PostHog, verify event `assistant_mode_selected` is emitted with property `mode=tft_coach` and `mode=general`. + +4. Snapshot refresh script +- Run: + - `python3 scripts/update_tft_meta_snapshot.py` +- Confirm `leanring-buddy/TFTMetaContext.swift` snapshot block updates between markers: + - `// BEGIN_AUTOGEN_TFT_SNAPSHOT` + - `// END_AUTOGEN_TFT_SNAPSHOT` + +5. Unit test coverage +- Confirm tests exist for TFT prompt context/status builder in: + - `leanring-buddyTests/leanring_buddyTests.swift` diff --git a/leanring-buddy/ClickyAnalytics.swift b/leanring-buddy/ClickyAnalytics.swift index 29e26138..2f51a4c0 100644 --- a/leanring-buddy/ClickyAnalytics.swift +++ b/leanring-buddy/ClickyAnalytics.swift @@ -69,6 +69,13 @@ enum ClickyAnalytics { // MARK: - Voice Interaction + /// User switched assistant behavior mode from the panel. + static func trackAssistantModeSelected(mode: String) { + PostHogSDK.shared.capture("assistant_mode_selected", properties: [ + "mode": mode + ]) + } + /// User pressed the push-to-talk shortcut (control+option) to start talking. static func trackPushToTalkStarted() { PostHogSDK.shared.capture("push_to_talk_started") diff --git a/leanring-buddy/CompanionManager.swift b/leanring-buddy/CompanionManager.swift index 0234cf19..5add3b7b 100644 --- a/leanring-buddy/CompanionManager.swift +++ b/leanring-buddy/CompanionManager.swift @@ -21,6 +21,20 @@ enum CompanionVoiceState { case responding } +enum CompanionAssistantMode: String, CaseIterable { + case general = "general" + case tftCoach = "tft_coach" + + var panelLabel: String { + switch self { + case .general: + return "General" + case .tftCoach: + return "TFT Coach" + } + } +} + @MainActor final class CompanionManager: ObservableObject { @Published private(set) var voiceState: CompanionVoiceState = .idle @@ -109,6 +123,16 @@ final class CompanionManager: ObservableObject { /// The Claude model used for voice responses. Persisted to UserDefaults. @Published var selectedModel: String = UserDefaults.standard.string(forKey: "selectedClaudeModel") ?? "claude-sonnet-4-6" + /// The assistant behavior mode shown in the panel. + @Published var selectedAssistantMode: CompanionAssistantMode = { + guard let storedMode = UserDefaults.standard.string(forKey: "selectedAssistantMode"), + let resolvedMode = CompanionAssistantMode(rawValue: storedMode) else { + return .general + } + return resolvedMode + }() + /// Human-readable status for the TFT snapshot shown in the panel. + @Published private(set) var tftMetaStatusMessage: String = TFTMetaPromptBuilder.buildStatusMessage() func setSelectedModel(_ model: String) { selectedModel = model @@ -116,6 +140,15 @@ final class CompanionManager: ObservableObject { claudeAPI.model = model } + func setSelectedAssistantMode(_ mode: CompanionAssistantMode) { + selectedAssistantMode = mode + UserDefaults.standard.set(mode.rawValue, forKey: "selectedAssistantMode") + if mode == .tftCoach { + tftMetaStatusMessage = TFTMetaPromptBuilder.buildStatusMessage() + } + ClickyAnalytics.trackAssistantModeSelected(mode: mode.rawValue) + } + /// User preference for whether the Clicky cursor should be shown. /// When toggled off, the overlay is hidden and push-to-talk is disabled. /// Persisted to UserDefaults so the choice survives app restarts. @@ -576,6 +609,18 @@ final class CompanionManager: ObservableObject { - element is on screen 2 (not where cursor is): "that's over on your other monitor — see the terminal window? [POINT:400,300:terminal:screen2]" """ + /// Additional instructions when the assistant runs in TFT coach mode. + /// This is appended to the default companion prompt. + private static let tftCoachModePrompt = """ + you are in teamfight tactics coach mode. + + extra rules for tft coach mode: + - prioritize advice for the board and shop you currently see, then use the patch snapshot as supporting context. + - if meta guidance is uncertain, say that plainly instead of pretending confidence. + - keep recommendations actionable: what to buy, what to itemize, what to level, and which unit placement changes matter most. + - when the screenshot shows a clear next move, point directly at the relevant unit, shop slot, item, augment, or trait panel. + """ + // MARK: - AI Response Pipeline /// Captures a screenshot, sends it along with the transcript to Claude, @@ -610,9 +655,35 @@ final class CompanionManager: ObservableObject { (userPlaceholder: entry.userTranscript, assistantResponse: entry.assistantResponse) } + let resolvedSystemPrompt: String = { + guard selectedAssistantMode == .tftCoach else { + return Self.companionVoiceResponseSystemPrompt + } + + let tftContext = TFTMetaPromptBuilder.buildPromptContext() + tftMetaStatusMessage = TFTMetaPromptBuilder.buildStatusMessage() + + return Self.companionVoiceResponseSystemPrompt + + "\n\n" + + Self.tftCoachModePrompt + + "\n\nmanual tft snapshot context:\n" + + tftContext + }() + + let promptLogPayload: [String: Any] = [ + "event": "clicky_prompt_context", + "assistantMode": selectedAssistantMode.rawValue, + "transcriptCharacterCount": transcript.count, + "conversationHistoryCount": conversationHistory.count + ] + if let logData = try? JSONSerialization.data(withJSONObject: promptLogPayload), + let logMessage = String(data: logData, encoding: .utf8) { + print(logMessage) + } + let (fullResponseText, _) = try await claudeAPI.analyzeImageStreaming( images: labeledImages, - systemPrompt: Self.companionVoiceResponseSystemPrompt, + systemPrompt: resolvedSystemPrompt, conversationHistory: historyForAPI, userPrompt: transcript, onTextChunk: { _ in diff --git a/leanring-buddy/CompanionPanelView.swift b/leanring-buddy/CompanionPanelView.swift index 76789b4c..bfe4145c 100644 --- a/leanring-buddy/CompanionPanelView.swift +++ b/leanring-buddy/CompanionPanelView.swift @@ -29,8 +29,12 @@ struct CompanionPanelView: View { Spacer() .frame(height: 12) - modelPickerRow - .padding(.horizontal, 16) + VStack(spacing: 8) { + assistantModePickerRow + modelPickerRow + tftSnapshotStatusRow + } + .padding(.horizontal, 16) } if !companionManager.allPermissionsGranted { @@ -127,7 +131,9 @@ struct CompanionPanelView: View { @ViewBuilder private var permissionsCopySection: some View { if companionManager.hasCompletedOnboarding && companionManager.allPermissionsGranted { - Text("Hold Control+Option to talk.") + Text(companionManager.selectedAssistantMode == .tftCoach + ? "Hold Control+Option to ask for TFT coaching." + : "Hold Control+Option to talk.") .font(.system(size: 12, weight: .medium)) .foregroundColor(DS.Colors.textSecondary) .frame(maxWidth: .infinity, alignment: .leading) @@ -598,6 +604,49 @@ struct CompanionPanelView: View { // MARK: - Model Picker + private var assistantModePickerRow: some View { + HStack { + Text("Mode") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(DS.Colors.textSecondary) + + Spacer() + + HStack(spacing: 0) { + assistantModeOptionButton(mode: .general) + assistantModeOptionButton(mode: .tftCoach) + } + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.white.opacity(0.06)) + ) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(DS.Colors.borderSubtle, lineWidth: 0.5) + ) + } + .padding(.vertical, 4) + } + + private func assistantModeOptionButton(mode: CompanionAssistantMode) -> some View { + let isSelected = companionManager.selectedAssistantMode == mode + return Button(action: { + companionManager.setSelectedAssistantMode(mode) + }) { + Text(mode.panelLabel) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(isSelected ? DS.Colors.textPrimary : DS.Colors.textTertiary) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background( + RoundedRectangle(cornerRadius: 5, style: .continuous) + .fill(isSelected ? Color.white.opacity(0.1) : Color.clear) + ) + } + .buttonStyle(.plain) + .pointerCursor() + } + private var modelPickerRow: some View { HStack { Text("Model") @@ -641,6 +690,23 @@ struct CompanionPanelView: View { .pointerCursor() } + @ViewBuilder + private var tftSnapshotStatusRow: some View { + if companionManager.selectedAssistantMode == .tftCoach { + HStack(spacing: 8) { + Image(systemName: "info.circle") + .font(.system(size: 11, weight: .medium)) + .foregroundColor(DS.Colors.textTertiary) + + Text(companionManager.tftMetaStatusMessage) + .font(.system(size: 10)) + .foregroundColor(DS.Colors.textTertiary) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.vertical, 2) + } + } + // MARK: - DM Farza Button private var dmFarzaButton: some View { diff --git a/leanring-buddy/TFTMetaContext.swift b/leanring-buddy/TFTMetaContext.swift new file mode 100644 index 00000000..0d9c8f7d --- /dev/null +++ b/leanring-buddy/TFTMetaContext.swift @@ -0,0 +1,59 @@ +// +// TFTMetaContext.swift +// leanring-buddy +// +// Local, manually refreshed TFT patch/meta snapshot. This keeps the app fully +// self-contained while still giving the assistant recent TFT context. +// + +import Foundation + +struct TFTMetaContextSnapshot: Equatable { + let snapshotUpdatedOn: String + let latestPatchTitle: String + let latestPatchPublishedAt: String + let latestPatchURL: String + let dataDragonVersion: String + let metaNotes: [String] +} + +enum TFTMetaKnowledgeBase { + // BEGIN_AUTOGEN_TFT_SNAPSHOT + static let currentSnapshot = TFTMetaContextSnapshot( + snapshotUpdatedOn: "2026-04-13", + latestPatchTitle: "Teamfight Tactics patch 16.8", + latestPatchPublishedAt: "2026-03-31T18:00:00.000Z", + latestPatchURL: "https://teamfighttactics.leagueoflegends.com/en-us/news/game-updates/teamfight-tactics-patch-16-8", + dataDragonVersion: "16.7.1", + metaNotes: [ + "This is a manually maintained snapshot, not a live API feed. State confidence when meta calls are uncertain.", + "Patch notes are the source of truth for current balance direction.", + "Use the current board/shop/items shown on screen to make final recommendations.", + ] + ) + // END_AUTOGEN_TFT_SNAPSHOT +} + +enum TFTMetaPromptBuilder { + static func buildPromptContext() -> String { + let snapshot = TFTMetaKnowledgeBase.currentSnapshot + let notes = snapshot.metaNotes.enumerated().map { index, value in + "\(index + 1). \(value)" + }.joined(separator: "\n") + + return """ + TFT SNAPSHOT (manual) + Snapshot last updated: \(snapshot.snapshotUpdatedOn) + Latest official patch notes: \(snapshot.latestPatchTitle) (\(snapshot.latestPatchPublishedAt)) + Patch notes URL: \(snapshot.latestPatchURL) + Latest Data Dragon version: \(snapshot.dataDragonVersion) + Meta notes: + \(notes) + """ + } + + static func buildStatusMessage() -> String { + let snapshot = TFTMetaKnowledgeBase.currentSnapshot + return "Manual TFT snapshot: \(snapshot.latestPatchTitle), updated \(snapshot.snapshotUpdatedOn)" + } +} diff --git a/leanring-buddyTests/leanring_buddyTests.swift b/leanring-buddyTests/leanring_buddyTests.swift index 188fe7ae..af6ff959 100644 --- a/leanring-buddyTests/leanring_buddyTests.swift +++ b/leanring-buddyTests/leanring_buddyTests.swift @@ -37,4 +37,19 @@ struct leanring_buddyTests { #expect(shouldTreatPermissionAsGranted) } + @Test func tftPromptContextIncludesPatchAndDataDragonVersion() async throws { + let promptContext = TFTMetaPromptBuilder.buildPromptContext() + let snapshot = TFTMetaKnowledgeBase.currentSnapshot + + #expect(promptContext.contains(snapshot.latestPatchTitle)) + #expect(promptContext.contains(snapshot.dataDragonVersion)) + #expect(promptContext.contains("TFT SNAPSHOT (manual)")) + } + + @Test func tftStatusMessageMarksSnapshotAsManual() async throws { + let statusMessage = TFTMetaPromptBuilder.buildStatusMessage() + + #expect(statusMessage.contains("Manual TFT snapshot")) + } + } diff --git a/scripts/update_tft_meta_snapshot.py b/scripts/update_tft_meta_snapshot.py new file mode 100644 index 00000000..d9055947 --- /dev/null +++ b/scripts/update_tft_meta_snapshot.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +""" +Refreshes the hardcoded TFT snapshot in leanring-buddy/TFTMetaContext.swift. + +Usage: + python3 scripts/update_tft_meta_snapshot.py + python3 scripts/update_tft_meta_snapshot.py --meta-note "Your custom note" +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import json +import re +import urllib.request +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +SWIFT_FILE = REPO_ROOT / "leanring-buddy" / "TFTMetaContext.swift" +PATCH_INDEX_URLS = [ + "https://teamfighttactics.leagueoflegends.com/en-us/news/game-updates/", + "https://teamfighttactics.leagueoflegends.com/en-ph/news/game-updates/", +] +DDRAGON_VERSIONS_URL = "https://ddragon.leagueoflegends.com/api/versions.json" + +BEGIN_MARKER = "// BEGIN_AUTOGEN_TFT_SNAPSHOT" +END_MARKER = "// END_AUTOGEN_TFT_SNAPSHOT" + +DEFAULT_META_NOTES = [ + "This is a manually maintained snapshot, not a live API feed. State confidence when meta calls are uncertain.", + "Patch notes are the source of truth for current balance direction.", + "Use the current board/shop/items shown on screen to make final recommendations.", +] + + +def fetch_text(url: str) -> str: + request = urllib.request.Request( + url, + headers={"User-Agent": "clicky-tft-snapshot-updater/1.0"}, + ) + with urllib.request.urlopen(request, timeout=20) as response: + return response.read().decode("utf-8", errors="replace") + + +def fetch_latest_patch_url() -> str: + discovered_urls: list[str] = [] + for source_url in PATCH_INDEX_URLS: + html = fetch_text(source_url) + + absolute_matches = re.findall( + r"https://teamfighttactics\.leagueoflegends\.com/[a-z-]+/news/game-updates/teamfight-tactics-patch-[^\"<\s]+/?", + html, + flags=re.IGNORECASE, + ) + discovered_urls.extend(absolute_matches) + + relative_matches = re.findall( + r"/[a-z-]+/news/game-updates/teamfight-tactics-patch-[^\"<\s]+/?", + html, + flags=re.IGNORECASE, + ) + discovered_urls.extend(f"https://teamfighttactics.leagueoflegends.com{path}" for path in relative_matches) + + if not discovered_urls: + raise RuntimeError("Could not find a TFT patch URL from index pages.") + + # Keep original order and deduplicate. + deduped = list(dict.fromkeys(discovered_urls)) + + def patch_version_key(url: str) -> tuple[int, int]: + match = re.search(r"teamfight-tactics-patch-(\d+)-(\d+)", url) + if not match: + return (0, 0) + return (int(match.group(1)), int(match.group(2))) + + sorted_urls = sorted(deduped, key=patch_version_key, reverse=True) + return sorted_urls[0] + + +def fetch_patch_details(patch_url: str) -> tuple[str, str]: + html = fetch_text(patch_url) + + title_match = re.search( + r"]*>\s*(Teamfight Tactics patch[^<]+)\s*", + html, + flags=re.IGNORECASE, + ) + if title_match: + patch_title = " ".join(title_match.group(1).split()) + else: + slug_match = re.search(r"teamfight-tactics-patch-([a-z0-9.-]+)", patch_url, flags=re.IGNORECASE) + if not slug_match: + raise RuntimeError("Could not determine patch title.") + patch_title = f"Teamfight Tactics patch {slug_match.group(1).replace('-', '.').upper()}" + + published_match = re.search(r"(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.000Z)", html) + patch_published_at = published_match.group(1) if published_match else "unknown" + + return patch_title, patch_published_at + + +def fetch_data_dragon_version() -> str: + payload = fetch_text(DDRAGON_VERSIONS_URL) + versions = json.loads(payload) + if not isinstance(versions, list) or not versions: + raise RuntimeError("Data Dragon versions response is empty.") + return str(versions[0]) + + +def swift_escape(value: str) -> str: + return value.replace("\\", "\\\\").replace("\"", "\\\"") + + +def build_snapshot_block( + snapshot_date: str, + patch_title: str, + patch_published_at: str, + patch_url: str, + ddragon_version: str, + meta_notes: list[str], +) -> str: + escaped_notes = "\n".join( + f' "{swift_escape(note)}",' + for note in meta_notes + ) + + return f"""{BEGIN_MARKER} + static let currentSnapshot = TFTMetaContextSnapshot( + snapshotUpdatedOn: "{swift_escape(snapshot_date)}", + latestPatchTitle: "{swift_escape(patch_title)}", + latestPatchPublishedAt: "{swift_escape(patch_published_at)}", + latestPatchURL: "{swift_escape(patch_url)}", + dataDragonVersion: "{swift_escape(ddragon_version)}", + metaNotes: [ +{escaped_notes} + ] + ) + {END_MARKER}""" + + +def replace_snapshot_block(file_text: str, new_block: str) -> str: + pattern = re.compile( + re.escape(BEGIN_MARKER) + r".*?" + re.escape(END_MARKER), + flags=re.DOTALL, + ) + if not pattern.search(file_text): + raise RuntimeError("Could not find TFT snapshot markers in TFTMetaContext.swift.") + return pattern.sub(new_block, file_text) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Refresh hardcoded TFT snapshot data.") + parser.add_argument("--snapshot-date", dest="snapshot_date", help="YYYY-MM-DD date for snapshot update marker.") + parser.add_argument("--patch-title", dest="patch_title", help="Override patch title.") + parser.add_argument("--patch-published-at", dest="patch_published_at", help="Override patch published timestamp.") + parser.add_argument("--patch-url", dest="patch_url", help="Override patch URL.") + parser.add_argument("--ddragon-version", dest="ddragon_version", help="Override Data Dragon version.") + parser.add_argument("--meta-note", dest="meta_notes", action="append", help="Custom meta note. Repeat for multiple notes.") + args = parser.parse_args() + + patch_url = args.patch_url or fetch_latest_patch_url() + patch_title, patch_published_at = fetch_patch_details(patch_url) + if args.patch_title: + patch_title = args.patch_title + if args.patch_published_at: + patch_published_at = args.patch_published_at + + ddragon_version = args.ddragon_version or fetch_data_dragon_version() + snapshot_date = args.snapshot_date or dt.datetime.now(dt.timezone.utc).date().isoformat() + meta_notes = args.meta_notes if args.meta_notes else DEFAULT_META_NOTES + + file_text = SWIFT_FILE.read_text(encoding="utf-8") + new_block = build_snapshot_block( + snapshot_date=snapshot_date, + patch_title=patch_title, + patch_published_at=patch_published_at, + patch_url=patch_url, + ddragon_version=ddragon_version, + meta_notes=meta_notes, + ) + updated_text = replace_snapshot_block(file_text, new_block) + SWIFT_FILE.write_text(updated_text, encoding="utf-8") + + print("Updated TFT snapshot.") + print(f" Patch title: {patch_title}") + print(f" Patch URL: {patch_url}") + print(f" Published at: {patch_published_at}") + print(f" Data Dragon version: {ddragon_version}") + print(f" Snapshot date: {snapshot_date}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 2c74b771e48f923041f643767114d1d62c811da0 Mon Sep 17 00:00:00 2001 From: wpeterr Date: Mon, 13 Apr 2026 10:44:58 +0700 Subject: [PATCH 2/2] Use local Developer ID signing for Clicky target --- leanring-buddy.xcodeproj/project.pbxproj | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/leanring-buddy.xcodeproj/project.pbxproj b/leanring-buddy.xcodeproj/project.pbxproj index 75e57261..9fa0cefa 100644 --- a/leanring-buddy.xcodeproj/project.pbxproj +++ b/leanring-buddy.xcodeproj/project.pbxproj @@ -408,10 +408,11 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = "leanring-buddy/leanring-buddy.entitlements"; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_IDENTITY = "Developer ID Application"; + CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 2UDAY4J48G; + DEVELOPMENT_TEAM = 43ZGN39D84; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; @@ -446,10 +447,11 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = "leanring-buddy/leanring-buddy.entitlements"; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_IDENTITY = "Developer ID Application"; + CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 2UDAY4J48G; + DEVELOPMENT_TEAM = 43ZGN39D84; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;