extremely claude-assisted go game based on atproto! working on cleaning up and giving a more unique design, still has a bit of a slop vibe to it.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

added sound effects and "action" lexicon

+1092
+40
lexicons/boo.sky.go.action.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "boo.sky.go.action", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "record": { 9 + "type": "object", 10 + "required": ["game", "player", "action", "createdAt"], 11 + "properties": { 12 + "game": { 13 + "type": "string", 14 + "description": "AT URI of the game this action belongs to" 15 + }, 16 + "player": { 17 + "type": "string", 18 + "format": "did", 19 + "description": "DID of the player performing this action" 20 + }, 21 + "action": { 22 + "type": "string", 23 + "enum": ["join", "pass", "resign"], 24 + "description": "Type of action being performed" 25 + }, 26 + "reason": { 27 + "type": "string", 28 + "maxLength": 500, 29 + "description": "Optional reason for the action (e.g., resignation reason)" 30 + }, 31 + "createdAt": { 32 + "type": "string", 33 + "format": "datetime", 34 + "description": "Timestamp when this action was created" 35 + } 36 + } 37 + } 38 + } 39 + } 40 + }
+272
src/lib/components/ProverbBanner.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import { browser } from '$app/environment'; 4 + import proverbs from '$lib/go-proverbs.json'; 5 + 6 + let isDismissed = $state(false); 7 + let proverb = $state<{ text: string; url: string; category: string } | null>(null); 8 + 9 + function getDailyProverb() { 10 + // Use the current date as a seed to get the same proverb all day 11 + const today = new Date(); 12 + const dayOfYear = Math.floor((today.getTime() - new Date(today.getFullYear(), 0, 0).getTime()) / 86400000); 13 + const index = dayOfYear % proverbs.length; 14 + return proverbs[index]; 15 + } 16 + 17 + function getCookie(name: string): string | null { 18 + if (!browser) return null; 19 + const value = `; ${document.cookie}`; 20 + const parts = value.split(`; ${name}=`); 21 + if (parts.length === 2) return parts.pop()?.split(';').shift() || null; 22 + return null; 23 + } 24 + 25 + function setCookie(name: string, value: string, days: number) { 26 + if (!browser) return; 27 + const expires = new Date(); 28 + expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000); 29 + document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/`; 30 + } 31 + 32 + function dismiss() { 33 + isDismissed = true; 34 + const today = new Date().toISOString().split('T')[0]; 35 + setCookie('proverb-dismissed', today, 1); 36 + } 37 + 38 + onMount(() => { 39 + proverb = getDailyProverb(); 40 + 41 + // Check if proverb was dismissed today 42 + const dismissedDate = getCookie('proverb-dismissed'); 43 + const today = new Date().toISOString().split('T')[0]; 44 + 45 + if (dismissedDate === today) { 46 + isDismissed = true; 47 + } 48 + }); 49 + </script> 50 + 51 + {#if proverb && !isDismissed} 52 + <div class="proverb-banner"> 53 + <div class="scroll"> 54 + 55 + <div class="scroll-body"> 56 + <div class="scroll-content"> 57 + <div class="proverb-category">{proverb.category}</div> 58 + <a href={proverb.url} target="_blank" rel="noopener noreferrer" class="proverb-text"> 59 + "{proverb.text}" 60 + </a> 61 + <div class="proverb-hint">Click to learn more →</div> 62 + </div> 63 + <button class="dismiss-btn" onclick={dismiss} aria-label="Dismiss proverb"> 64 + 65 + </button> 66 + </div> 67 + 68 + </div> 69 + </div> 70 + {/if} 71 + 72 + <style> 73 + .proverb-banner { 74 + display: flex; 75 + justify-content: center; 76 + margin-bottom: 1.5rem; 77 + animation: unfurl 0.8s ease-out; 78 + } 79 + 80 + @keyframes unfurl { 81 + from { 82 + opacity: 0; 83 + transform: translateY(-20px) scale(0.95); 84 + } 85 + to { 86 + opacity: 1; 87 + transform: translateY(0) scale(1); 88 + } 89 + } 90 + 91 + .scroll { 92 + position: relative; 93 + 94 + width: 100%; 95 + display: flex; 96 + flex-direction: column; 97 + align-items: center; 98 + } 99 + 100 + .scroll-top, .scroll-bottom { 101 + height: 16px; 102 + width: 105%; 103 + background: 104 + linear-gradient(90deg, 105 + transparent 0%, 106 + #d4c4a8 3%, 107 + #e8d4b8 50%, 108 + #d4c4a8 97%, 109 + transparent 100% 110 + ), 111 + linear-gradient(180deg, #f5e6d3 0%, #e8d4b8 50%, #d4c4a8 100%); 112 + box-shadow: 113 + inset 0 3px 6px rgba(255, 255, 255, 0.5), 114 + inset 0 -2px 4px rgba(0, 0, 0, 0.15), 115 + 0 3px 8px rgba(0, 0, 0, 0.15); 116 + position: relative; 117 + } 118 + 119 + .scroll-top { 120 + clip-path: ellipse(50% 100% at 50% 0%); 121 + margin-bottom: -2px; 122 + } 123 + 124 + .scroll-bottom { 125 + clip-path: ellipse(50% 100% at 50% 100%); 126 + margin-top: -2px; 127 + } 128 + 129 + .scroll-top::before, 130 + .scroll-top::after, 131 + .scroll-bottom::before, 132 + .scroll-bottom::after { 133 + content: ''; 134 + position: absolute; 135 + width: 6px; 136 + height: 6px; 137 + background: radial-gradient(circle, rgba(139, 115, 85, 0.4), transparent); 138 + border-radius: 50%; 139 + } 140 + 141 + .scroll-top::before { left: 12%; top: 50%; } 142 + .scroll-top::after { right: 12%; top: 50%; } 143 + .scroll-bottom::before { left: 12%; bottom: 50%; } 144 + .scroll-bottom::after { right: 12%; bottom: 50%; } 145 + 146 + .scroll-body { 147 + position: relative; 148 + width: 100%; 149 + background: linear-gradient( 150 + to right, 151 + #f5e6d3 0%, 152 + #f9ead8 5%, 153 + #fdf5eb 50%, 154 + #f9ead8 95%, 155 + #f5e6d3 100% 156 + ); 157 + padding: 1.5rem 3rem 1.5rem 2rem; 158 + box-shadow: 159 + inset 2px 0 3px rgba(0, 0, 0, 0.05), 160 + inset -2px 0 3px rgba(0, 0, 0, 0.05), 161 + 0 4px 12px rgba(90, 122, 144, 0.1); 162 + border-left: 1px solid rgba(212, 196, 168, 0.5); 163 + border-right: 1px solid rgba(212, 196, 168, 0.5); 164 + position: relative; 165 + overflow: hidden; 166 + } 167 + 168 + .scroll-body::before { 169 + content: ''; 170 + position: absolute; 171 + inset: 0; 172 + background: 173 + repeating-linear-gradient( 174 + 0deg, 175 + transparent, 176 + transparent 19px, 177 + rgba(139, 115, 85, 0.03) 19px, 178 + rgba(139, 115, 85, 0.03) 20px 179 + ); 180 + pointer-events: none; 181 + } 182 + 183 + .scroll-body::after { 184 + content: ''; 185 + position: absolute; 186 + inset: 0; 187 + background: 188 + radial-gradient(ellipse at 20% 30%, rgba(139, 115, 85, 0.02), transparent 50%), 189 + radial-gradient(ellipse at 80% 70%, rgba(139, 115, 85, 0.015), transparent 50%), 190 + radial-gradient(ellipse at 40% 80%, rgba(139, 115, 85, 0.02), transparent 40%); 191 + pointer-events: none; 192 + } 193 + 194 + .scroll-content { 195 + position: relative; 196 + text-align: center; 197 + z-index: 1; 198 + } 199 + 200 + .proverb-category { 201 + font-size: 0.7rem; 202 + text-transform: uppercase; 203 + letter-spacing: 0.1em; 204 + color: #8B7355; 205 + font-weight: 600; 206 + margin-bottom: 0.5rem; 207 + opacity: 0.7; 208 + } 209 + 210 + .proverb-text { 211 + display: block; 212 + font-family: Georgia, 'Times New Roman', serif; 213 + font-size: 1.1rem; 214 + line-height: 1.6; 215 + color: #3a2f23; 216 + text-decoration: none; 217 + font-style: italic; 218 + transition: color 0.3s ease; 219 + margin-bottom: 0.25rem; 220 + } 221 + 222 + .proverb-text:hover { 223 + color: var(--sky-apricot-dark); 224 + } 225 + 226 + .proverb-hint { 227 + font-size: 0.7rem; 228 + color: #8B7355; 229 + opacity: 0; 230 + transition: opacity 0.3s ease; 231 + } 232 + 233 + .proverb-text:hover + .proverb-hint, 234 + .scroll:hover .proverb-hint { 235 + opacity: 0.6; 236 + } 237 + 238 + .dismiss-btn { 239 + position: absolute; 240 + top: 0.5rem; 241 + right: 0.5rem; 242 + background: rgba(139, 115, 85, 0.1); 243 + border: 1px solid rgba(139, 115, 85, 0.2); 244 + border-radius: 50%; 245 + width: 24px; 246 + height: 24px; 247 + display: flex; 248 + align-items: center; 249 + justify-content: center; 250 + cursor: pointer; 251 + color: #8B7355; 252 + font-size: 0.9rem; 253 + transition: all 0.3s ease; 254 + z-index: 2; 255 + } 256 + 257 + .dismiss-btn:hover { 258 + background: rgba(139, 115, 85, 0.2); 259 + border-color: rgba(139, 115, 85, 0.4); 260 + transform: rotate(90deg); 261 + } 262 + 263 + @media (max-width: 640px) { 264 + .scroll-body { 265 + padding: 1.25rem 2.5rem 1.25rem 1.5rem; 266 + } 267 + 268 + .proverb-text { 269 + font-size: 1rem; 270 + } 271 + } 272 + </style>
+512
src/lib/go-proverbs.json
··· 1 + [ 2 + { 3 + "text": "The enemy's key point is yours", 4 + "url": "https://senseis.xmp.net/?TheEnemysKeyPointIsYours", 5 + "category": "All Situations" 6 + }, 7 + { 8 + "text": "Play on the point of symmetry", 9 + "url": "https://senseis.xmp.net/?PlayOnThePointOfSymmetry", 10 + "category": "All Situations" 11 + }, 12 + { 13 + "text": "Sente gains nothing", 14 + "url": "https://senseis.xmp.net/?SenteGainsNothing", 15 + "category": "All Situations" 16 + }, 17 + { 18 + "text": "Beware of going back to patch up", 19 + "url": "https://senseis.xmp.net/?BewareOfGoingBackToPatchUp", 20 + "category": "All Situations" 21 + }, 22 + { 23 + "text": "When in doubt, Tenuki", 24 + "url": "https://senseis.xmp.net/?WhenInDoubtTenuki", 25 + "category": "All Situations" 26 + }, 27 + { 28 + "text": "People in glass houses shouldn't throw stones", 29 + "url": "https://senseis.xmp.net/?PeopleInGlassHousesShouldntThrowStones", 30 + "category": "All Situations" 31 + }, 32 + { 33 + "text": "There is death in the hane", 34 + "url": "https://senseis.xmp.net/?ThereIsDeathInTheHane", 35 + "category": "Life and Death" 36 + }, 37 + { 38 + "text": "Hane, Cut, Placement", 39 + "url": "https://senseis.xmp.net/?HaneCutPlacement", 40 + "category": "Life and Death" 41 + }, 42 + { 43 + "text": "Learn the eyestealing tesuji", 44 + "url": "https://senseis.xmp.net/?LearnTheEyestealingTesuji", 45 + "category": "Life and Death" 46 + }, 47 + { 48 + "text": "Capture three to get an eye", 49 + "url": "https://senseis.xmp.net/?CaptureThreeToGetAnEye", 50 + "category": "Life and Death" 51 + }, 52 + { 53 + "text": "Six die but eight live", 54 + "url": "https://senseis.xmp.net/?SixDieButEightLive", 55 + "category": "Life and Death" 56 + }, 57 + { 58 + "text": "Four die but six live", 59 + "url": "https://senseis.xmp.net/?FourDieButSixLive", 60 + "category": "Life and Death" 61 + }, 62 + { 63 + "text": "Four is five and five is eight and six is twelve", 64 + "url": "https://senseis.xmp.net/?FourIsFiveAndFiveIsEightAndSixIsTwelve", 65 + "category": "Life and Death" 66 + }, 67 + { 68 + "text": "The carpenter's square becomes ko", 69 + "url": "https://senseis.xmp.net/?CarpentersSquare", 70 + "category": "Life and Death" 71 + }, 72 + { 73 + "text": "The L group is dead", 74 + "url": "https://senseis.xmp.net/?LGroup", 75 + "category": "Life and Death" 76 + }, 77 + { 78 + "text": "The door group is dead", 79 + "url": "https://senseis.xmp.net/?DoorGroup", 80 + "category": "Life and Death" 81 + }, 82 + { 83 + "text": "Strange things happen at the one two point", 84 + "url": "https://senseis.xmp.net/?StrangeThingsHappenAtTheOneTwoPoint", 85 + "category": "Life and Death" 86 + }, 87 + { 88 + "text": "Eyes win semeais", 89 + "url": "https://senseis.xmp.net/?EyesWinSemeais", 90 + "category": "Life and Death" 91 + }, 92 + { 93 + "text": "Check escape routes first", 94 + "url": "https://senseis.xmp.net/?CheckEscapeRoutesFirst", 95 + "category": "Life and Death" 96 + }, 97 + { 98 + "text": "Only enclosed groups can be killed", 99 + "url": "https://senseis.xmp.net/?OnlyEnclosedGroupsCanBeKilled", 100 + "category": "Life and Death" 101 + }, 102 + { 103 + "text": "A three-move approach ko is not a ko", 104 + "url": "https://senseis.xmp.net/?ApproachKo", 105 + "category": "Life and Death" 106 + }, 107 + { 108 + "text": "Respond to attachment with hane", 109 + "url": "https://senseis.xmp.net/?RespondToAttachmentWithHane", 110 + "category": "Tactics" 111 + }, 112 + { 113 + "text": "Hane at the Head of Two Stones", 114 + "url": "https://senseis.xmp.net/?HaneAtTheHeadOfTwoStones", 115 + "category": "Tactics" 116 + }, 117 + { 118 + "text": "Crosscut then extend", 119 + "url": "https://senseis.xmp.net/?CrosscutThenExtend", 120 + "category": "Tactics" 121 + }, 122 + { 123 + "text": "Capture the cutting stones", 124 + "url": "https://senseis.xmp.net/?CaptureTheCuttingStones", 125 + "category": "Tactics" 126 + }, 127 + { 128 + "text": "Beginners play atari", 129 + "url": "https://senseis.xmp.net/?BeginnersPlayAtari", 130 + "category": "Tactics" 131 + }, 132 + { 133 + "text": "The empty triangle is bad", 134 + "url": "https://senseis.xmp.net/?TheEmptyTriangleIsBad", 135 + "category": "Tactics" 136 + }, 137 + { 138 + "text": "The one-point jump is never bad", 139 + "url": "https://senseis.xmp.net/?IkkenTobiIsNeverWrong", 140 + "category": "Tactics" 141 + }, 142 + { 143 + "text": "Don't try to cut the one-point jump", 144 + "url": "https://senseis.xmp.net/?DontTryToCutTheOnePointJump", 145 + "category": "Tactics" 146 + }, 147 + { 148 + "text": "From one, two. From two, three", 149 + "url": "https://senseis.xmp.net/?ExtensionFromAWall", 150 + "category": "Tactics" 151 + }, 152 + { 153 + "text": "Strike at the waist of the keima", 154 + "url": "https://senseis.xmp.net/?StrikeAtTheWaistOfTheKeima", 155 + "category": "Tactics" 156 + }, 157 + { 158 + "text": "Cutting right through a knight's move is very big", 159 + "url": "https://senseis.xmp.net/?CuttingRightThroughAKnightsMoveIsVeryBig", 160 + "category": "Tactics" 161 + }, 162 + { 163 + "text": "Do not peep at cutting points", 164 + "url": "https://senseis.xmp.net/?DoNotPeepAtCuttingPoints", 165 + "category": "Tactics" 166 + }, 167 + { 168 + "text": "Even a moron connects against a peep", 169 + "url": "https://senseis.xmp.net/?EvenAMoronConnectsAgainstAPeep", 170 + "category": "Tactics" 171 + }, 172 + { 173 + "text": "Add a second stone and sacrifice both", 174 + "url": "https://senseis.xmp.net/?AddASecondStoneAndSacrificeBoth", 175 + "category": "Tactics" 176 + }, 177 + { 178 + "text": "Use contact moves for defence", 179 + "url": "https://senseis.xmp.net/?UseContactMovesForDefence", 180 + "category": "Tactics" 181 + }, 182 + { 183 + "text": "Never ignore a shoulder hit", 184 + "url": "https://senseis.xmp.net/?NeverIgnoreAShoulderHit", 185 + "category": "Tactics" 186 + }, 187 + { 188 + "text": "The bamboo joint may be short of liberties", 189 + "url": "https://senseis.xmp.net/?TheBambooJointMayBeShortOfLiberties", 190 + "category": "Tactics" 191 + }, 192 + { 193 + "text": "Nets are better than ladders", 194 + "url": "https://senseis.xmp.net/?NetsAreBetterThanLadders", 195 + "category": "Tactics" 196 + }, 197 + { 198 + "text": "Answer the capping play with a knight's move", 199 + "url": "https://senseis.xmp.net/?AnswerTheCappingPlayWithAKnightsMove", 200 + "category": "Tactics" 201 + }, 202 + { 203 + "text": "Approach from the wider side", 204 + "url": "https://senseis.xmp.net/?ApproachFromTheWiderSide", 205 + "category": "Tactics" 206 + }, 207 + { 208 + "text": "Block on the wider side", 209 + "url": "https://senseis.xmp.net/?BlockOnTheWiderSide", 210 + "category": "Tactics" 211 + }, 212 + { 213 + "text": "Play at the centre of three stones", 214 + "url": "https://senseis.xmp.net/?PlayAtTheCentreOfThreeStones", 215 + "category": "Tactics" 216 + }, 217 + { 218 + "text": "Answer keima with kosumi", 219 + "url": "https://senseis.xmp.net/?AnswerKeimaWithKosumi", 220 + "category": "Tactics" 221 + }, 222 + { 223 + "text": "Five liberties for tactical stability", 224 + "url": "https://senseis.xmp.net/?FiveLibertiesForTacticalStability", 225 + "category": "Tactics" 226 + }, 227 + { 228 + "text": "Capture stones caught in a ladder at the earliest opportunity", 229 + "url": "https://senseis.xmp.net/?CaptureStonesCaughtInALadderAtTheEarliestOpportunity", 230 + "category": "Tactics" 231 + }, 232 + { 233 + "text": "Two hanes gain a liberty", 234 + "url": "https://senseis.xmp.net/?TwoHanesGainALiberty", 235 + "category": "Tactics" 236 + }, 237 + { 238 + "text": "The strong player plays straight, the weak plays diagonal", 239 + "url": "https://senseis.xmp.net/?TheStrongPlayerPlaysStraightTheWeakPlaysDiagonal", 240 + "category": "Tactics" 241 + }, 242 + { 243 + "text": "There is no connection in the carpenter's triangle", 244 + "url": "https://senseis.xmp.net/?ThereIsNoConnectionInTheCarpentersTriangle", 245 + "category": "Tactics" 246 + }, 247 + { 248 + "text": "Play double sente early", 249 + "url": "https://senseis.xmp.net/?PlayDoubleSenteEarly", 250 + "category": "Tactics" 251 + }, 252 + { 253 + "text": "Remove mutual ko threats before the ko", 254 + "url": "https://senseis.xmp.net/?RemoveDoubleThreatsBeforeYouFirstCaptureTheKo", 255 + "category": "Tactics" 256 + }, 257 + { 258 + "text": "Urgent points before big points", 259 + "url": "https://senseis.xmp.net/?UrgentPointsBeforeBigPoints", 260 + "category": "Strategy" 261 + }, 262 + { 263 + "text": "Play away from thickness", 264 + "url": "https://senseis.xmp.net/?PlayAwayFromThickness", 265 + "category": "Strategy" 266 + }, 267 + { 268 + "text": "Don't use thickness to make territory", 269 + "url": "https://senseis.xmp.net/?DontUseThicknessToMakeTerritory", 270 + "category": "Strategy" 271 + }, 272 + { 273 + "text": "Make territory while attacking", 274 + "url": "https://senseis.xmp.net/?MakeTerritoryWhileAttacking", 275 + "category": "Strategy" 276 + }, 277 + { 278 + "text": "A ponnuki is worth thirty points", 279 + "url": "https://senseis.xmp.net/?APonnukiIsWorthThirtyPoints", 280 + "category": "Strategy" 281 + }, 282 + { 283 + "text": "Make a fist before striking", 284 + "url": "https://senseis.xmp.net/?MakeAFistBeforeStriking", 285 + "category": "Strategy" 286 + }, 287 + { 288 + "text": "Do not defend territories open on two sides", 289 + "url": "https://senseis.xmp.net/?DoNotDefendTerritoriesOpenOnTwoSides", 290 + "category": "Strategy" 291 + }, 292 + { 293 + "text": "Attach to the stronger stone in a pincer", 294 + "url": "https://senseis.xmp.net/?AttachToTheStrongerStoneInAPincer", 295 + "category": "Strategy" 296 + }, 297 + { 298 + "text": "Make a feint to the east while attacking in the west", 299 + "url": "https://senseis.xmp.net/?MakeAFeintToTheEastWhileAttackingInTheWest", 300 + "category": "Strategy" 301 + }, 302 + { 303 + "text": "A rich man should not pick quarrels", 304 + "url": "https://senseis.xmp.net/?ARichManShouldNotPickQuarrels", 305 + "category": "Strategy" 306 + }, 307 + { 308 + "text": "Play kikashi before living", 309 + "url": "https://senseis.xmp.net/?PlayKikashiBeforeLiving", 310 + "category": "Strategy" 311 + }, 312 + { 313 + "text": "Reduction Is Worth As Much As An Invasion", 314 + "url": "https://senseis.xmp.net/?ReductionIsWorthAsMuchAsAnInvasion", 315 + "category": "Strategy" 316 + }, 317 + { 318 + "text": "Invade a moyo one move before it becomes territory", 319 + "url": "https://senseis.xmp.net/?InvadeAMoyoOneMoveBeforeItBecomesTerritory", 320 + "category": "Strategy" 321 + }, 322 + { 323 + "text": "Don't attach when attacking", 324 + "url": "https://senseis.xmp.net/?DontAttachWhenAttacking", 325 + "category": "Strategy" 326 + }, 327 + { 328 + "text": "Don't touch weak stones", 329 + "url": "https://senseis.xmp.net/?DontTouchWeakStones", 330 + "category": "Strategy" 331 + }, 332 + { 333 + "text": "Make weak walk along with weak", 334 + "url": "https://senseis.xmp.net/?MakeWeakWalkAlongWithWeak", 335 + "category": "Strategy" 336 + }, 337 + { 338 + "text": "Five groups might live but the sixth will die", 339 + "url": "https://senseis.xmp.net/?FiveGroupsMightLiveButTheSixthWillDie", 340 + "category": "Strategy" 341 + }, 342 + { 343 + "text": "Big dragons never die", 344 + "url": "https://senseis.xmp.net/?BigDragonsNeverDie", 345 + "category": "Strategy" 346 + }, 347 + { 348 + "text": "Grab the shape points in kikashi", 349 + "url": "https://senseis.xmp.net/?GrabTheShapePointsInKikashi", 350 + "category": "Strategy" 351 + }, 352 + { 353 + "text": "Give your opponent what he wants", 354 + "url": "https://senseis.xmp.net/?GiveYourOpponentWhatHeWants", 355 + "category": "Strategy" 356 + }, 357 + { 358 + "text": "Avoid ippoji", 359 + "url": "https://senseis.xmp.net/?AvoidIppoji", 360 + "category": "Strategy" 361 + }, 362 + { 363 + "text": "Sacrifice plums for peaches", 364 + "url": "https://senseis.xmp.net/?SacrificePlumsForPeaches", 365 + "category": "Strategy" 366 + }, 367 + { 368 + "text": "Don't trade a dollar for a penny", 369 + "url": "https://senseis.xmp.net/?DontTradeADollarForAPenny", 370 + "category": "Strategy" 371 + }, 372 + { 373 + "text": "Don't throw an egg at a wall", 374 + "url": "https://senseis.xmp.net/?DontThrowAnEggAtAWall", 375 + "category": "Strategy" 376 + }, 377 + { 378 + "text": "There are no ko threats in the opening", 379 + "url": "https://senseis.xmp.net/?ThereAreNoKoThreatsInTheOpening", 380 + "category": "Strategy" 381 + }, 382 + { 383 + "text": "Strengthening your own weak group makes your opponent's weaker", 384 + "url": "https://senseis.xmp.net/?StrengtheningYourOwnWeakGroupMakesYourOpponentsWeaker", 385 + "category": "Strategy" 386 + }, 387 + { 388 + "text": "Don't go fishing while your house is on fire", 389 + "url": "https://senseis.xmp.net/?DontGoFishingWhileYourHouseIsOnFire", 390 + "category": "Strategy" 391 + }, 392 + { 393 + "text": "Never upset your star-point stones", 394 + "url": "https://senseis.xmp.net/?NeverUpsetYourStarPointStones", 395 + "category": "Strategy" 396 + }, 397 + { 398 + "text": "Greed for the win takes the win away", 399 + "url": "https://senseis.xmp.net/?GreedForTheWinTakesTheWinAway", 400 + "category": "Strategy" 401 + }, 402 + { 403 + "text": "High move (4th line) for influence, low move (3rd line) for territory", 404 + "url": "https://senseis.xmp.net/?HighAndLowMoves", 405 + "category": "Strategy" 406 + }, 407 + { 408 + "text": "If you have lost four corners, resign", 409 + "url": "https://senseis.xmp.net/?IfYouHaveLostFourCornersResign", 410 + "category": "Strategy" 411 + }, 412 + { 413 + "text": "Don't push from behind", 414 + "url": "https://senseis.xmp.net/?DontPushFromBehind", 415 + "category": "Strategy" 416 + }, 417 + { 418 + "text": "Don't push along the second line", 419 + "url": "https://senseis.xmp.net/?TheSecondLineIsTheRouteToDefeat", 420 + "category": "Strategy" 421 + }, 422 + { 423 + "text": "Riding the tiger it is difficult to get off", 424 + "url": "https://senseis.xmp.net/?RidingTheTigerItIsDifficultToGetOff", 425 + "category": "Strategy" 426 + }, 427 + { 428 + "text": "Don't push if you're not going to cut", 429 + "url": "https://senseis.xmp.net/?DontPushIfYoureNotGoingToCut", 430 + "category": "Strategy" 431 + }, 432 + { 433 + "text": "Don't follow proverbs blindly", 434 + "url": "https://senseis.xmp.net/?DontFollowProverbsBlindly", 435 + "category": "Meta" 436 + }, 437 + { 438 + "text": "Proverbs do not apply to White", 439 + "url": "https://senseis.xmp.net/?ProverbsDoNotApplyToWhite", 440 + "category": "Meta" 441 + }, 442 + { 443 + "text": "If It Has a Name Know It", 444 + "url": "https://senseis.xmp.net/?IfItHasANameKnowIt", 445 + "category": "Meta" 446 + }, 447 + { 448 + "text": "Use Go to meet friends", 449 + "url": "https://senseis.xmp.net/?UseGoToMeetFriends", 450 + "category": "Meta" 451 + }, 452 + { 453 + "text": "Learning Joseki loses two stones strength", 454 + "url": "https://senseis.xmp.net/?LearningJosekiLosesTwoStonesStrength", 455 + "category": "Meta" 456 + }, 457 + { 458 + "text": "Black should resign if one player has four corners", 459 + "url": "https://senseis.xmp.net/?BlackShouldResignIfOnePlayerHasFourCorners", 460 + "category": "Meta" 461 + }, 462 + { 463 + "text": "If you don't know ladders, don't play go", 464 + "url": "https://senseis.xmp.net/?IfYouDontKnowLaddersDontPlayGo", 465 + "category": "Meta" 466 + }, 467 + { 468 + "text": "You can play Go but don't let Go play you", 469 + "url": "https://senseis.xmp.net/?YouCanPlayGoButDontLetGoPlayYou", 470 + "category": "Meta" 471 + }, 472 + { 473 + "text": "If you don't like Ko don't Play Go", 474 + "url": "https://senseis.xmp.net/?IfYouDontLikeKoDontPlayGo", 475 + "category": "Meta" 476 + }, 477 + { 478 + "text": "Lose Your First 50 Games as Quickly as Possible", 479 + "url": "https://senseis.xmp.net/?LoseYourFirst50GamesAsQuicklyAsPossible", 480 + "category": "Meta" 481 + }, 482 + { 483 + "text": "The Threat Is Stronger Than Its Execution", 484 + "url": "https://senseis.xmp.net/?TheThreatIsStrongerThanItsExecution", 485 + "category": "Meta" 486 + }, 487 + { 488 + "text": "Only after the 10th punch will you see the fist", 489 + "url": "https://senseis.xmp.net/?OnlyAfterThe10thPunchWillYouSeeTheFist", 490 + "category": "Meta" 491 + }, 492 + { 493 + "text": "Kill two birds with one stone", 494 + "url": "https://senseis.xmp.net/?KillTwoBirdsWithOneStone", 495 + "category": "Modern" 496 + }, 497 + { 498 + "text": "You need half the points + 1", 499 + "url": "https://senseis.xmp.net/?YouNeedHalfThePoints1", 500 + "category": "Modern" 501 + }, 502 + { 503 + "text": "Never wrestle with a pig", 504 + "url": "https://senseis.xmp.net/?NeverWrestleWithAPig", 505 + "category": "Modern" 506 + }, 507 + { 508 + "text": "A bird in the hand is worth two in the bush", 509 + "url": "https://senseis.xmp.net/?BirdInTheHand", 510 + "category": "Modern" 511 + } 512 + ]
+163
src/lib/notifications.ts
··· 1 + import { browser } from '$app/environment'; 2 + 3 + export class GameNotifications { 4 + private originalTitle: string; 5 + private titleInterval: ReturnType<typeof setInterval> | null = null; 6 + private notificationPermission: NotificationPermission = 'default'; 7 + 8 + constructor() { 9 + if (browser) { 10 + this.originalTitle = document.title; 11 + // iOS Safari doesn't support the Notification API 12 + this.notificationPermission = ('Notification' in window) ? Notification.permission : 'denied'; 13 + } else { 14 + this.originalTitle = ''; 15 + } 16 + } 17 + 18 + /** 19 + * Request notification permission from the user 20 + */ 21 + async requestPermission(): Promise<boolean> { 22 + if (!browser || !('Notification' in window)) { 23 + return false; 24 + } 25 + 26 + if (Notification.permission === 'granted') { 27 + this.notificationPermission = 'granted'; 28 + return true; 29 + } 30 + 31 + if (Notification.permission !== 'denied') { 32 + const permission = await Notification.requestPermission(); 33 + this.notificationPermission = permission; 34 + return permission === 'granted'; 35 + } 36 + 37 + return false; 38 + } 39 + 40 + /** 41 + * Start flashing the page title to get attention 42 + */ 43 + startTitleFlash(message: string) { 44 + if (!browser) return; 45 + 46 + this.stopTitleFlash(); // Clear any existing flash 47 + this.originalTitle = document.title; 48 + 49 + let isOriginal = true; 50 + this.titleInterval = setInterval(() => { 51 + document.title = isOriginal ? message : this.originalTitle; 52 + isOriginal = !isOriginal; 53 + }, 1000); 54 + } 55 + 56 + /** 57 + * Stop flashing the title and restore the original 58 + */ 59 + stopTitleFlash() { 60 + if (!browser) return; 61 + 62 + if (this.titleInterval) { 63 + clearInterval(this.titleInterval); 64 + this.titleInterval = null; 65 + } 66 + document.title = this.originalTitle; 67 + } 68 + 69 + /** 70 + * Show a browser push notification 71 + */ 72 + async showNotification(title: string, options?: NotificationOptions) { 73 + if (!browser || !('Notification' in window)) { 74 + return; 75 + } 76 + 77 + // Auto-request permission if not denied 78 + if (this.notificationPermission === 'default') { 79 + await this.requestPermission(); 80 + } 81 + 82 + if (this.notificationPermission === 'granted') { 83 + try { 84 + const notification = new Notification(title, { 85 + icon: '/favicon.png', 86 + badge: '/favicon.png', 87 + ...options 88 + }); 89 + 90 + // Auto-close after 5 seconds 91 + setTimeout(() => notification.close(), 5000); 92 + 93 + return notification; 94 + } catch (err) { 95 + console.error('Failed to show notification:', err); 96 + } 97 + } 98 + } 99 + 100 + /** 101 + * Notify user of their turn with both title flash and push notification 102 + */ 103 + notifyYourTurn(opponentHandle?: string) { 104 + const message = opponentHandle 105 + ? `Your turn vs ${opponentHandle}!` 106 + : 'Your turn!'; 107 + 108 + this.startTitleFlash(`⚫ ${message}`); 109 + this.showNotification('Cloud Go - Your Turn', { 110 + body: message, 111 + tag: 'your-turn', 112 + requireInteraction: false 113 + }); 114 + } 115 + 116 + /** 117 + * Notify user of a new move in the game they're watching 118 + */ 119 + notifyNewMove(playerHandle?: string) { 120 + const message = playerHandle 121 + ? `${playerHandle} played a move` 122 + : 'New move played'; 123 + 124 + this.startTitleFlash(`🔄 ${message}`); 125 + this.showNotification('Cloud Go - New Move', { 126 + body: message, 127 + tag: 'new-move', 128 + requireInteraction: false 129 + }); 130 + } 131 + 132 + /** 133 + * Clean up when component unmounts 134 + */ 135 + cleanup() { 136 + this.stopTitleFlash(); 137 + } 138 + } 139 + 140 + /** 141 + * Check if the page is currently visible/focused 142 + */ 143 + export function isPageVisible(): boolean { 144 + if (!browser) return false; 145 + return document.visibilityState === 'visible'; 146 + } 147 + 148 + /** 149 + * Listen for visibility changes 150 + */ 151 + export function onVisibilityChange(callback: (visible: boolean) => void): () => void { 152 + if (!browser) return () => {}; 153 + 154 + const handler = () => { 155 + callback(document.visibilityState === 'visible'); 156 + }; 157 + 158 + document.addEventListener('visibilitychange', handler); 159 + 160 + return () => { 161 + document.removeEventListener('visibilitychange', handler); 162 + }; 163 + }
+105
src/lib/sound-manager.ts
··· 1 + import { browser } from '$app/environment'; 2 + 3 + export type SoundEffect = 4 + | 'capture' // When you capture opponent's stones 5 + | 'captured' // When your stones get captured 6 + | 'move_made' // When someone makes a move in your game (homepage notification) 7 + | 'opened_game' // When you open/load a game 8 + | 'played_stone'; // When placing a stone on the board 9 + 10 + export class SoundManager { 11 + private sounds: Map<SoundEffect, HTMLAudioElement> = new Map(); 12 + private enabled: boolean = true; 13 + 14 + constructor() { 15 + if (!browser) return; 16 + 17 + // Load SFX preference from cookie 18 + this.enabled = this.getSfxEnabled(); 19 + 20 + // Preload all sound effects 21 + this.preloadSound('capture', '/sfx/capture.wav'); 22 + this.preloadSound('captured', '/sfx/captured.wav'); 23 + this.preloadSound('move_made', '/sfx/move_made.wav'); 24 + this.preloadSound('opened_game', '/sfx/opened_game.wav'); 25 + this.preloadSound('played_stone', '/sfx/played_stone.wav'); 26 + } 27 + 28 + private preloadSound(name: SoundEffect, path: string) { 29 + if (!browser) return; 30 + 31 + const audio = new Audio(path); 32 + audio.preload = 'auto'; 33 + audio.volume = 0.5; // Default volume at 50% 34 + this.sounds.set(name, audio); 35 + } 36 + 37 + play(sound: SoundEffect) { 38 + if (!browser || !this.enabled) return; 39 + 40 + const audio = this.sounds.get(sound); 41 + if (audio) { 42 + // Clone the audio to allow overlapping sounds 43 + const clone = audio.cloneNode() as HTMLAudioElement; 44 + clone.volume = audio.volume; 45 + clone.play().catch(err => { 46 + console.warn('Failed to play sound:', sound, err); 47 + }); 48 + } 49 + } 50 + 51 + setEnabled(enabled: boolean) { 52 + this.enabled = enabled; 53 + this.setSfxCookie(enabled); 54 + } 55 + 56 + isEnabled(): boolean { 57 + return this.enabled; 58 + } 59 + 60 + setVolume(volume: number) { 61 + if (!browser) return; 62 + 63 + // Clamp volume between 0 and 1 64 + volume = Math.max(0, Math.min(1, volume)); 65 + 66 + for (const audio of this.sounds.values()) { 67 + audio.volume = volume; 68 + } 69 + } 70 + 71 + private getSfxEnabled(): boolean { 72 + if (!browser) return true; 73 + 74 + const value = this.getCookie('sfx-enabled'); 75 + if (value === null) return true; // Default to enabled 76 + return value === 'true'; 77 + } 78 + 79 + private setSfxCookie(enabled: boolean) { 80 + if (!browser) return; 81 + 82 + const expires = new Date(); 83 + expires.setFullYear(expires.getFullYear() + 1); // Expire in 1 year 84 + document.cookie = `sfx-enabled=${enabled};expires=${expires.toUTCString()};path=/`; 85 + } 86 + 87 + private getCookie(name: string): string | null { 88 + if (!browser) return null; 89 + 90 + const value = `; ${document.cookie}`; 91 + const parts = value.split(`; ${name}=`); 92 + if (parts.length === 2) return parts.pop()?.split(';').shift() || null; 93 + return null; 94 + } 95 + } 96 + 97 + // Global sound manager instance 98 + let soundManager: SoundManager | null = null; 99 + 100 + export function getSoundManager(): SoundManager { 101 + if (!soundManager) { 102 + soundManager = new SoundManager(); 103 + } 104 + return soundManager; 105 + }
static/reaction-template.png

This is a binary file and will not be displayed.

static/sfx/capture.wav

This is a binary file and will not be displayed.

static/sfx/captured.wav

This is a binary file and will not be displayed.

static/sfx/move_made.wav

This is a binary file and will not be displayed.

static/sfx/opened_game.wav

This is a binary file and will not be displayed.

static/sfx/played_stone.wav

This is a binary file and will not be displayed.